rig-c 0.0.0-stage → 2.20.4
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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
package/src/diff.ts
ADDED
|
@@ -0,0 +1,2252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rigc diff — structural comparison of two skeletons, section by section.
|
|
3
|
+
*
|
|
4
|
+
* The benchmark ladder needs a yardstick, and a yardstick that reports one
|
|
5
|
+
* number is not one. A single "87% match" cannot distinguish a rig with the
|
|
6
|
+
* right skeleton and the wrong timing from a rig with the right timing and the
|
|
7
|
+
* wrong skeleton, and those are opposite diagnoses. So this reports a ratio per
|
|
8
|
+
* MEASURE, grouped into sections, and refuses to combine them: the section
|
|
9
|
+
* ratios are means of their own measures and are labelled as such, and there is
|
|
10
|
+
* no report-wide score at all.
|
|
11
|
+
*
|
|
12
|
+
* Three properties the measures are built to have:
|
|
13
|
+
*
|
|
14
|
+
* 1. **`diff X X` is 1.000 everywhere.** Every measure is a comparison of two
|
|
15
|
+
* derived quantities, never a judgement, so a file against itself has
|
|
16
|
+
* nothing to differ on. The CLI has a `--self-check` for exactly this, and
|
|
17
|
+
* the selftest runs it: a comparison tool that cannot recognise identity is
|
|
18
|
+
* reporting noise, and noise looks like a small honest gap.
|
|
19
|
+
* 2. **A difference moves as few measures as possible.** Reordering slots must
|
|
20
|
+
* not disturb the slot-to-bone binding figure, so bindings are compared by
|
|
21
|
+
* name and order is a separate measure. This is what makes the report
|
|
22
|
+
* diagnostic rather than merely quantitative.
|
|
23
|
+
* 3. **Name-agnostic figures sit beside name-matched ones.** A candidate that
|
|
24
|
+
* builds the right tree with its own names scores zero on `parent_by_name`
|
|
25
|
+
* and full marks on `depth_histogram` / `degree_sequence`. Reporting only
|
|
26
|
+
* the first would call a correct rig a total failure; reporting only the
|
|
27
|
+
* second would call any 14-bone tree a match. Both, separately, or neither
|
|
28
|
+
* is honest.
|
|
29
|
+
*
|
|
30
|
+
* ⭐ That held at the MEASURE level and not at the SECTION level, which is
|
|
31
|
+
* where a reader actually looks (issue #21). `bones` rolled eight measures
|
|
32
|
+
* into one mean, five of them gated on the same one-name-in-common
|
|
33
|
+
* condition — the naming figure counted five times, not five findings — so
|
|
34
|
+
* a rig with a structurally identical tree and its own vocabulary read
|
|
35
|
+
* `bones=0.567` and a reader who did not open the table under it read "the
|
|
36
|
+
* skeleton is wrong" when the skeleton was right. So `bones` and `slots`
|
|
37
|
+
* now carry a SECOND, independent comparison in `nameAgnostic`: the same
|
|
38
|
+
* two skeletons compared with names thrown away entirely.
|
|
39
|
+
*
|
|
40
|
+
* The two are not a partition of one mean. They are two comparisons of one
|
|
41
|
+
* section, with their own measure sets, and neither is a subset of the
|
|
42
|
+
* other — `section.ratio` is unchanged, to the digit, from what it has
|
|
43
|
+
* always been, so every `bench.json` already on disk stays comparable.
|
|
44
|
+
* Read them as a pair: name-agnostic 1.000 with name-matched low says the
|
|
45
|
+
* shape is right and the vocabulary differs; both low says the rig is
|
|
46
|
+
* wrong; name-agnostic low alone is impossible, since a wrong shape cannot
|
|
47
|
+
* have right names.
|
|
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
|
+
*
|
|
77
|
+
* 4. **A measure that cannot gate is not in the mean.** `section.reported`
|
|
78
|
+
* carries the measures `docs/GATE.md`'s *What never gates* calls
|
|
79
|
+
* unobservable by construction — *"could any reading of the frames have
|
|
80
|
+
* decided it?"*, and for these the answer is no whatever the frames are.
|
|
81
|
+
* They are printed beside the section, with their conventions, and they have
|
|
82
|
+
* **no mean at all**: an average of "does this mesh declare edges" and "how
|
|
83
|
+
* many keys per second" is a number with no referent, and a section mean is
|
|
84
|
+
* the one figure a stored ladder row quotes. Same discipline as point 3 and
|
|
85
|
+
* the same reason — `section.ratio` does not move when a reported measure is
|
|
86
|
+
* added, so every `bench.json` already on disk stays comparable — with one
|
|
87
|
+
* addition of its own: *reports, never gates* becomes a property of where the
|
|
88
|
+
* measure sits rather than of a sentence somebody has to remember.
|
|
89
|
+
*
|
|
90
|
+
* Pure JSON reading — no spine-core, no filesystem. Validity is `validate.ts`'s
|
|
91
|
+
* job and this file assumes nothing about it. In particular the name-agnostic
|
|
92
|
+
* measures resolve nothing through the atlas: see `attachments.region_size`.
|
|
93
|
+
* The per-frame pose comparison that DOES need spine-core is a separate
|
|
94
|
+
* instrument — [`bonedist.ts`](bonedist.ts), the ladder's stage 3.
|
|
95
|
+
*/
|
|
96
|
+
import { constraintAt } from './rig.ts';
|
|
97
|
+
import { walkTimelines } from './timelines.ts';
|
|
98
|
+
// Type only, and erased: the values themselves are read by `skeletonValues` in
|
|
99
|
+
// `src/validate.ts`, which is one of the three modules CLAUDE.md allows to link
|
|
100
|
+
// spine-core. This file stays what its header says it is — see
|
|
101
|
+
// `diffSkeletonValues` for why the reading and the comparing are split there.
|
|
102
|
+
import type { SkeletonValue } from './validate.ts';
|
|
103
|
+
|
|
104
|
+
type Json = Record<string, unknown>;
|
|
105
|
+
|
|
106
|
+
function isObj(v: unknown): v is Json {
|
|
107
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function arr(v: unknown): unknown[] {
|
|
111
|
+
return Array.isArray(v) ? v : [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function objs(v: unknown): Json[] {
|
|
115
|
+
return arr(v).filter(isObj);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function str(v: unknown): string | null {
|
|
119
|
+
return typeof v === 'string' ? v : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function num(v: unknown): number | null {
|
|
123
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One comparable quantity. `total` 0 means neither side had anything to compare. */
|
|
127
|
+
export interface DiffMeasure {
|
|
128
|
+
/** Stable dotted id, e.g. `bones.parent_by_name`. Tests name these. */
|
|
129
|
+
id: string;
|
|
130
|
+
matched: number;
|
|
131
|
+
total: number;
|
|
132
|
+
ratio: number;
|
|
133
|
+
/** What the measure is, in one line, for the human table. */
|
|
134
|
+
what: string;
|
|
135
|
+
/** Set when the measure is vacuous or otherwise needs a caveat. */
|
|
136
|
+
note?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A second comparison of one section, made without consulting a name anywhere.
|
|
141
|
+
*
|
|
142
|
+
* Its `ratio` is an unweighted mean of its OWN measures and is not comparable,
|
|
143
|
+
* term for term, with the section's — the two sets overlap but neither contains
|
|
144
|
+
* the other. `count` / `depth_histogram` / `degree_sequence` appear in both,
|
|
145
|
+
* once under their section id and once under `<section>.agnostic.*`, so that
|
|
146
|
+
* each report reads on its own without the other open beside it.
|
|
147
|
+
*/
|
|
148
|
+
export interface DiffAgnostic {
|
|
149
|
+
/** Unweighted mean of the measures below. NOT a quality score either. */
|
|
150
|
+
ratio: number;
|
|
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;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Measures that are **reported and never gate**, with no mean over them.
|
|
168
|
+
*
|
|
169
|
+
* `docs/GATE.md`'s *What never gates* seals off "anything unobservable by
|
|
170
|
+
* construction", and its test is *could any reading of the frames have decided
|
|
171
|
+
* it?* — mesh `edges` draw no pixel at all, and two keyings of one curve render
|
|
172
|
+
* the same frames at every rate. Those measures still belong in the report:
|
|
173
|
+
* `bench` is a structural comparison against the reference export and a feature
|
|
174
|
+
* the reference declares that a candidate drops is a finding, whether or not a
|
|
175
|
+
* clause may read it.
|
|
176
|
+
*
|
|
177
|
+
* ⚠️ No `ratio` field, deliberately, and not for lack of arithmetic. The
|
|
178
|
+
* measures here have unlike units — a presence share, a keys-per-second
|
|
179
|
+
* agreement — so their mean would be a number with no referent, and the one
|
|
180
|
+
* number a stored ladder row quotes per section is `DiffSection.ratio`. Keeping
|
|
181
|
+
* these out of it is what lets a measure be added without moving a figure
|
|
182
|
+
* somebody has already recorded.
|
|
183
|
+
*/
|
|
184
|
+
export interface DiffReported {
|
|
185
|
+
measures: DiffMeasure[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface DiffSection {
|
|
189
|
+
name: string;
|
|
190
|
+
/** Unweighted mean of this section's measures. NOT a quality score. */
|
|
191
|
+
ratio: number;
|
|
192
|
+
measures: DiffMeasure[];
|
|
193
|
+
/**
|
|
194
|
+
* The same section compared with names thrown away — `bones` and `slots`
|
|
195
|
+
* only, because they are the two sections whose measures are dominated by
|
|
196
|
+
* name-keyed ones (#21). Absent elsewhere rather than empty: a section with
|
|
197
|
+
* no name-agnostic comparison defined should say so by having none.
|
|
198
|
+
*/
|
|
199
|
+
nameAgnostic?: DiffAgnostic;
|
|
200
|
+
/**
|
|
201
|
+
* Measures reported beside this section and folded into nothing. Absent
|
|
202
|
+
* rather than empty, for the same reason `nameAgnostic` is.
|
|
203
|
+
*/
|
|
204
|
+
reported?: DiffReported;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface DiffReport {
|
|
208
|
+
sections: DiffSection[];
|
|
209
|
+
/**
|
|
210
|
+
* The `skeleton` block itself — measured, and reported beside the sections
|
|
211
|
+
* rather than inside one (issue #578).
|
|
212
|
+
*
|
|
213
|
+
* ⭐ It is a `DiffReported` and not a seventh `DiffSection`, and the reason is
|
|
214
|
+
* the one thing a section must have: a `ratio`. A section's mean is the figure
|
|
215
|
+
* a stored `bench.json` row quotes, and these measures cannot be in one —
|
|
216
|
+
* `docs/GATE.md`'s *What never gates* covers them twice over, so a `skeleton`
|
|
217
|
+
* section would carry either a vacuous `mean 1.000 over 0 measures` (the false
|
|
218
|
+
* green this file exists to refuse) or a nullable `ratio` every reader of every
|
|
219
|
+
* other section would then have to handle. `DiffReported` already models
|
|
220
|
+
* exactly "measures with no mean over them", which is what the header is.
|
|
221
|
+
*
|
|
222
|
+
* ⚠️ Not optional, deliberately: a block that may be absent is a block that can
|
|
223
|
+
* silently stop being emitted, and `movedReportedMeasures` over an absent one
|
|
224
|
+
* is the empty list — indistinguishable from one that is all 1.000. The
|
|
225
|
+
* selftest asserts its COUNT for the same reason it does for the others.
|
|
226
|
+
*/
|
|
227
|
+
header: DiffReported;
|
|
228
|
+
/** Raw counts either side, for orientation. Never combined into anything. */
|
|
229
|
+
candidate: Record<string, number>;
|
|
230
|
+
reference: Record<string, number>;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// measure primitives
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
const FRAME = 1 / 60;
|
|
238
|
+
|
|
239
|
+
function ratioOf(matched: number, total: number): number {
|
|
240
|
+
return total === 0 ? 1 : matched / total;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function measure(id: string, what: string, matched: number, total: number, note?: string): DiffMeasure {
|
|
244
|
+
return {
|
|
245
|
+
id,
|
|
246
|
+
what,
|
|
247
|
+
matched,
|
|
248
|
+
total,
|
|
249
|
+
ratio: ratioOf(matched, total),
|
|
250
|
+
...(total === 0 ? { note: note ?? 'neither side has any' } : note ? { note } : {}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** |A ∩ B| over |A ∪ B| — the set measure. */
|
|
255
|
+
function jaccard(id: string, what: string, a: Set<string>, b: Set<string>): DiffMeasure {
|
|
256
|
+
let shared = 0;
|
|
257
|
+
for (const x of a) if (b.has(x)) shared++;
|
|
258
|
+
return measure(id, what, shared, a.size + b.size - shared);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Histogram intersection: sum of the per-bucket minima over the larger total.
|
|
263
|
+
* Used for every name-agnostic comparison, because it degrades smoothly — one
|
|
264
|
+
* bone in the wrong bucket costs one bone, not the whole measure.
|
|
265
|
+
*/
|
|
266
|
+
function histogram(id: string, what: string, a: Map<string, number>, b: Map<string, number>): DiffMeasure {
|
|
267
|
+
let shared = 0;
|
|
268
|
+
for (const [k, v] of a) shared += Math.min(v, b.get(k) ?? 0);
|
|
269
|
+
const sum = (m: Map<string, number>) => [...m.values()].reduce((x, y) => x + y, 0);
|
|
270
|
+
return measure(id, what, shared, Math.max(sum(a), sum(b)));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Longest common subsequence length — order agreement that survives an insert. */
|
|
274
|
+
function lcs(a: string[], b: string[]): number {
|
|
275
|
+
const rows: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
|
|
276
|
+
for (let i = 1; i <= a.length; i++) {
|
|
277
|
+
for (let j = 1; j <= b.length; j++) {
|
|
278
|
+
rows[i][j] = a[i - 1] === b[j - 1] ? rows[i - 1][j - 1] + 1 : Math.max(rows[i - 1][j], rows[i][j - 1]);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return rows[a.length][b.length];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function counted(values: Iterable<string>): Map<string, number> {
|
|
285
|
+
const out = new Map<string, number>();
|
|
286
|
+
for (const v of values) out.set(v, (out.get(v) ?? 0) + 1);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Agreement over the names both sides have, scored against the larger roster.
|
|
292
|
+
*
|
|
293
|
+
* ⚠️ The denominator is deliberately `max(candidate, reference)` rather than the
|
|
294
|
+
* shared count. Scoring only the shared names would let a candidate with two of
|
|
295
|
+
* twenty bones report 1.000 for "parents agree", which is the shape of a
|
|
296
|
+
* measurement that flatters whatever is missing.
|
|
297
|
+
*/
|
|
298
|
+
function agreement<T>(
|
|
299
|
+
id: string,
|
|
300
|
+
what: string,
|
|
301
|
+
a: Map<string, T>,
|
|
302
|
+
b: Map<string, T>,
|
|
303
|
+
same: (x: T, y: T) => boolean,
|
|
304
|
+
): DiffMeasure {
|
|
305
|
+
let agree = 0;
|
|
306
|
+
for (const [k, v] of a) {
|
|
307
|
+
const other = b.get(k);
|
|
308
|
+
if (other !== undefined && same(v, other)) agree++;
|
|
309
|
+
}
|
|
310
|
+
return measure(id, what, agree, Math.max(a.size, b.size));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function meanRatio(measures: DiffMeasure[]): number {
|
|
314
|
+
return measures.length === 0 ? 1 : measures.reduce((s, m) => s + m.ratio, 0) / measures.length;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function sectionOf(
|
|
318
|
+
name: string,
|
|
319
|
+
measures: DiffMeasure[],
|
|
320
|
+
nameAgnostic?: DiffMeasure[],
|
|
321
|
+
reported?: DiffMeasure[],
|
|
322
|
+
pairedBy?: string,
|
|
323
|
+
): DiffSection {
|
|
324
|
+
return {
|
|
325
|
+
name,
|
|
326
|
+
ratio: meanRatio(measures),
|
|
327
|
+
measures,
|
|
328
|
+
...(nameAgnostic === undefined
|
|
329
|
+
? {}
|
|
330
|
+
: {
|
|
331
|
+
nameAgnostic: {
|
|
332
|
+
ratio: meanRatio(nameAgnostic),
|
|
333
|
+
measures: nameAgnostic,
|
|
334
|
+
...(pairedBy === undefined ? {} : { pairedBy }),
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
337
|
+
...(reported === undefined ? {} : { reported: { measures: reported } }),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* How many decimal places a reported RATE is compared at.
|
|
343
|
+
*
|
|
344
|
+
* The counts column prints `matched`/`total` verbatim, so a rate measured to
|
|
345
|
+
* full float precision would print seventeen digits of it. Rounding here rather
|
|
346
|
+
* than at print time keeps `ratio === matched / total` true in the data — a
|
|
347
|
+
* printed figure that is not the compared quantity is the same defect as a
|
|
348
|
+
* measure nobody asserts on, one layer down.
|
|
349
|
+
*/
|
|
350
|
+
const RATE_PLACES = 3;
|
|
351
|
+
|
|
352
|
+
function atPlaces(n: number): number {
|
|
353
|
+
const scale = 10 ** RATE_PLACES;
|
|
354
|
+
return Math.round(n * scale) / scale;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Two rates compared as `min / max`, so that identity reads 1.000 and either
|
|
359
|
+
* direction of disagreement reads below it.
|
|
360
|
+
*
|
|
361
|
+
* ⚠️ The direction is the whole point of the measure and a ratio cannot carry
|
|
362
|
+
* it, so `note` states both rates and which side is the bigger one. A histogram
|
|
363
|
+
* intersection over the same quantity reads 421/1339 whether the candidate has
|
|
364
|
+
* a third of the reference's keys or three times them — that ambiguity is what
|
|
365
|
+
* [issue #20](https://github.com/firejune/rigc/issues/20) turned on, and a
|
|
366
|
+
* figure that repeated it would be the second copy of the same defect.
|
|
367
|
+
*/
|
|
368
|
+
function rateAgreement(id: string, what: string, unit: string, a: number, b: number, convention: string): DiffMeasure {
|
|
369
|
+
const candidate = atPlaces(a);
|
|
370
|
+
const reference = atPlaces(b);
|
|
371
|
+
const lo = Math.min(candidate, reference);
|
|
372
|
+
const hi = Math.max(candidate, reference);
|
|
373
|
+
const direction =
|
|
374
|
+
hi === 0
|
|
375
|
+
? 'neither side has any'
|
|
376
|
+
: candidate === reference
|
|
377
|
+
? 'the two agree'
|
|
378
|
+
: reference === 0
|
|
379
|
+
? 'the reference has none'
|
|
380
|
+
: candidate === 0
|
|
381
|
+
? 'the candidate has none'
|
|
382
|
+
: `candidate carries ${(candidate / reference).toFixed(2)}x — ${candidate > reference ? 'OVER' : 'UNDER'}-keyed`;
|
|
383
|
+
return {
|
|
384
|
+
id,
|
|
385
|
+
what,
|
|
386
|
+
matched: lo,
|
|
387
|
+
total: hi,
|
|
388
|
+
ratio: ratioOf(lo, hi),
|
|
389
|
+
note: `candidate ${candidate} vs reference ${reference} ${unit}; ${direction}. ${convention}`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Order agreement over a sequence of SHAPES rather than names — the
|
|
395
|
+
* name-agnostic half of an `order` measure.
|
|
396
|
+
*
|
|
397
|
+
* LCS over the signatures, against the larger roster, exactly as the
|
|
398
|
+
* name-matched `order` measures do it, so the two read on the same scale. Two
|
|
399
|
+
* elements with the same signature are interchangeable here and swapping them
|
|
400
|
+
* is correctly invisible: name-agnostically they ARE the same element. The
|
|
401
|
+
* name-matched `order` measure is what catches that swap.
|
|
402
|
+
*/
|
|
403
|
+
function orderShape(id: string, what: string, a: string[], b: string[]): DiffMeasure {
|
|
404
|
+
return measure(id, what, lcs(a, b), Math.max(a.length, b.length));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// per-section extraction
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
interface BoneFacts {
|
|
412
|
+
order: string[];
|
|
413
|
+
parent: Map<string, string | null>;
|
|
414
|
+
hasLength: Map<string, boolean>;
|
|
415
|
+
hasInherit: Map<string, boolean>;
|
|
416
|
+
depths: Map<string, number>;
|
|
417
|
+
children: Map<string, number>;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function boneFacts(root: Json): BoneFacts {
|
|
421
|
+
const bones = objs(root.bones);
|
|
422
|
+
const order: string[] = [];
|
|
423
|
+
const parent = new Map<string, string | null>();
|
|
424
|
+
const hasLength = new Map<string, boolean>();
|
|
425
|
+
const hasInherit = new Map<string, boolean>();
|
|
426
|
+
const children = new Map<string, number>();
|
|
427
|
+
for (const b of bones) {
|
|
428
|
+
const name = str(b.name);
|
|
429
|
+
if (name === null) continue;
|
|
430
|
+
order.push(name);
|
|
431
|
+
parent.set(name, str(b.parent));
|
|
432
|
+
hasLength.set(name, 'length' in b);
|
|
433
|
+
hasInherit.set(name, 'inherit' in b);
|
|
434
|
+
children.set(name, children.get(name) ?? 0);
|
|
435
|
+
}
|
|
436
|
+
for (const [, p] of parent) {
|
|
437
|
+
if (p !== null) children.set(p, (children.get(p) ?? 0) + 1);
|
|
438
|
+
}
|
|
439
|
+
// Depth by walking to the root. A parent declared after its child is invalid
|
|
440
|
+
// Spine, so the walk terminates on any file the parser would accept; the
|
|
441
|
+
// `seen` guard is there so a malformed candidate cannot hang the comparison.
|
|
442
|
+
const depths = new Map<string, number>();
|
|
443
|
+
for (const name of order) {
|
|
444
|
+
let depth = 0;
|
|
445
|
+
const seen = new Set<string>([name]);
|
|
446
|
+
for (let at = parent.get(name) ?? null; at !== null; at = parent.get(at) ?? null) {
|
|
447
|
+
depth++;
|
|
448
|
+
if (seen.has(at)) break;
|
|
449
|
+
seen.add(at);
|
|
450
|
+
}
|
|
451
|
+
depths.set(name, depth);
|
|
452
|
+
}
|
|
453
|
+
return { order, parent, hasLength, hasInherit, depths, children };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The shape of a bone nothing declares — a slot bound to a name no bone
|
|
458
|
+
* carries. A real answer rather than a gap: it says "this hangs off nothing".
|
|
459
|
+
*/
|
|
460
|
+
const UNDECLARED = '?';
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* A bone's place in the tree, written without its name: how far it sits from a
|
|
464
|
+
* root, and how many children hang off it. `d1c3` is "one hop down, three
|
|
465
|
+
* children".
|
|
466
|
+
*
|
|
467
|
+
* Nothing else in a bone's declaration is name-free AND structural. `x`, `y`,
|
|
468
|
+
* `rotation`, `scale` are the setup pose, which this section does not compare
|
|
469
|
+
* on either side of the split; `length` and `inherit` are compared by name
|
|
470
|
+
* above and have no name-free counterpart, because there is no way to say
|
|
471
|
+
* WHICH bone is missing its length without naming one.
|
|
472
|
+
*/
|
|
473
|
+
function boneShape(facts: BoneFacts, name: string): string {
|
|
474
|
+
if (!facts.parent.has(name)) return UNDECLARED;
|
|
475
|
+
return `d${facts.depths.get(name) ?? 0}c${facts.children.get(name) ?? 0}`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function boneShapes(facts: BoneFacts): string[] {
|
|
479
|
+
return facts.order.map((n) => boneShape(facts, n));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function diffBones(c: Json, r: Json): DiffSection {
|
|
483
|
+
const a = boneFacts(c);
|
|
484
|
+
const b = boneFacts(r);
|
|
485
|
+
const shared = a.order.filter((n) => b.parent.has(n));
|
|
486
|
+
const max = Math.max(a.order.length, b.order.length);
|
|
487
|
+
const aShapes = boneShapes(a);
|
|
488
|
+
const bShapes = boneShapes(b);
|
|
489
|
+
return sectionOf(
|
|
490
|
+
'bones',
|
|
491
|
+
[
|
|
492
|
+
measure('bones.count', 'how many bones', Math.min(a.order.length, b.order.length), max),
|
|
493
|
+
jaccard('bones.names', 'the bone names themselves', new Set(a.order), new Set(b.order)),
|
|
494
|
+
agreement('bones.parent_by_name', 'each bone hangs off the same parent', a.parent, b.parent, (x, y) => x === y),
|
|
495
|
+
measure(
|
|
496
|
+
'bones.order',
|
|
497
|
+
'the bones are declared in the same order',
|
|
498
|
+
lcs(shared, b.order.filter((n) => a.parent.has(n))),
|
|
499
|
+
max,
|
|
500
|
+
),
|
|
501
|
+
agreement('bones.length_present', 'a setup `length` is present or absent alike', a.hasLength, b.hasLength, (x, y) => x === y),
|
|
502
|
+
agreement('bones.inherit_present', 'a setup `inherit` is present or absent alike', a.hasInherit, b.hasInherit, (x, y) => x === y),
|
|
503
|
+
histogram(
|
|
504
|
+
'bones.depth_histogram',
|
|
505
|
+
'NAME-AGNOSTIC: as many bones at each depth',
|
|
506
|
+
counted([...a.depths.values()].map(String)),
|
|
507
|
+
counted([...b.depths.values()].map(String)),
|
|
508
|
+
),
|
|
509
|
+
histogram(
|
|
510
|
+
'bones.degree_sequence',
|
|
511
|
+
'NAME-AGNOSTIC: as many bones with each child count',
|
|
512
|
+
counted([...a.children.values()].map(String)),
|
|
513
|
+
counted([...b.children.values()].map(String)),
|
|
514
|
+
),
|
|
515
|
+
],
|
|
516
|
+
// The tree compared as a tree — no name is consulted anywhere below.
|
|
517
|
+
[
|
|
518
|
+
measure('bones.agnostic.count', 'how many bones', Math.min(a.order.length, b.order.length), max),
|
|
519
|
+
histogram(
|
|
520
|
+
'bones.agnostic.depth_histogram',
|
|
521
|
+
'as many bones at each depth',
|
|
522
|
+
counted([...a.depths.values()].map(String)),
|
|
523
|
+
counted([...b.depths.values()].map(String)),
|
|
524
|
+
),
|
|
525
|
+
histogram(
|
|
526
|
+
'bones.agnostic.degree_sequence',
|
|
527
|
+
'as many bones with each child count',
|
|
528
|
+
counted([...a.children.values()].map(String)),
|
|
529
|
+
counted([...b.children.values()].map(String)),
|
|
530
|
+
),
|
|
531
|
+
// Strictly stronger than the two above it, and it earns its place for
|
|
532
|
+
// that reason: two trees can hold the same depths and the same child
|
|
533
|
+
// counts while pairing them up differently — a deep leaf and a shallow
|
|
534
|
+
// fork against a shallow leaf and a deep fork. This asks for the pair.
|
|
535
|
+
histogram(
|
|
536
|
+
'bones.agnostic.shape_histogram',
|
|
537
|
+
'as many bones of each depth-and-child-count shape (`d1c3` = one hop down, three children)',
|
|
538
|
+
counted(aShapes),
|
|
539
|
+
counted(bShapes),
|
|
540
|
+
),
|
|
541
|
+
orderShape(
|
|
542
|
+
'bones.agnostic.order_shape',
|
|
543
|
+
'the bones are declared in the same order of shapes',
|
|
544
|
+
aShapes,
|
|
545
|
+
bShapes,
|
|
546
|
+
),
|
|
547
|
+
],
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
interface SlotFacts {
|
|
552
|
+
order: string[];
|
|
553
|
+
bone: Map<string, string | null>;
|
|
554
|
+
attachment: Map<string, string | null>;
|
|
555
|
+
blend: Map<string, string>;
|
|
556
|
+
hasColor: Map<string, boolean>;
|
|
557
|
+
/**
|
|
558
|
+
* `<slot>` -> the TYPE of what it shows in setup: `region`, `mesh`, and so
|
|
559
|
+
* on; `none` when the slot shows nothing; `absent` when it names an
|
|
560
|
+
* attachment no skin carries. The type is name-free — it is *what kind of
|
|
561
|
+
* thing is drawn here*, which survives every rename — while the attachment's
|
|
562
|
+
* own name is compared by `slots.attachment` above.
|
|
563
|
+
*/
|
|
564
|
+
setupType: Map<string, string>;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** `<slot>/<attachment>` -> type, over every skin. */
|
|
568
|
+
function attachmentTypes(root: Json): Map<string, string> {
|
|
569
|
+
const out = new Map<string, string>();
|
|
570
|
+
for (const skin of objs(root.skins)) {
|
|
571
|
+
if (!isObj(skin.attachments)) continue;
|
|
572
|
+
for (const [slotName, slotMap] of Object.entries(skin.attachments)) {
|
|
573
|
+
if (!isObj(slotMap)) continue;
|
|
574
|
+
for (const [attName, att] of Object.entries(slotMap)) {
|
|
575
|
+
if (!isObj(att)) continue;
|
|
576
|
+
// First skin wins. Every skeleton on the ladder has exactly one skin
|
|
577
|
+
// (`default`), and a per-skin type comparison would need a skin
|
|
578
|
+
// correspondence, which is a naming question by another route.
|
|
579
|
+
if (!out.has(`${slotName}/${attName}`)) out.set(`${slotName}/${attName}`, str(att.type) ?? 'region');
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return out;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function slotFacts(root: Json): SlotFacts {
|
|
587
|
+
const order: string[] = [];
|
|
588
|
+
const bone = new Map<string, string | null>();
|
|
589
|
+
const attachment = new Map<string, string | null>();
|
|
590
|
+
const blend = new Map<string, string>();
|
|
591
|
+
const hasColor = new Map<string, boolean>();
|
|
592
|
+
const setupType = new Map<string, string>();
|
|
593
|
+
const types = attachmentTypes(root);
|
|
594
|
+
for (const s of objs(root.slots)) {
|
|
595
|
+
const name = str(s.name);
|
|
596
|
+
if (name === null) continue;
|
|
597
|
+
const setup = str(s.attachment);
|
|
598
|
+
order.push(name);
|
|
599
|
+
bone.set(name, str(s.bone));
|
|
600
|
+
attachment.set(name, setup);
|
|
601
|
+
blend.set(name, str(s.blend) ?? 'normal');
|
|
602
|
+
hasColor.set(name, 'color' in s);
|
|
603
|
+
setupType.set(name, setup === null ? 'none' : (types.get(`${name}/${setup}`) ?? 'absent'));
|
|
604
|
+
}
|
|
605
|
+
return { order, bone, attachment, blend, hasColor, setupType };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** What a slot is, written without a name: what it draws, on what shape of bone. */
|
|
609
|
+
function slotShapes(facts: SlotFacts, bones: BoneFacts): string[] {
|
|
610
|
+
return facts.order.map((n) => `${facts.setupType.get(n) ?? 'none'}@${boneShape(bones, facts.bone.get(n) ?? '')}`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function diffSlots(c: Json, r: Json): DiffSection {
|
|
614
|
+
const a = slotFacts(c);
|
|
615
|
+
const b = slotFacts(r);
|
|
616
|
+
const ab = boneFacts(c);
|
|
617
|
+
const bb = boneFacts(r);
|
|
618
|
+
const max = Math.max(a.order.length, b.order.length);
|
|
619
|
+
const aShapes = slotShapes(a, ab);
|
|
620
|
+
const bShapes = slotShapes(b, bb);
|
|
621
|
+
const byPosition = (f: SlotFacts): Map<string, number> =>
|
|
622
|
+
counted(f.order.map((n, i) => `${i}:${f.setupType.get(n) ?? 'none'}`));
|
|
623
|
+
const boundTo = (f: SlotFacts, bones: BoneFacts): Map<string, number> =>
|
|
624
|
+
counted(f.order.map((n) => boneShape(bones, f.bone.get(n) ?? '')));
|
|
625
|
+
return sectionOf(
|
|
626
|
+
'slots',
|
|
627
|
+
[
|
|
628
|
+
measure('slots.count', 'how many slots', Math.min(a.order.length, b.order.length), max),
|
|
629
|
+
jaccard('slots.names', 'the slot names themselves', new Set(a.order), new Set(b.order)),
|
|
630
|
+
measure(
|
|
631
|
+
'slots.order',
|
|
632
|
+
'the slots array IS the draw order, so its order is data',
|
|
633
|
+
lcs(
|
|
634
|
+
a.order.filter((n) => b.bone.has(n)),
|
|
635
|
+
b.order.filter((n) => a.bone.has(n)),
|
|
636
|
+
),
|
|
637
|
+
max,
|
|
638
|
+
),
|
|
639
|
+
agreement('slots.bone', 'each slot is bound to the same bone', a.bone, b.bone, (x, y) => x === y),
|
|
640
|
+
agreement('slots.attachment', 'each slot shows the same setup attachment', a.attachment, b.attachment, (x, y) => x === y),
|
|
641
|
+
agreement('slots.blend', 'each slot uses the same blend mode', a.blend, b.blend, (x, y) => x === y),
|
|
642
|
+
agreement('slots.color_present', 'a tint is present or absent alike', a.hasColor, b.hasColor, (x, y) => x === y),
|
|
643
|
+
],
|
|
644
|
+
[
|
|
645
|
+
measure('slots.agnostic.count', 'how many slots', Math.min(a.order.length, b.order.length), max),
|
|
646
|
+
// Positional on purpose, and paired with the LCS measure below for the
|
|
647
|
+
// same reason `slots.order` is paired with `slots.names`: this one says
|
|
648
|
+
// "position 3 draws a mesh on both sides", the LCS one degrades smoothly
|
|
649
|
+
// when a slot is inserted rather than counting every later slot wrong.
|
|
650
|
+
histogram(
|
|
651
|
+
'slots.agnostic.attachment_types_by_position',
|
|
652
|
+
'the same kind of attachment sits at each position in the draw order',
|
|
653
|
+
byPosition(a),
|
|
654
|
+
byPosition(b),
|
|
655
|
+
),
|
|
656
|
+
histogram(
|
|
657
|
+
'slots.agnostic.bone_binding_shape',
|
|
658
|
+
'as many slots hang off a bone of each shape (`?` = no such bone is declared)',
|
|
659
|
+
boundTo(a, ab),
|
|
660
|
+
boundTo(b, bb),
|
|
661
|
+
),
|
|
662
|
+
orderShape(
|
|
663
|
+
'slots.agnostic.order_shape',
|
|
664
|
+
'the draw order is the same order of `<attachment type>@<bone shape>`',
|
|
665
|
+
aShapes,
|
|
666
|
+
bShapes,
|
|
667
|
+
),
|
|
668
|
+
],
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
interface AttachmentFact {
|
|
673
|
+
type: string;
|
|
674
|
+
/** uv pairs, i.e. the real vertex count of a mesh. */
|
|
675
|
+
vertices: number | null;
|
|
676
|
+
triangles: number | null;
|
|
677
|
+
weighted: boolean | null;
|
|
678
|
+
hull: number | null;
|
|
679
|
+
/**
|
|
680
|
+
* Whether the mesh DECLARES an `edges` key at all — see
|
|
681
|
+
* `attachments.mesh_edges` for why that is the granularity and not the list.
|
|
682
|
+
*/
|
|
683
|
+
edgesPresent: boolean | null;
|
|
684
|
+
/** `<width>x<height>` as stated, or `unstated` — see `attachments.region_size`. */
|
|
685
|
+
size: string;
|
|
686
|
+
/**
|
|
687
|
+
* The name the runtime gives the attachment: its stated `name`, else its
|
|
688
|
+
* placeholder key (`getValue(map, "name", placeholder)`, `SkeletonJson.js:526`)
|
|
689
|
+
* — see `attachments.runtime_name`.
|
|
690
|
+
*/
|
|
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();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, AttachmentFact> } {
|
|
732
|
+
const skins = new Set<string>();
|
|
733
|
+
const byKey = new Map<string, AttachmentFact>();
|
|
734
|
+
for (const skin of objs(root.skins)) {
|
|
735
|
+
const skinName = str(skin.name) ?? 'default';
|
|
736
|
+
skins.add(skinName);
|
|
737
|
+
if (!isObj(skin.attachments)) continue;
|
|
738
|
+
for (const [slotName, slotMap] of Object.entries(skin.attachments)) {
|
|
739
|
+
if (!isObj(slotMap)) continue;
|
|
740
|
+
for (const [attName, att] of Object.entries(slotMap)) {
|
|
741
|
+
if (!isObj(att)) continue;
|
|
742
|
+
const type = str(att.type) ?? 'region';
|
|
743
|
+
const uvs = arr(att.uvs);
|
|
744
|
+
const verticesRun = arr(att.vertices);
|
|
745
|
+
const vertexCount = uvs.length > 0 ? uvs.length / 2 : num(att.vertexCount);
|
|
746
|
+
// Weighted versus unweighted carries no marker: the run is longer than
|
|
747
|
+
// 2 numbers per vertex exactly when it holds bone/weight triples.
|
|
748
|
+
const weighted =
|
|
749
|
+
vertexCount === null || verticesRun.length === 0 ? null : verticesRun.length !== vertexCount * 2;
|
|
750
|
+
byKey.set(`${skinName}/${slotName}/${attName}`, {
|
|
751
|
+
type,
|
|
752
|
+
vertices: type === 'mesh' ? vertexCount : null,
|
|
753
|
+
triangles: type === 'mesh' ? arr(att.triangles).length / 3 : null,
|
|
754
|
+
weighted: type === 'mesh' ? weighted : null,
|
|
755
|
+
hull: type === 'mesh' ? num(att.hull) : null,
|
|
756
|
+
// `'edges' in att` rather than a length test, so a mesh that declares
|
|
757
|
+
// `"edges": []` reads as DECLARED. That is a different statement from
|
|
758
|
+
// omitting the key — the parser stores it as a different thing — and
|
|
759
|
+
// `bench/count_features.ts`'s `mesh_hasEdges` survey counts presence
|
|
760
|
+
// the same way, so the corpus census and this measure agree on what
|
|
761
|
+
// "has edges" means.
|
|
762
|
+
edgesPresent: type === 'mesh' ? 'edges' in att : null,
|
|
763
|
+
size: num(att.width) !== null && num(att.height) !== null ? `${num(att.width)}x${num(att.height)}` : 'unstated',
|
|
764
|
+
runtimeName: str(att.name) ?? attName,
|
|
765
|
+
refs: attachmentRefTokens(type, attName, att).join(' '),
|
|
766
|
+
refList: attachmentRefTokens(type, attName, att),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return { skins, byKey };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function diffAttachments(c: Json, r: Json): DiffSection {
|
|
775
|
+
const a = attachmentFacts(c);
|
|
776
|
+
const b = attachmentFacts(r);
|
|
777
|
+
const meshes = (m: Map<string, AttachmentFact>) => new Map([...m].filter(([, f]) => f.type === 'mesh'));
|
|
778
|
+
const regions = (m: Map<string, AttachmentFact>) => new Map([...m].filter(([, f]) => f.type === 'region'));
|
|
779
|
+
const am = meshes(a.byKey);
|
|
780
|
+
const bm = meshes(b.byKey);
|
|
781
|
+
const ar = regions(a.byKey);
|
|
782
|
+
const br = regions(b.byKey);
|
|
783
|
+
return sectionOf('attachments', [
|
|
784
|
+
jaccard('attachments.skins', 'the skin names', a.skins, b.skins),
|
|
785
|
+
measure(
|
|
786
|
+
'attachments.count',
|
|
787
|
+
'how many attachments',
|
|
788
|
+
Math.min(a.byKey.size, b.byKey.size),
|
|
789
|
+
Math.max(a.byKey.size, b.byKey.size),
|
|
790
|
+
),
|
|
791
|
+
jaccard('attachments.names', 'skin/slot/attachment keys', new Set(a.byKey.keys()), new Set(b.byKey.keys())),
|
|
792
|
+
histogram(
|
|
793
|
+
'attachments.type_counts',
|
|
794
|
+
'as many of each attachment type',
|
|
795
|
+
counted([...a.byKey.values()].map((f) => f.type)),
|
|
796
|
+
counted([...b.byKey.values()].map((f) => f.type)),
|
|
797
|
+
),
|
|
798
|
+
agreement('attachments.mesh_vertices', 'each mesh has the same vertex count', am, bm, (x, y) => x.vertices === y.vertices),
|
|
799
|
+
agreement('attachments.mesh_triangles', 'each mesh has the same triangle count', am, bm, (x, y) => x.triangles === y.triangles),
|
|
800
|
+
agreement('attachments.mesh_weighted', 'each mesh is weighted, or is not, alike', am, bm, (x, y) => x.weighted === y.weighted),
|
|
801
|
+
agreement('attachments.mesh_hull', 'each mesh declares the same hull length', am, bm, (x, y) => x.hull === y.hull),
|
|
802
|
+
// Replaces `attachments.region_size_present` (issue #28), which asked
|
|
803
|
+
// whether each region STATED a size and read near zero on every honest run.
|
|
804
|
+
//
|
|
805
|
+
// The issue's diagnosis was that Spine's exporter omits `width`/`height`
|
|
806
|
+
// when they match the atlas region, so a rigc rig — which always states
|
|
807
|
+
// them (AUTHORING R1/R5) — could never agree. That is not what the corpus
|
|
808
|
+
// says: all twelve reference exports state a size on every one of their
|
|
809
|
+
// regions, and no region omits either field — so there is nothing for an
|
|
810
|
+
// atlas lookup to resolve and the `--atlas` plumbing the issue proposed
|
|
811
|
+
// would be dead code against every rung on the ladder.
|
|
812
|
+
//
|
|
813
|
+
// ⛔ No count on that claim, deliberately, and this was the fourth copy of
|
|
814
|
+
// the one issue #490 struck off `docs/LADDER.md`: nothing derives the
|
|
815
|
+
// figure — not the plainest reading, every `region` attachment under
|
|
816
|
+
// `skins` in the twelve `export/*.json` skeletons, and not counting by
|
|
817
|
+
// skin, by slot, by attachment name, by atlas path or by atlas region. The
|
|
818
|
+
// claim carries the argument without one, because it is universal and a
|
|
819
|
+
// single counterexample refutes it: after `bun run fetch-examples`, read
|
|
820
|
+
// the region attachments of those twelve files and check that each states
|
|
821
|
+
// both `width` and `height`.
|
|
822
|
+
//
|
|
823
|
+
// What the measure actually reported was the naming gap, a third time. It
|
|
824
|
+
// was keyed by `skin/slot/attachment`, so it could never exceed the name
|
|
825
|
+
// overlap: rung 1 read `0/8` where `attachments.names` read `0/16`, and
|
|
826
|
+
// `3/5` where three names matched. That is #21's defect in this section.
|
|
827
|
+
//
|
|
828
|
+
// So it is name-agnostic and numeric instead: do the two rigs agree about
|
|
829
|
+
// how big their regions are? A rig that states a size a differently-named
|
|
830
|
+
// rig also states now agrees, which is what a structural diff is for.
|
|
831
|
+
histogram(
|
|
832
|
+
'attachments.region_size',
|
|
833
|
+
'NAME-AGNOSTIC: as many regions of each stated size (`unstated` is its own size)',
|
|
834
|
+
counted([...ar.values()].map((f) => f.size)),
|
|
835
|
+
counted([...br.values()].map((f) => f.size)),
|
|
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
|
+
),
|
|
853
|
+
],
|
|
854
|
+
undefined,
|
|
855
|
+
// ── reported (issue #46) ────────────────────────────────────────────────
|
|
856
|
+
//
|
|
857
|
+
// `docs/LADDER.md` gates rung 6 on three features — transform constraints,
|
|
858
|
+
// weighted meshes from authored geometry, and mesh `edges` — and this section
|
|
859
|
+
// measured nine things, none of which read the third. A candidate that
|
|
860
|
+
// dropped one of the rung's own gating features scored a clean 1.000 for the
|
|
861
|
+
// meshes it kept, which is the shape of a measurement that flatters whatever
|
|
862
|
+
// is missing.
|
|
863
|
+
//
|
|
864
|
+
// ⭐ Present-vs-absent, and not the list. `edges` constrains triangulation in
|
|
865
|
+
// the editor and has no runtime effect whatever — it draws no pixel — so two
|
|
866
|
+
// candidates can carry different-but-equivalent lists and mean the same rig,
|
|
867
|
+
// and index equality would score a correct rig below 1.000. `edges.length`
|
|
868
|
+
// has the same defect in weaker form: `6-arcs`' `tail` declares 116 entries
|
|
869
|
+
// and a mesh triangulated differently declares a different number while
|
|
870
|
+
// being just as right. Present-vs-absent is the only distinction that is
|
|
871
|
+
// unambiguous, and it is exactly the one that was invisible.
|
|
872
|
+
//
|
|
873
|
+
// 🚫 Reported rather than in the mean, and on principle rather than for
|
|
874
|
+
// compatibility: `edges` is unobservable by construction under GATE.md's own
|
|
875
|
+
// test — no reading of the frames could decide it, because no reading of the
|
|
876
|
+
// frames can see it.
|
|
877
|
+
[
|
|
878
|
+
agreement(
|
|
879
|
+
'attachments.mesh_edges',
|
|
880
|
+
'each mesh declares an edge list, or declares none, alike',
|
|
881
|
+
am,
|
|
882
|
+
bm,
|
|
883
|
+
(x, y) => x.edgesPresent === y.edgesPresent,
|
|
884
|
+
),
|
|
885
|
+
// ── issue #796 ───────────────────────────────────────────────────────────
|
|
886
|
+
//
|
|
887
|
+
// The name the RUNTIME gives each attachment — `name` if stated, else the
|
|
888
|
+
// placeholder key — agreed per skin/slot/placeholder. Every measure above is
|
|
889
|
+
// keyed by the placeholder, so a rebuild that renamed every attachment while
|
|
890
|
+
// keeping every key read 1.000 across this whole section: that is what a
|
|
891
|
+
// rebuild did on two production rigs (a stated name respelled as `path`, and
|
|
892
|
+
// `<skin>/<placeholder>` composed over a name the file never stated), and it
|
|
893
|
+
// took a pose oracle comparing `slot.attachment.name` to see it.
|
|
894
|
+
//
|
|
895
|
+
// 🚫 Reported rather than in the mean, by the same test as `mesh_edges`: a
|
|
896
|
+
// name draws no pixel, so no reading of the frames could decide it. It is
|
|
897
|
+
// still a value a consumer reads — `slot.attachment.name` — which is why it
|
|
898
|
+
// is measured at all.
|
|
899
|
+
//
|
|
900
|
+
// ⚠️ Over the keys BOTH sides hold, not over the larger roster, and that is
|
|
901
|
+
// the one departure from `agreement` here. A key one side lacks is already
|
|
902
|
+
// `attachments.names` and `attachments.count`; scored again here it would
|
|
903
|
+
// move this measure on every rename of a key and every dropped attachment,
|
|
904
|
+
// and "the same key answers to another name" — the only thing nothing else
|
|
905
|
+
// reads — would be one term among many. The denominator this reports is the
|
|
906
|
+
// number of keys compared, so a report over few shared keys says so.
|
|
907
|
+
runtimeNames(a.byKey, b.byKey),
|
|
908
|
+
]);
|
|
909
|
+
}
|
|
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
|
+
|
|
972
|
+
/**
|
|
973
|
+
* `attachments.runtime_name`: of the skin/slot/placeholder keys both sides
|
|
974
|
+
* hold, how many answer to the same runtime name — see the note at its call.
|
|
975
|
+
*/
|
|
976
|
+
function runtimeNames(a: Map<string, AttachmentFact>, b: Map<string, AttachmentFact>): DiffMeasure {
|
|
977
|
+
let shared = 0;
|
|
978
|
+
let agree = 0;
|
|
979
|
+
const differ: string[] = [];
|
|
980
|
+
for (const [key, fact] of a) {
|
|
981
|
+
const other = b.get(key);
|
|
982
|
+
if (other === undefined) continue;
|
|
983
|
+
shared++;
|
|
984
|
+
if (fact.runtimeName === other.runtimeName) agree++;
|
|
985
|
+
else if (differ.length < 3) differ.push(`${key} "${fact.runtimeName}" vs "${other.runtimeName}"`);
|
|
986
|
+
}
|
|
987
|
+
const missed = shared - agree;
|
|
988
|
+
return measure(
|
|
989
|
+
'attachments.runtime_name',
|
|
990
|
+
'each attachment both sides hold answers to the same name at runtime (`name` if stated, else its placeholder)',
|
|
991
|
+
agree,
|
|
992
|
+
shared,
|
|
993
|
+
shared === 0
|
|
994
|
+
? 'no skin/slot/placeholder key is on both sides, so no name was compared'
|
|
995
|
+
: missed === 0
|
|
996
|
+
? undefined
|
|
997
|
+
: `${missed} renamed: ${differ.join('; ')}${missed > differ.length ? `; …and ${missed - differ.length} more` : ''}`,
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
interface ConstraintFact {
|
|
1002
|
+
type: string;
|
|
1003
|
+
/**
|
|
1004
|
+
* Every name the constraint resolves, as `<field>:<name>` and sorted — its
|
|
1005
|
+
* wiring: the bones and slots it reads and writes, a slider's animation, and
|
|
1006
|
+
* the properties a slider or a transform maps (`property:rotate`,
|
|
1007
|
+
* `property:x>y`). See `constraintFacts` for which fields and why.
|
|
1008
|
+
*/
|
|
1009
|
+
refs: string;
|
|
1010
|
+
/** The same tokens as a list, for the note that spells a disagreement out. */
|
|
1011
|
+
refList: string[];
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Every constraint, keyed the way the format resolves one: by KIND and name.
|
|
1016
|
+
*
|
|
1017
|
+
* ⚠️ Keyed by the name alone, a skeleton that carries `leg` as an ik constraint
|
|
1018
|
+
* and as a transform one — valid Spine, and what `findConstraint(name, type)`
|
|
1019
|
+
* exists to tell apart — lost one of the pair out of all five measures below
|
|
1020
|
+
* (issue #692). It was invisible rather than wrong: both sides collapse the same
|
|
1021
|
+
* way, so the report read 1.000 while its own header line counted one constraint
|
|
1022
|
+
* more than `constraints.count` did, measured at 14/14 against 13/13 on the
|
|
1023
|
+
* export that made the card.
|
|
1024
|
+
*
|
|
1025
|
+
* ⭐ **What the wiring is** (issue #1078): every field of a 4.3 constraint whose
|
|
1026
|
+
* value is a NAME the parser resolves — `SPEC_COVERAGE.md` §1.4, field by field.
|
|
1027
|
+
* `bones`, `bone`, `target`, `source` and `slot` name a bone or a slot; a
|
|
1028
|
+
* slider's `animation` names an animation; a slider's `property` and the
|
|
1029
|
+
* `from`/`to` keys of a transform's `properties` name the property read and the
|
|
1030
|
+
* property written, from a closed set the parser throws outside of. Every other
|
|
1031
|
+
* field is a number, a flag or a mode — a value, which this file does not compare
|
|
1032
|
+
* on either side of any split; the value walk (`diffSkeletonValues`) does.
|
|
1033
|
+
*
|
|
1034
|
+
* 🔍 Until #1078 the list stopped at the five bone and slot fields, so an editor
|
|
1035
|
+
* import that repointed both sliders of a probe (`yaw -> "5"` came back as
|
|
1036
|
+
* `yaw -> "-a"`, issue #1040) read 1.000 on every constraint measure. The two
|
|
1037
|
+
* property fields were blinder still: measured on `gallery/look` and `6-arcs-pro`,
|
|
1038
|
+
* a slider moved from `rotate` to `x` and a transform remapped from `x>x` to
|
|
1039
|
+
* `x>y` moved no measure here and no value in the value walk either (its skip
|
|
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`).
|
|
1042
|
+
*
|
|
1043
|
+
* ⚠️ Absent is no token, never a default: a transform with no `properties` maps
|
|
1044
|
+
* nothing, and two sides that both omit a field agree about it. And the tokens
|
|
1045
|
+
* are a set, so the order a file writes `properties` in — an object, re-keyed by
|
|
1046
|
+
* any writer — is not a difference.
|
|
1047
|
+
*/
|
|
1048
|
+
function constraintFacts(root: Json): Map<string, ConstraintFact> {
|
|
1049
|
+
const out = new Map<string, ConstraintFact>();
|
|
1050
|
+
for (const con of objs(root.constraints)) {
|
|
1051
|
+
const name = str(con.name);
|
|
1052
|
+
if (name === null) continue;
|
|
1053
|
+
const at = constraintAt(str(con.type) ?? '(none)', name);
|
|
1054
|
+
const refs = new Set<string>();
|
|
1055
|
+
for (const b of arr(con.bones)) {
|
|
1056
|
+
const s = str(b);
|
|
1057
|
+
if (s !== null) refs.add(`bone:${s}`);
|
|
1058
|
+
}
|
|
1059
|
+
for (const key of ['bone', 'target', 'source', 'slot', 'animation', 'property'] as const) {
|
|
1060
|
+
const s = str(con[key]);
|
|
1061
|
+
if (s !== null) refs.add(`${key}:${s}`);
|
|
1062
|
+
}
|
|
1063
|
+
if (isObj(con.properties)) {
|
|
1064
|
+
for (const [from, entry] of Object.entries(con.properties)) {
|
|
1065
|
+
const to = isObj(entry) && isObj(entry.to) ? Object.keys(entry.to) : [];
|
|
1066
|
+
// A `from` with no `to` maps nothing at runtime, but it is still a
|
|
1067
|
+
// statement the file makes, so it is a token of its own rather than
|
|
1068
|
+
// silently equal to the key being absent.
|
|
1069
|
+
if (to.length === 0) refs.add(`property:${from}>`);
|
|
1070
|
+
for (const t of to) refs.add(`property:${from}>${t}`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
const refList = [...refs].sort();
|
|
1074
|
+
out.set(at, { type: str(con.type) ?? '(none)', refs: refList.join(' '), refList });
|
|
1075
|
+
}
|
|
1076
|
+
return out;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** How many disagreeing constraints `constraints.refs`' note spells out before it counts the rest. */
|
|
1080
|
+
const REFS_SPELLED_OUT = 3;
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* One constraint's disagreement, by field: `slider constraint "yaw": animation
|
|
1084
|
+
* "5" vs "-a"` — the candidate's names for each field that differs, then the
|
|
1085
|
+
* reference's, `none` where a side has none.
|
|
1086
|
+
*/
|
|
1087
|
+
function refsDisagreement(at: string, a: { refList: string[] }, b: { refList: string[] }): string {
|
|
1088
|
+
const byField = (f: { refList: string[] }): Map<string, string[]> => {
|
|
1089
|
+
const m = new Map<string, string[]>();
|
|
1090
|
+
for (const token of f.refList) {
|
|
1091
|
+
const colon = token.indexOf(':');
|
|
1092
|
+
const field = token.slice(0, colon);
|
|
1093
|
+
m.set(field, [...(m.get(field) ?? []), token.slice(colon + 1)]);
|
|
1094
|
+
}
|
|
1095
|
+
return m;
|
|
1096
|
+
};
|
|
1097
|
+
const af = byField(a);
|
|
1098
|
+
const bf = byField(b);
|
|
1099
|
+
const fields = [...new Set([...af.keys(), ...bf.keys()])].sort();
|
|
1100
|
+
const spell = (names: string[] | undefined): string =>
|
|
1101
|
+
names === undefined ? 'none' : names.map((n) => JSON.stringify(n)).join(', ');
|
|
1102
|
+
const parts = fields
|
|
1103
|
+
.filter((f) => (af.get(f) ?? []).join(' ') !== (bf.get(f) ?? []).join(' '))
|
|
1104
|
+
.map((f) => `${f} ${spell(af.get(f))} vs ${spell(bf.get(f))}`);
|
|
1105
|
+
return `${at}: ${parts.join(', ')}`;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* `constraints.refs`: `agreement` over the constraints, unchanged in what it
|
|
1110
|
+
* counts, with a note naming each disagreement and both sides' names — the
|
|
1111
|
+
* figure alone says that some constraint is wired differently, and a reader
|
|
1112
|
+
* acting on it needs to know which one and to what (issue #1078).
|
|
1113
|
+
*
|
|
1114
|
+
* The note's two counts partition the misses: a constraint both sides hold whose
|
|
1115
|
+
* wiring differs is spelled out, and one only one side holds is counted, since
|
|
1116
|
+
* `constraints.names` is the measure that names it.
|
|
1117
|
+
*/
|
|
1118
|
+
function constraintRefs(a: Map<string, ConstraintFact>, b: Map<string, ConstraintFact>): DiffMeasure {
|
|
1119
|
+
const base = agreement(
|
|
1120
|
+
'constraints.refs',
|
|
1121
|
+
'each constraint names the same bones, slots, animation and properties',
|
|
1122
|
+
a,
|
|
1123
|
+
b,
|
|
1124
|
+
(x, y) => x.refs === y.refs,
|
|
1125
|
+
);
|
|
1126
|
+
const differ: string[] = [];
|
|
1127
|
+
for (const [at, fact] of a) {
|
|
1128
|
+
const other = b.get(at);
|
|
1129
|
+
if (other !== undefined && other.refs !== fact.refs) differ.push(refsDisagreement(at, fact, other));
|
|
1130
|
+
}
|
|
1131
|
+
const missed = base.total - base.matched;
|
|
1132
|
+
if (missed === 0) return base;
|
|
1133
|
+
const oneSided = missed - differ.length;
|
|
1134
|
+
const spelled = differ.slice(0, REFS_SPELLED_OUT);
|
|
1135
|
+
const note =
|
|
1136
|
+
(differ.length === 0 ? '' : `${differ.length} wired differently: ${spelled.join('; ')}`) +
|
|
1137
|
+
(differ.length > spelled.length ? `; …and ${differ.length - spelled.length} more` : '') +
|
|
1138
|
+
(oneSided === 0
|
|
1139
|
+
? ''
|
|
1140
|
+
: `${differ.length === 0 ? '' : '; '}${oneSided} on one side only, which \`constraints.names\` names`);
|
|
1141
|
+
return { ...base, note };
|
|
1142
|
+
}
|
|
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
|
+
|
|
1172
|
+
function diffConstraints(c: Json, r: Json): DiffSection {
|
|
1173
|
+
const a = constraintFacts(c);
|
|
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);
|
|
1181
|
+
return sectionOf('constraints', [
|
|
1182
|
+
measure('constraints.count', 'how many constraints', Math.min(a.size, b.size), Math.max(a.size, b.size)),
|
|
1183
|
+
jaccard('constraints.names', 'the constraint names', new Set(a.keys()), new Set(b.keys())),
|
|
1184
|
+
histogram(
|
|
1185
|
+
'constraints.type_counts',
|
|
1186
|
+
'as many of each constraint type',
|
|
1187
|
+
counted([...a.values()].map((f) => f.type)),
|
|
1188
|
+
counted([...b.values()].map((f) => f.type)),
|
|
1189
|
+
),
|
|
1190
|
+
agreement('constraints.type_by_name', 'each constraint is the same type', a, b, (x, y) => x.type === y.type),
|
|
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
|
+
),
|
|
1202
|
+
]);
|
|
1203
|
+
}
|
|
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
|
+
|
|
1218
|
+
interface AnimationFacts {
|
|
1219
|
+
names: string[];
|
|
1220
|
+
/** `<anim>` -> last key time. Skeleton JSON has no duration field. */
|
|
1221
|
+
duration: Map<string, number>;
|
|
1222
|
+
/** `<anim>|<path>` -> 1, one entry per timeline that exists. */
|
|
1223
|
+
kinds: Map<string, number>;
|
|
1224
|
+
/** `<anim>|<kind>` -> number of keys. */
|
|
1225
|
+
keys: Map<string, number>;
|
|
1226
|
+
/** `<anim>|linear|stepped|bezier` -> number of keys with that curve. */
|
|
1227
|
+
curves: Map<string, number>;
|
|
1228
|
+
events: Map<string, number>;
|
|
1229
|
+
hasDrawOrder: Map<string, boolean>;
|
|
1230
|
+
hasDeform: Map<string, boolean>;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function animationFacts(root: Json): AnimationFacts {
|
|
1234
|
+
const facts: AnimationFacts = {
|
|
1235
|
+
names: [],
|
|
1236
|
+
duration: new Map(),
|
|
1237
|
+
kinds: new Map(),
|
|
1238
|
+
keys: new Map(),
|
|
1239
|
+
curves: new Map(),
|
|
1240
|
+
events: new Map(),
|
|
1241
|
+
hasDrawOrder: new Map(),
|
|
1242
|
+
hasDeform: new Map(),
|
|
1243
|
+
};
|
|
1244
|
+
if (!isObj(root.animations)) return facts;
|
|
1245
|
+
for (const name of Object.keys(root.animations)) {
|
|
1246
|
+
facts.names.push(name);
|
|
1247
|
+
facts.duration.set(name, 0);
|
|
1248
|
+
facts.events.set(name, 0);
|
|
1249
|
+
facts.hasDrawOrder.set(name, false);
|
|
1250
|
+
facts.hasDeform.set(name, false);
|
|
1251
|
+
}
|
|
1252
|
+
const bump = (m: Map<string, number>, k: string, by = 1) => m.set(k, (m.get(k) ?? 0) + by);
|
|
1253
|
+
walkTimelines(root, (path, kind, name, keys) => {
|
|
1254
|
+
const anim = path.slice(0, path.indexOf('.'));
|
|
1255
|
+
// The path carries the target's name (bone `face`, constraint `probe_ik`),
|
|
1256
|
+
// which is compared under bones/constraints already. Here only the SHAPE
|
|
1257
|
+
// matters, so the target is dropped and the kind kept.
|
|
1258
|
+
bump(facts.kinds, `${anim}|${kind}.${name}`);
|
|
1259
|
+
bump(facts.keys, `${anim}|${kind}.${name}`, keys.length);
|
|
1260
|
+
if (kind === 'drawOrder' || kind === 'drawOrderFolder') facts.hasDrawOrder.set(anim, true);
|
|
1261
|
+
if (kind === 'attachment' && name === 'deform') facts.hasDeform.set(anim, true);
|
|
1262
|
+
if (kind === 'event') facts.events.set(anim, (facts.events.get(anim) ?? 0) + keys.length);
|
|
1263
|
+
for (const key of keys) {
|
|
1264
|
+
if (!isObj(key)) continue;
|
|
1265
|
+
const time = num(key.time) ?? 0;
|
|
1266
|
+
if (time > (facts.duration.get(anim) ?? 0)) facts.duration.set(anim, time);
|
|
1267
|
+
const curve = key.curve;
|
|
1268
|
+
const shape = curve === undefined ? 'linear' : curve === 'stepped' ? 'stepped' : Array.isArray(curve) ? 'bezier' : 'other';
|
|
1269
|
+
bump(facts.curves, `${anim}|${shape}`);
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
return facts;
|
|
1273
|
+
}
|
|
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
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* What a side's keying costs, in the two shapes that separate the two ways a
|
|
1407
|
+
* key total can differ: more timelines, or more keys inside a timeline.
|
|
1408
|
+
*
|
|
1409
|
+
* Every number here is already collected by `animationFacts` — this only sums
|
|
1410
|
+
* it. `seconds` is the sum of each animation's last key time, which is what
|
|
1411
|
+
* `animations.duration` compares, because skeleton JSON carries no duration
|
|
1412
|
+
* field of its own.
|
|
1413
|
+
*/
|
|
1414
|
+
interface KeyingTotals {
|
|
1415
|
+
keys: number;
|
|
1416
|
+
timelines: number;
|
|
1417
|
+
seconds: number;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function keyingTotals(f: AnimationFacts): KeyingTotals {
|
|
1421
|
+
const sum = (m: Map<string, number>): number => [...m.values()].reduce((x, y) => x + y, 0);
|
|
1422
|
+
return { keys: sum(f.keys), timelines: sum(f.kinds), seconds: sum(f.duration) };
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** One animation on each side, said to be the same shot. */
|
|
1426
|
+
export interface DiffAnimationPair {
|
|
1427
|
+
candidate: string;
|
|
1428
|
+
reference: string;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* What a caller may tell `diffSkeletons` that neither file can say itself.
|
|
1433
|
+
*
|
|
1434
|
+
* Only the animation pairing today, and it is an INPUT in the sense
|
|
1435
|
+
* `bonedist`'s correspondence file is one: two skeletons cannot derive which of
|
|
1436
|
+
* their shots are the same shot, so a value worked out here would be a guess
|
|
1437
|
+
* reported as a measurement.
|
|
1438
|
+
*/
|
|
1439
|
+
export interface DiffOptions {
|
|
1440
|
+
/** `--as <candidate>=<reference>`, in the order the caller stated them. */
|
|
1441
|
+
animationPairs?: readonly DiffAnimationPair[];
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* The pairing used for `animations.agnostic.*`, or `null` when there is none.
|
|
1446
|
+
*
|
|
1447
|
+
* ⚠️ A stated pair naming an animation a side does not have is DROPPED and said
|
|
1448
|
+
* so in `pairedBy`, rather than silently making the block narrower. `cmdDiff`
|
|
1449
|
+
* refuses one by name before this is reached, so through the CLI the branch is
|
|
1450
|
+
* unreachable; it exists because this module is exported and a caller of the
|
|
1451
|
+
* API can state one.
|
|
1452
|
+
*/
|
|
1453
|
+
function pairAnimations(
|
|
1454
|
+
a: AnimationFacts,
|
|
1455
|
+
b: AnimationFacts,
|
|
1456
|
+
stated: readonly DiffAnimationPair[],
|
|
1457
|
+
): { pairs: DiffAnimationPair[]; pairedBy: string } | null {
|
|
1458
|
+
const spell = (p: DiffAnimationPair): string => `${p.candidate}=${p.reference}`;
|
|
1459
|
+
if (stated.length > 0) {
|
|
1460
|
+
const usable = stated.filter((p) => a.duration.has(p.candidate) && b.duration.has(p.reference));
|
|
1461
|
+
if (usable.length === 0) return null;
|
|
1462
|
+
const dropped = stated.filter((p) => !usable.includes(p));
|
|
1463
|
+
return {
|
|
1464
|
+
pairs: [...usable],
|
|
1465
|
+
pairedBy:
|
|
1466
|
+
`paired by --as: ${usable.map(spell).join(', ')}` +
|
|
1467
|
+
(dropped.length === 0 ? '' : `; ${dropped.map(spell).join(', ')} named an animation a side does not have and was dropped`),
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
if (a.names.length === 1 && b.names.length === 1) {
|
|
1471
|
+
const pair = { candidate: a.names[0], reference: b.names[0] };
|
|
1472
|
+
return { pairs: [pair], pairedBy: `paired by position: ${spell(pair)}, the one animation each side carries` };
|
|
1473
|
+
}
|
|
1474
|
+
return null;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* `f` restricted to the animations `label` names, with each one's name replaced
|
|
1479
|
+
* by the label — `#0` for the first pair, `#1` for the second.
|
|
1480
|
+
*
|
|
1481
|
+
* That substitution is the whole of what makes the block name-agnostic: the
|
|
1482
|
+
* measures below are the name-matched ones run again over facts whose keys are
|
|
1483
|
+
* positions in the pairing. Everything else about them — the tolerance, the
|
|
1484
|
+
* denominators, the histogram — is unchanged, which is what lets the two blocks
|
|
1485
|
+
* be read against each other.
|
|
1486
|
+
*/
|
|
1487
|
+
function underLabels(f: AnimationFacts, label: ReadonlyMap<string, string>): AnimationFacts {
|
|
1488
|
+
const keyed = <T>(m: Map<string, T>): Map<string, T> => {
|
|
1489
|
+
const out = new Map<string, T>();
|
|
1490
|
+
for (const [anim, v] of m) {
|
|
1491
|
+
const to = label.get(anim);
|
|
1492
|
+
if (to !== undefined) out.set(to, v);
|
|
1493
|
+
}
|
|
1494
|
+
return out;
|
|
1495
|
+
};
|
|
1496
|
+
// `kinds`, `keys` and `curves` are keyed `<anim>|<rest>`, so only the head is
|
|
1497
|
+
// relabelled and the tail — the timeline's kind, the curve's shape — is what
|
|
1498
|
+
// the histogram then intersects on.
|
|
1499
|
+
const prefixed = (m: Map<string, number>): Map<string, number> => {
|
|
1500
|
+
const out = new Map<string, number>();
|
|
1501
|
+
for (const [k, v] of m) {
|
|
1502
|
+
const bar = k.indexOf('|');
|
|
1503
|
+
const to = label.get(k.slice(0, bar));
|
|
1504
|
+
if (to === undefined) continue;
|
|
1505
|
+
const id = `${to}${k.slice(bar)}`;
|
|
1506
|
+
out.set(id, (out.get(id) ?? 0) + v);
|
|
1507
|
+
}
|
|
1508
|
+
return out;
|
|
1509
|
+
};
|
|
1510
|
+
return {
|
|
1511
|
+
names: [...label.values()],
|
|
1512
|
+
duration: keyed(f.duration),
|
|
1513
|
+
kinds: prefixed(f.kinds),
|
|
1514
|
+
keys: prefixed(f.keys),
|
|
1515
|
+
curves: prefixed(f.curves),
|
|
1516
|
+
events: keyed(f.events),
|
|
1517
|
+
hasDrawOrder: keyed(f.hasDrawOrder),
|
|
1518
|
+
hasDeform: keyed(f.hasDeform),
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* The six measures of `animations.agnostic.*`.
|
|
1524
|
+
*
|
|
1525
|
+
* ⛔ `names` is not among them, by construction — a block that threw the names
|
|
1526
|
+
* away cannot then compare them. ⛔ Neither is `count`, which `bones` and
|
|
1527
|
+
* `slots` do carry, and the difference is what the block is OVER: those two
|
|
1528
|
+
* compare whole rosters, so their agnostic half has the same subject as their
|
|
1529
|
+
* name-matched half and restates the count for a reader with one block open.
|
|
1530
|
+
* This one is over the PAIRS. A `count` in it would either restate the roster
|
|
1531
|
+
* figure — a different subject under the same heading — or count the pairs,
|
|
1532
|
+
* which measures the flag rather than the two rigs. The roster figure is
|
|
1533
|
+
* `animations.count`, and it is already name-free.
|
|
1534
|
+
*/
|
|
1535
|
+
function agnosticAnimationMeasures(a: AnimationFacts, b: AnimationFacts, pairs: readonly DiffAnimationPair[]): DiffMeasure[] {
|
|
1536
|
+
const slot = (i: number): string => `#${i}`;
|
|
1537
|
+
const ca = underLabels(a, new Map(pairs.map((p, i) => [p.candidate, slot(i)])));
|
|
1538
|
+
const rb = underLabels(b, new Map(pairs.map((p, i) => [p.reference, slot(i)])));
|
|
1539
|
+
return [
|
|
1540
|
+
agreement(
|
|
1541
|
+
'animations.agnostic.duration',
|
|
1542
|
+
'each paired animation runs as long (last key time, within one frame)',
|
|
1543
|
+
ca.duration,
|
|
1544
|
+
rb.duration,
|
|
1545
|
+
(x, y) => Math.abs(x - y) <= FRAME,
|
|
1546
|
+
),
|
|
1547
|
+
histogram('animations.agnostic.timeline_kinds', 'the same timelines exist in the paired animations', ca.kinds, rb.kinds),
|
|
1548
|
+
histogram('animations.agnostic.key_counts', 'those timelines carry as many keys', ca.keys, rb.keys),
|
|
1549
|
+
histogram('animations.agnostic.curve_kinds', 'as many linear / stepped / bezier keys', ca.curves, rb.curves),
|
|
1550
|
+
agreement(
|
|
1551
|
+
'animations.agnostic.draw_order',
|
|
1552
|
+
'a draw-order timeline is present or absent alike',
|
|
1553
|
+
ca.hasDrawOrder,
|
|
1554
|
+
rb.hasDrawOrder,
|
|
1555
|
+
(x, y) => x === y,
|
|
1556
|
+
),
|
|
1557
|
+
agreement(
|
|
1558
|
+
'animations.agnostic.deform',
|
|
1559
|
+
'a deform timeline is present or absent alike',
|
|
1560
|
+
ca.hasDeform,
|
|
1561
|
+
rb.hasDeform,
|
|
1562
|
+
(x, y) => x === y,
|
|
1563
|
+
),
|
|
1564
|
+
];
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPair[]): DiffSection {
|
|
1568
|
+
const a = animationFacts(c);
|
|
1569
|
+
const b = animationFacts(r);
|
|
1570
|
+
const at = keyingTotals(a);
|
|
1571
|
+
const bt = keyingTotals(b);
|
|
1572
|
+
const paired = pairAnimations(a, b, pairsStated);
|
|
1573
|
+
const refsA = animationRefs(c);
|
|
1574
|
+
const refsB = animationRefs(r);
|
|
1575
|
+
const perSecond = (t: KeyingTotals): number => (t.seconds === 0 ? 0 : t.keys / t.seconds);
|
|
1576
|
+
const perTimeline = (t: KeyingTotals): number => (t.timelines === 0 ? 0 : t.keys / t.timelines);
|
|
1577
|
+
return sectionOf('animations', [
|
|
1578
|
+
measure('animations.count', 'how many animations', Math.min(a.names.length, b.names.length), Math.max(a.names.length, b.names.length)),
|
|
1579
|
+
jaccard('animations.names', 'the animation names', new Set(a.names), new Set(b.names)),
|
|
1580
|
+
agreement(
|
|
1581
|
+
'animations.duration',
|
|
1582
|
+
'each animation runs as long (last key time, within one frame)',
|
|
1583
|
+
a.duration,
|
|
1584
|
+
b.duration,
|
|
1585
|
+
(x, y) => Math.abs(x - y) <= FRAME,
|
|
1586
|
+
),
|
|
1587
|
+
histogram('animations.timeline_kinds', 'the same timelines exist', a.kinds, b.kinds),
|
|
1588
|
+
histogram('animations.key_counts', 'those timelines carry as many keys', a.keys, b.keys),
|
|
1589
|
+
histogram('animations.curve_kinds', 'as many linear / stepped / bezier keys', a.curves, b.curves),
|
|
1590
|
+
histogram('animations.event_keys', 'as many event firings', a.events, b.events),
|
|
1591
|
+
agreement('animations.draw_order', 'a draw-order timeline is present or absent alike', a.hasDrawOrder, b.hasDrawOrder, (x, y) => x === y),
|
|
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
|
+
),
|
|
1605
|
+
],
|
|
1606
|
+
// ── the same two skeletons' shots, paired rather than named (issue #720) ──
|
|
1607
|
+
//
|
|
1608
|
+
// Absent unless something pairs them — see `pairAnimations` and the header's
|
|
1609
|
+
// point 3. `undefined` and not `[]`: a block with no measures in it prints a
|
|
1610
|
+
// vacuous `mean 1.000 over 0 measures`, which is the false green this whole
|
|
1611
|
+
// file is built to refuse, and `movedAgnosticMeasures` cannot tell it from a
|
|
1612
|
+
// block that agreed about everything.
|
|
1613
|
+
paired === null ? undefined : agnosticAnimationMeasures(a, b, paired.pairs),
|
|
1614
|
+
// ── reported (issue #20) ────────────────────────────────────────────────
|
|
1615
|
+
//
|
|
1616
|
+
// 🔍 What #20 asked and what was actually wrong. The issue proposed making key
|
|
1617
|
+
// density OBSERVABLE — 24/30 fps reference renders, or a hint in the brief —
|
|
1618
|
+
// and both routes were measured before either was built (the figures are in
|
|
1619
|
+
// [BENCHMARK.md](../docs/BENCHMARK.md), *Key density*). Neither works, and the
|
|
1620
|
+
// reason is the same one twice: **the two keyings render the same pictures.**
|
|
1621
|
+
// A candidate that lays extra keys along the curve the reference already
|
|
1622
|
+
// describes poses identically at every instant, so a higher sampling rate
|
|
1623
|
+
// samples the same curve more often and sees the same thing — the frames'
|
|
1624
|
+
// rate is not the ceiling, the pixels are. And a hint in the brief does not
|
|
1625
|
+
// make density observable; it makes it TOLD, which turns a scored measure into
|
|
1626
|
+
// an input and would be a reference-side value of a scored row landing in an
|
|
1627
|
+
// allowed-reading surface — the exact text LADDER.md's honesty rule seals.
|
|
1628
|
+
//
|
|
1629
|
+
// ⭐ So what the issue found is a REPORTING defect, and it is here.
|
|
1630
|
+
// `animations.key_counts` is a histogram intersection over
|
|
1631
|
+
// `max(candidate, reference)`, so **it cannot say which side is the bigger
|
|
1632
|
+
// one**: rung 4 read 421/1339 for a candidate carrying three times the
|
|
1633
|
+
// reference's keys, and that figure is indistinguishable from one carrying a
|
|
1634
|
+
// third of them. The author read it as a gap and left it, correctly, because
|
|
1635
|
+
// nothing in the report said "over-keyed". These two measures say so, with
|
|
1636
|
+
// their conventions, in the direction the ratio drops.
|
|
1637
|
+
//
|
|
1638
|
+
// Two figures and not one, because a key total can differ two ways and the
|
|
1639
|
+
// repairs are opposite: `key_density` moves when the shot is keyed harder,
|
|
1640
|
+
// `keys_per_timeline` when each timeline is, and a candidate with the
|
|
1641
|
+
// reference's density spread over twice the timelines shows up on the second
|
|
1642
|
+
// alone. 🚫 Neither gates — GATE.md's *What never gates* names key density
|
|
1643
|
+
// outright, and gate v2.3 (#153) settled that a clause does not read a figure
|
|
1644
|
+
// the authoring loop cannot see.
|
|
1645
|
+
[
|
|
1646
|
+
rateAgreement(
|
|
1647
|
+
'animations.key_density',
|
|
1648
|
+
'how hard the shot is keyed, as keys per second',
|
|
1649
|
+
'keys/s',
|
|
1650
|
+
perSecond(at),
|
|
1651
|
+
perSecond(bt),
|
|
1652
|
+
`Convention: every key of every timeline (${at.keys} vs ${bt.keys}) over the summed last-key time of ` +
|
|
1653
|
+
`every animation (${atPlaces(at.seconds)}s vs ${atPlaces(bt.seconds)}s), compared as min/max at ${RATE_PLACES} decimal places.`,
|
|
1654
|
+
),
|
|
1655
|
+
rateAgreement(
|
|
1656
|
+
'animations.keys_per_timeline',
|
|
1657
|
+
'how hard each timeline is keyed, as keys per timeline',
|
|
1658
|
+
'keys/timeline',
|
|
1659
|
+
perTimeline(at),
|
|
1660
|
+
perTimeline(bt),
|
|
1661
|
+
`Convention: every key of every timeline (${at.keys} vs ${bt.keys}) over the number of timelines that exist ` +
|
|
1662
|
+
`(${at.timelines} vs ${bt.timelines}), compared as min/max at ${RATE_PLACES} decimal places. Read beside ` +
|
|
1663
|
+
'`key_density`: this one alone moving means the same keying spread over a different number of timelines.',
|
|
1664
|
+
),
|
|
1665
|
+
],
|
|
1666
|
+
paired?.pairedBy);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
function eventFacts(root: Json): Map<string, string> {
|
|
1670
|
+
const out = new Map<string, string>();
|
|
1671
|
+
if (!isObj(root.events)) return out;
|
|
1672
|
+
for (const [name, def] of Object.entries(root.events)) {
|
|
1673
|
+
const d = isObj(def) ? def : {};
|
|
1674
|
+
// The typed payload, in the parser's own defaults (`:469-484`), so that a
|
|
1675
|
+
// field written explicitly at its default reads the same as one omitted.
|
|
1676
|
+
out.set(
|
|
1677
|
+
name,
|
|
1678
|
+
JSON.stringify({
|
|
1679
|
+
int: num(d.int) ?? 0,
|
|
1680
|
+
float: num(d.float) ?? 0,
|
|
1681
|
+
string: str(d.string) ?? '',
|
|
1682
|
+
audio: str(d.audio) ?? '',
|
|
1683
|
+
volume: num(d.volume) ?? 1,
|
|
1684
|
+
balance: num(d.balance) ?? 0,
|
|
1685
|
+
}),
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
return out;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function diffEvents(c: Json, r: Json): DiffSection {
|
|
1692
|
+
const a = eventFacts(c);
|
|
1693
|
+
const b = eventFacts(r);
|
|
1694
|
+
return sectionOf('events', [
|
|
1695
|
+
jaccard('events.names', 'the event names', new Set(a.keys()), new Set(b.keys())),
|
|
1696
|
+
agreement('events.payloads', 'each event carries the same typed payload', a, b, (x, y) => x === y),
|
|
1697
|
+
]);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// ---------------------------------------------------------------------------
|
|
1701
|
+
// the skeleton header
|
|
1702
|
+
// ---------------------------------------------------------------------------
|
|
1703
|
+
|
|
1704
|
+
/**
|
|
1705
|
+
* The header's box: `x`, `y`, `width`, `height` of the setup-pose bounding box
|
|
1706
|
+
* — the box around the attachments at the setup pose, which is what the Spine
|
|
1707
|
+
* format says the four are — or the absence of all four.
|
|
1708
|
+
*
|
|
1709
|
+
* 🔁 **Renamed from `stage_present`/`stage_box` by issue #907, because the old
|
|
1710
|
+
* name had become a lie.** Until then `build` copied rigc's stage — the crop
|
|
1711
|
+
* the art was painted in — into these four fields, so on a rigc candidate the
|
|
1712
|
+
* measure read a stage and on an editor export it read a bounding box, under
|
|
1713
|
+
* one word. Since #907 `build` writes the setup-pose bounding box there too
|
|
1714
|
+
* (`headerBoundsOf` in `src/compile.ts`, held to spine-core's `getBounds`), so
|
|
1715
|
+
* both sides of every comparison carry the same kind of value, and the stage —
|
|
1716
|
+
* which a rig spec states and the model document carries — is not in the file
|
|
1717
|
+
* at all. A measure called `stage` would send a reader to change the stage,
|
|
1718
|
+
* which no longer moves this number.
|
|
1719
|
+
*
|
|
1720
|
+
* 🔍 **Why this measure exists at all** (issue #578). The header box was the
|
|
1721
|
+
* one value `build` required and no instrument in this tree could see:
|
|
1722
|
+
* `validate`, `check` and `render` all ignore it — `render` frames from the
|
|
1723
|
+
* posed bounds — and `diff` had no header measure, so a sweep that handed a
|
|
1724
|
+
* deliberately absurd unit stage `0,0,1,1` to 37 real exports read **1.000 on
|
|
1725
|
+
* every measure** for 32 of them.
|
|
1726
|
+
*
|
|
1727
|
+
* ⭐ **`bounds_present` is 1/1 or 0/1 and never `0/0`.** Both sides always have
|
|
1728
|
+
* a presence to compare, including when both say "none" — that is agreement,
|
|
1729
|
+
* not an absence of data, and `total: 0` here would be the vacuous 1.000 this
|
|
1730
|
+
* file refuses. `bounds_box` is the one that goes vacuous, and only when there
|
|
1731
|
+
* are not two boxes to compare.
|
|
1732
|
+
*/
|
|
1733
|
+
interface HeaderBox {
|
|
1734
|
+
present: boolean;
|
|
1735
|
+
/**
|
|
1736
|
+
* The four fields as the header MEANS them: the extent exactly as stated, and
|
|
1737
|
+
* the origin of a stated box as stated or `0` where it is omitted. See
|
|
1738
|
+
* `headerBox` for why the second half is a reading and not a fallback.
|
|
1739
|
+
*/
|
|
1740
|
+
box: Array<number | null>;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
const BOX_FIELDS = ['x', 'y', 'width', 'height'] as const;
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* A header states a box by its EXTENT: a numeric `width` and `height`.
|
|
1747
|
+
*
|
|
1748
|
+
* `x`/`y` alone are an origin for a box that is not there — no export carries
|
|
1749
|
+
* that shape, and rigc never emits it — so they do not make a box on their own.
|
|
1750
|
+
*
|
|
1751
|
+
* ⭐ **Inside a stated box, an omitted `x`/`y` IS `0`** (issue #620). That is a
|
|
1752
|
+
* reading of the format, not a value invented for a gap — the distinction this
|
|
1753
|
+
* file lives or dies by — and three measurements carry it, none of them
|
|
1754
|
+
* anybody's word for the convention:
|
|
1755
|
+
*
|
|
1756
|
+
* - **The header's writer omits a field at its default.** All twelve exports
|
|
1757
|
+
* under `examples/` omit `referenceScale`, whose default the parser itself
|
|
1758
|
+
* spells two lines below the four raw assignments (`SkeletonJson.js:74`,
|
|
1759
|
+
* `getValue(skeletonMap, "referenceScale", 100)`), and none of the 389 bone
|
|
1760
|
+
* `x`/`y`/`rotation`/`shearX`/`shearY` values those same files DO write is an
|
|
1761
|
+
* explicit `0`. A box at the origin therefore has no spelling but the
|
|
1762
|
+
* omission, so reading the omission as absence reads a value the format cannot
|
|
1763
|
+
* express. (An editor round trip of a header stating `"x": 0, "y": 0` wrote
|
|
1764
|
+
* neither back — measured through a licensed 4.3.26 editor for issue #907.)
|
|
1765
|
+
* - **The binary reader supplies it unconditionally.** `SkeletonBinary.js:69-72`
|
|
1766
|
+
* reads the four as four floats with no key to be missing, so one skeleton's
|
|
1767
|
+
* origin is `0` in a `.skel` and absent in a `.json`. A measure that called
|
|
1768
|
+
* those two different boxes would be reporting the container.
|
|
1769
|
+
* - **The JSON reader's silence is an oversight rather than a meaning.**
|
|
1770
|
+
* `SkeletonJson.js:70-73` is `skeletonData.x = skeletonMap.x`, a raw
|
|
1771
|
+
* assignment that overwrites `SkeletonData`'s own `0` with `undefined`. The
|
|
1772
|
+
* line beside it does the same to `fps`, whose default is `30` and which every
|
|
1773
|
+
* one of the twelve omits — so taking `undefined` for a meaning would say the
|
|
1774
|
+
* editor has never exported a frame rate.
|
|
1775
|
+
*
|
|
1776
|
+
* ⚠️ **The default is the ORIGIN's alone**, and the guard is the paragraph above
|
|
1777
|
+
* it: the extent is what states a box, so defaulting it would turn the box-less
|
|
1778
|
+
* header of issue #578 into a `0x0` box at `0,0` and answer the question
|
|
1779
|
+
* instead of reading it.
|
|
1780
|
+
*/
|
|
1781
|
+
function headerBox(root: Json): HeaderBox {
|
|
1782
|
+
const header = isObj(root.skeleton) ? root.skeleton : {};
|
|
1783
|
+
const box = BOX_FIELDS.map((k) => num(header[k]));
|
|
1784
|
+
const present = box[2] !== null && box[3] !== null;
|
|
1785
|
+
return { present, box: present ? [box[0] ?? 0, box[1] ?? 0, box[2], box[3]] : box };
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* The two header measures.
|
|
1790
|
+
*
|
|
1791
|
+
* ⚠️ **The box is compared EXACTLY, and that is a measurement rather than a
|
|
1792
|
+
* choice.** The structural measures in this file have exactly one tolerance —
|
|
1793
|
+
* `FRAME`, one sixtieth of a second, used once, for `animations.duration` — and
|
|
1794
|
+
* no spatial one anywhere, because they compare no position at all (that is
|
|
1795
|
+
* `bonedist.ts`). Each side's box is a number its writer computed: rigc's is
|
|
1796
|
+
* spine-core's `getBounds` on the header's 1e-6 grid at float32 (`headerBoxNumber`; `tools/core_gate.ts` holds it), and
|
|
1797
|
+
* the editor's is its own arithmetic, which on the twelve examples sits up to
|
|
1798
|
+
* 0.0071 units from `getBounds` on the same skeleton and agrees with its
|
|
1799
|
+
* float32 on 13 of the 48 numbers (issue #907's measurement). A rebuild and its
|
|
1800
|
+
* export therefore read below 4/4 here by the editor's arithmetic alone, and an
|
|
1801
|
+
* epsilon chosen to hide that would be a number nobody measured, in the file
|
|
1802
|
+
* whose whole job is to report measured ones. The measure is reported and gates
|
|
1803
|
+
* nothing on the ladder.
|
|
1804
|
+
*
|
|
1805
|
+
* ⭐ `diffSkeletonValues` (issue #615) is the second tolerance in the file and
|
|
1806
|
+
* it does not weaken this one. It compares positions, so it needs one; both its
|
|
1807
|
+
* terms are read off other code — the 1e-6 grid rigc's closed-form models are
|
|
1808
|
+
* evaluated on and the parser's float32 storage — rather than chosen here; and
|
|
1809
|
+
* the four numbers above are the one place the two overlap, where `bounds_box`
|
|
1810
|
+
* stays the stricter reading and says so by staying exact.
|
|
1811
|
+
*/
|
|
1812
|
+
function diffHeader(c: Json, r: Json): DiffReported {
|
|
1813
|
+
const a = headerBox(c);
|
|
1814
|
+
const b = headerBox(r);
|
|
1815
|
+
const both = a.present && b.present;
|
|
1816
|
+
const agreed = both ? a.box.filter((v, i) => v === b.box[i]).length : 0;
|
|
1817
|
+
const side = (f: HeaderBox): string => (f.present ? `${f.box[2]}x${f.box[3]} at ${f.box[0]},${f.box[1]}` : 'none');
|
|
1818
|
+
return {
|
|
1819
|
+
measures: [
|
|
1820
|
+
measure(
|
|
1821
|
+
'skeleton.bounds_present',
|
|
1822
|
+
'both headers carry a setup-pose bounding box, or neither does',
|
|
1823
|
+
a.present === b.present ? 1 : 0,
|
|
1824
|
+
1,
|
|
1825
|
+
`candidate ${side(a)}; reference ${side(b)}. A header carries a box by stating a width and a height`,
|
|
1826
|
+
),
|
|
1827
|
+
measure(
|
|
1828
|
+
'skeleton.bounds_box',
|
|
1829
|
+
'the header carries the same box (x, y, width, height — the extent as stated, an omitted origin as the 0 it means)',
|
|
1830
|
+
agreed,
|
|
1831
|
+
both ? BOX_FIELDS.length : 0,
|
|
1832
|
+
both
|
|
1833
|
+
? `candidate ${side(a)}; reference ${side(b)}`
|
|
1834
|
+
: a.present === b.present
|
|
1835
|
+
? 'neither header carries a box, so there is no box to compare — `bounds_present` carries that'
|
|
1836
|
+
: 'only one header carries a box, so there is no second box to compare — `bounds_present` carries that',
|
|
1837
|
+
),
|
|
1838
|
+
],
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// ---------------------------------------------------------------------------
|
|
1843
|
+
// the report
|
|
1844
|
+
// ---------------------------------------------------------------------------
|
|
1845
|
+
|
|
1846
|
+
function orientation(root: Json): Record<string, number> {
|
|
1847
|
+
const attachments = attachmentFacts(root);
|
|
1848
|
+
return {
|
|
1849
|
+
bones: objs(root.bones).length,
|
|
1850
|
+
slots: objs(root.slots).length,
|
|
1851
|
+
skins: objs(root.skins).length,
|
|
1852
|
+
attachments: attachments.byKey.size,
|
|
1853
|
+
constraints: objs(root.constraints).length,
|
|
1854
|
+
animations: isObj(root.animations) ? Object.keys(root.animations).length : 0,
|
|
1855
|
+
events: isObj(root.events) ? Object.keys(root.events).length : 0,
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
export function diffSkeletons(candidate: unknown, reference: unknown, options?: DiffOptions): DiffReport {
|
|
1860
|
+
const c = isObj(candidate) ? candidate : {};
|
|
1861
|
+
const r = isObj(reference) ? reference : {};
|
|
1862
|
+
const animationPairs = options?.animationPairs ?? [];
|
|
1863
|
+
return {
|
|
1864
|
+
sections: [
|
|
1865
|
+
diffBones(c, r),
|
|
1866
|
+
diffSlots(c, r),
|
|
1867
|
+
diffAttachments(c, r),
|
|
1868
|
+
diffConstraints(c, r),
|
|
1869
|
+
diffAnimations(c, r, animationPairs),
|
|
1870
|
+
diffEvents(c, r),
|
|
1871
|
+
],
|
|
1872
|
+
header: diffHeader(c, r),
|
|
1873
|
+
candidate: orientation(c),
|
|
1874
|
+
reference: orientation(r),
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/**
|
|
1879
|
+
* Every NAME-MATCHED measure that is not a perfect match, by id. What a test
|
|
1880
|
+
* asserts on.
|
|
1881
|
+
*
|
|
1882
|
+
* The name-agnostic reports are deliberately not folded in here. They are a
|
|
1883
|
+
* separate comparison with its own measure set, and a single flattened list
|
|
1884
|
+
* would make one edit's footprint depend on how many measures the other report
|
|
1885
|
+
* happens to define — which is the opposite of what these assertions are for.
|
|
1886
|
+
* `movedAgnosticMeasures` is the other half, and a case pins both.
|
|
1887
|
+
*/
|
|
1888
|
+
export function movedMeasures(report: DiffReport): string[] {
|
|
1889
|
+
return report.sections.flatMap((s) => s.measures.filter((m) => m.ratio < 1).map((m) => m.id));
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/** The same, over the name-agnostic reports. Empty when every shape agrees. */
|
|
1893
|
+
export function movedAgnosticMeasures(report: DiffReport): string[] {
|
|
1894
|
+
return report.sections.flatMap((s) => (s.nameAgnostic?.measures ?? []).filter((m) => m.ratio < 1).map((m) => m.id));
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* The same, over the reported measures.
|
|
1899
|
+
*
|
|
1900
|
+
* A third list rather than a third of one flattened list, for the reason
|
|
1901
|
+
* `movedAgnosticMeasures` gives: a case's expectation should not depend on how
|
|
1902
|
+
* many measures a different report happens to define. Pinned on every case and
|
|
1903
|
+
* not only the ones about a mesh or a curve — a figure nothing asserts on is a
|
|
1904
|
+
* figure that can quietly stop moving, and these gate nothing, so nothing else
|
|
1905
|
+
* would notice.
|
|
1906
|
+
*/
|
|
1907
|
+
export function movedReportedMeasures(report: DiffReport): string[] {
|
|
1908
|
+
return [...report.sections.flatMap((s) => s.reported?.measures ?? []), ...report.header.measures]
|
|
1909
|
+
.filter((m) => m.ratio < 1)
|
|
1910
|
+
.map((m) => m.id);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// ---------------------------------------------------------------------------
|
|
1914
|
+
// The value level — what the structural measures above are blind to
|
|
1915
|
+
// ---------------------------------------------------------------------------
|
|
1916
|
+
|
|
1917
|
+
/**
|
|
1918
|
+
* The one absolute grid rigc still emits on: `onModelGrid` in
|
|
1919
|
+
* [`compile.ts`](compile.ts), 1e-6 in a closed-form model's own unit, which a
|
|
1920
|
+
* deform `transform` and a track `derive` are evaluated onto before they are
|
|
1921
|
+
* written. So a value a model produced may sit up to one step of it from a
|
|
1922
|
+
* reading that evaluated the same model in float64 — through no fault of
|
|
1923
|
+
* anything this measure is looking for.
|
|
1924
|
+
*
|
|
1925
|
+
* ⚠️ **Restated, not retired, by issue #716.** This said `r6` until then — the
|
|
1926
|
+
* six fixed decimals every emitted number took, and `keyTime` rounding a key
|
|
1927
|
+
* time down over the same step. Both are gone: every number is now its
|
|
1928
|
+
* float32's shortest name, whose distance from its double the second term
|
|
1929
|
+
* already covers, and a key time steps at most one float down. What survives
|
|
1930
|
+
* is the models' grid, which is absolute because a model's identities are
|
|
1931
|
+
* exact zeros and a float's grid is relative. On the corpus, where nothing is
|
|
1932
|
+
* a model, the term is idle: the twelve rebuilds read identical to their
|
|
1933
|
+
* sources, value for value (`valueTolerance`'s figure).
|
|
1934
|
+
*/
|
|
1935
|
+
export const VALUE_EMITTED_GRID = 1e-6;
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* One float32 ULP, relative. `spine-core` holds every frame, curve sample and
|
|
1939
|
+
* vertex in a `Float32Array` (`Utils.newFloatArray`), so two decimals that
|
|
1940
|
+
* differ by the grid above can land on adjacent float32s, and the difference
|
|
1941
|
+
* the walk sees is the decimal gap plus that step.
|
|
1942
|
+
*
|
|
1943
|
+
* ⚠️ It is also the floor of what this measure can see AT ALL: a difference
|
|
1944
|
+
* smaller than one float32 step is invisible to the parser and therefore to
|
|
1945
|
+
* this. The tolerance states that rather than hiding it.
|
|
1946
|
+
*/
|
|
1947
|
+
export const VALUE_PARSED_ULP = 2 ** -23;
|
|
1948
|
+
|
|
1949
|
+
/**
|
|
1950
|
+
* What two readings of one number are allowed to differ by, and nothing more.
|
|
1951
|
+
*
|
|
1952
|
+
* Both terms are derived rather than fitted: the first is rigc's models' grid,
|
|
1953
|
+
* the second is the parser's storage. Measured over the twelve editor exports
|
|
1954
|
+
* in `examples/`, the widest gap between a rebuild and the file it was read
|
|
1955
|
+
* from is **0** — none of 189,699 numeric values differs at all, since issue
|
|
1956
|
+
* #716 made every emitted number its float's own name (it was 0.81 of this
|
|
1957
|
+
* bound, over 56,951 values that differed, while rigc emitted six decimals) —
|
|
1958
|
+
* so the corpus sits inside a bound that was not drawn around it, and no
|
|
1959
|
+
* longer tests its width: `IG21` does.
|
|
1960
|
+
*/
|
|
1961
|
+
export function valueTolerance(magnitude: number): number {
|
|
1962
|
+
return VALUE_EMITTED_GRID + VALUE_PARSED_ULP * magnitude;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/** Whether two readings of one path agree. `NaN` on both sides is agreement: neither file states it. */
|
|
1966
|
+
function valuesAgree(a: number | string, b: number | string): boolean {
|
|
1967
|
+
if (typeof a !== 'number' || typeof b !== 'number') return a === b;
|
|
1968
|
+
if (Number.isNaN(a) && Number.isNaN(b)) return true;
|
|
1969
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return a === b;
|
|
1970
|
+
return Math.abs(a - b) <= valueTolerance(Math.max(Math.abs(a), Math.abs(b)));
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* The measures `diffSkeletonValues` defines, in the order it prints them, and
|
|
1975
|
+
* what each one is. The set is fixed rather than derived from the paths in
|
|
1976
|
+
* front of it, so that a run can say a measure compared NOTHING — the same
|
|
1977
|
+
* distinction `DiffMeasure.total` draws everywhere else in this file.
|
|
1978
|
+
*/
|
|
1979
|
+
const VALUE_MEASURES: ReadonlyArray<{ id: string; what: string; prefix: string }> = [
|
|
1980
|
+
{ id: 'values.skeleton', what: 'the header and its setup-pose bounding box', prefix: 'skeleton/' },
|
|
1981
|
+
{ id: 'values.bones', what: 'every bone setup pose, its length and its colour', prefix: 'bones/' },
|
|
1982
|
+
{ id: 'values.slots', what: 'every slot colour, dark colour, blend mode and setup attachment', prefix: 'slots/' },
|
|
1983
|
+
{ id: 'values.attachments', what: 'every attachment offset, size, vertex, weight, uv and triangle', prefix: 'skins/' },
|
|
1984
|
+
{
|
|
1985
|
+
id: 'values.constraints',
|
|
1986
|
+
what: 'every constraint field the parser reads: pose, flags, offsets, and the property map and its kinds',
|
|
1987
|
+
prefix: 'constraints/',
|
|
1988
|
+
},
|
|
1989
|
+
{ id: 'values.events', what: 'every event payload in the setup pose', prefix: 'events/' },
|
|
1990
|
+
{ id: 'values.key_times', what: 'every key time, and each animation\'s duration', prefix: 'animations/' },
|
|
1991
|
+
{ id: 'values.key_values', what: 'every keyed value: poses, deform vertices, draw orders, event payloads', prefix: 'animations/' },
|
|
1992
|
+
{ id: 'values.curves', what: 'every curve type and the Bezier samples the parser built from its handles', prefix: 'animations/' },
|
|
1993
|
+
];
|
|
1994
|
+
|
|
1995
|
+
/**
|
|
1996
|
+
* Which measure a path belongs to. The first path segment decides it, except
|
|
1997
|
+
* under `animations/`, where the three arms are the three questions that fail
|
|
1998
|
+
* differently: a timing that moved, a value that moved, and an easing that
|
|
1999
|
+
* moved. A segment nothing here names becomes a measure of its own rather than
|
|
2000
|
+
* disappearing into one of these — a walk that grows a top-level kind should
|
|
2001
|
+
* arrive as a new line, not as a silently wider denominator.
|
|
2002
|
+
*/
|
|
2003
|
+
function valueMeasureFor(path: string): string {
|
|
2004
|
+
const head = path.slice(0, path.indexOf('/') + 1);
|
|
2005
|
+
if (head === 'animations/') {
|
|
2006
|
+
if (path.endsWith('/duration') || /\/key\/\d+\/time$/.test(path)) return 'values.key_times';
|
|
2007
|
+
if (/\/curves\/\d+$/.test(path)) return 'values.curves';
|
|
2008
|
+
return 'values.key_values';
|
|
2009
|
+
}
|
|
2010
|
+
const known = VALUE_MEASURES.find((m) => m.prefix === head);
|
|
2011
|
+
return known === undefined ? `values.${head.slice(0, -1) || path}` : known.id;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
/** How many offending paths a note spells out before it counts the rest. */
|
|
2015
|
+
const VALUE_OFFENDERS_SPELLED_OUT = 3;
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* The value-level comparison: are the numbers inside the structure the same
|
|
2019
|
+
* numbers?
|
|
2020
|
+
*
|
|
2021
|
+
* ## Why it is a separate call rather than a section of `diffSkeletons`
|
|
2022
|
+
*
|
|
2023
|
+
* Because its inputs are not JSON. Every default in the format — `time` absent
|
|
2024
|
+
* is 0, `scaleX` absent is 1, a physics key's `value` absent is 0 while its
|
|
2025
|
+
* `mix` is 1 — has to be applied before two files can be compared value by
|
|
2026
|
+
* value, and writing that table here would be a second spelling of the format
|
|
2027
|
+
* inside the gate. So the defaults come from the parser, through
|
|
2028
|
+
* `skeletonValues` in [`validate.ts`](validate.ts), and this function compares
|
|
2029
|
+
* what it returns: an ordered list of `path → value` with every default already
|
|
2030
|
+
* applied by the one reader that owns them.
|
|
2031
|
+
*
|
|
2032
|
+
* ⚠️ **It reports; it does not gate the ladder.** A rung's brief withholds
|
|
2033
|
+
* coordinates on purpose, so no reading of the reference frames could decide
|
|
2034
|
+
* these — `docs/GATE.md`'s *What never gates* applies word for word. The corpus
|
|
2035
|
+
* gate is the one place they DO decide a verdict, and for the reason `IG16`
|
|
2036
|
+
* already gives about `mesh_edges`: there the reference is the very file the
|
|
2037
|
+
* specs were read from, so a value that moved is a decompiler loss rather than
|
|
2038
|
+
* a candidate's entitlement.
|
|
2039
|
+
*
|
|
2040
|
+
* ## What a difference means, and what it cannot mean
|
|
2041
|
+
*
|
|
2042
|
+
* A path missing on one side is counted as unmatched and named as such: it is a
|
|
2043
|
+
* structural finding too, and the measures above it are where that is
|
|
2044
|
+
* diagnosed. A number present on both sides is compared under
|
|
2045
|
+
* `valueTolerance`, which is derived from rigc's quantiser and the parser's
|
|
2046
|
+
* float32 storage — never from the corpus.
|
|
2047
|
+
*/
|
|
2048
|
+
export function diffSkeletonValues(candidate: SkeletonValue[], reference: SkeletonValue[]): DiffMeasure[] {
|
|
2049
|
+
const left = new Map<string, number | string>();
|
|
2050
|
+
for (const v of candidate) left.set(v.path, v.value);
|
|
2051
|
+
const right = new Map<string, number | string>();
|
|
2052
|
+
for (const v of reference) right.set(v.path, v.value);
|
|
2053
|
+
|
|
2054
|
+
interface Tally {
|
|
2055
|
+
matched: number;
|
|
2056
|
+
total: number;
|
|
2057
|
+
offenders: string[];
|
|
2058
|
+
unpaired: number;
|
|
2059
|
+
}
|
|
2060
|
+
const tallies = new Map<string, Tally>();
|
|
2061
|
+
const order: string[] = VALUE_MEASURES.map((m) => m.id);
|
|
2062
|
+
const tallyFor = (id: string): Tally => {
|
|
2063
|
+
const held = tallies.get(id);
|
|
2064
|
+
if (held !== undefined) return held;
|
|
2065
|
+
const fresh: Tally = { matched: 0, total: 0, offenders: [], unpaired: 0 };
|
|
2066
|
+
tallies.set(id, fresh);
|
|
2067
|
+
if (!order.includes(id)) order.push(id);
|
|
2068
|
+
return fresh;
|
|
2069
|
+
};
|
|
2070
|
+
for (const id of order) tallyFor(id);
|
|
2071
|
+
|
|
2072
|
+
// The candidate's paths in the candidate's own order, then whatever the
|
|
2073
|
+
// reference has that the candidate does not — an iteration over the two
|
|
2074
|
+
// lists as they were built, never over a set.
|
|
2075
|
+
for (const { path, value } of candidate) {
|
|
2076
|
+
const tally = tallyFor(valueMeasureFor(path));
|
|
2077
|
+
tally.total++;
|
|
2078
|
+
const other = right.get(path);
|
|
2079
|
+
if (other === undefined) {
|
|
2080
|
+
tally.unpaired++;
|
|
2081
|
+
if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) tally.offenders.push(`${path} (candidate only)`);
|
|
2082
|
+
continue;
|
|
2083
|
+
}
|
|
2084
|
+
if (valuesAgree(value, other)) {
|
|
2085
|
+
tally.matched++;
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) {
|
|
2089
|
+
const gap =
|
|
2090
|
+
typeof value === 'number' && typeof other === 'number'
|
|
2091
|
+
? `, off by ${Math.abs(value - other).toExponential(3)} against ${valueTolerance(
|
|
2092
|
+
Math.max(Math.abs(value), Math.abs(other)),
|
|
2093
|
+
).toExponential(3)} allowed`
|
|
2094
|
+
: '';
|
|
2095
|
+
tally.offenders.push(`${path} ${JSON.stringify(value)} vs ${JSON.stringify(other)}${gap}`);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
for (const { path } of reference) {
|
|
2099
|
+
if (left.has(path)) continue;
|
|
2100
|
+
const tally = tallyFor(valueMeasureFor(path));
|
|
2101
|
+
tally.total++;
|
|
2102
|
+
tally.unpaired++;
|
|
2103
|
+
if (tally.offenders.length < VALUE_OFFENDERS_SPELLED_OUT) tally.offenders.push(`${path} (reference only)`);
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
return order.map((id) => {
|
|
2107
|
+
const tally = tallyFor(id);
|
|
2108
|
+
const defined = VALUE_MEASURES.find((m) => m.id === id);
|
|
2109
|
+
const missed = tally.total - tally.matched;
|
|
2110
|
+
const note =
|
|
2111
|
+
tally.total === 0
|
|
2112
|
+
? 'neither side carries one'
|
|
2113
|
+
: missed === 0
|
|
2114
|
+
? undefined
|
|
2115
|
+
: `${missed} moved` +
|
|
2116
|
+
(tally.unpaired === 0 ? '' : `, ${tally.unpaired} of them on one side only`) +
|
|
2117
|
+
`: ${tally.offenders.join('; ')}` +
|
|
2118
|
+
(missed > tally.offenders.length ? `; …and ${missed - tally.offenders.length} more` : '');
|
|
2119
|
+
return measure(id, defined?.what ?? 'values under a path kind this report does not define', tally.matched, tally.total, note);
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
/** Every value measure that is not a perfect match, by id. What a test asserts on. */
|
|
2124
|
+
export function movedValueMeasures(measures: DiffMeasure[]): string[] {
|
|
2125
|
+
return measures.filter((m) => m.ratio < 1).map((m) => m.id);
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
/**
|
|
2129
|
+
* `skeleton 1.000 · bones 1.000 · …`, the same one-line shape `reportedFigures`
|
|
2130
|
+
* has, and with no roll-up for the same reason: a mean over "did the times move"
|
|
2131
|
+
* and "did the vertices move" is a number with no referent.
|
|
2132
|
+
*/
|
|
2133
|
+
export function valueFigures(measures: DiffMeasure[]): string {
|
|
2134
|
+
return measures.map((m) => `${m.id.slice(m.id.indexOf('.') + 1)} ${fmt(m.ratio)}`).join(' · ');
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
const fmt = (n: number): string => n.toFixed(3);
|
|
2138
|
+
|
|
2139
|
+
/**
|
|
2140
|
+
* `mesh_edges 1.000 · key_density 0.333`, or `null` when nothing is reported.
|
|
2141
|
+
*
|
|
2142
|
+
* Every reported measure by name, and no roll-up of any kind: these have unlike
|
|
2143
|
+
* units, so a digest over them would be the mean `DiffReported` exists to
|
|
2144
|
+
* refuse. The section is dropped from the id because the ids are unique across
|
|
2145
|
+
* the report and the line is a summary.
|
|
2146
|
+
*/
|
|
2147
|
+
export function reportedFigures(report: DiffReport): string | null {
|
|
2148
|
+
const measures = [...report.sections.flatMap((s) => s.reported?.measures ?? []), ...report.header.measures];
|
|
2149
|
+
if (measures.length === 0) return null;
|
|
2150
|
+
return measures.map((m) => `${m.id.slice(m.id.indexOf('.') + 1)} ${fmt(m.ratio)}`).join(' · ');
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/** `bones 0.567 (name-matched) · 1.000 (name-agnostic)`, or just the one figure. */
|
|
2154
|
+
export function sectionFigures(section: DiffSection): string {
|
|
2155
|
+
const matched = `${fmt(section.ratio)} (name-matched)`;
|
|
2156
|
+
return section.nameAgnostic === undefined
|
|
2157
|
+
? `${section.name} ${matched}`
|
|
2158
|
+
: `${section.name} ${matched} · ${fmt(section.nameAgnostic.ratio)} (name-agnostic)`;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
function measureLines(measures: DiffMeasure[], strip: number): string[] {
|
|
2162
|
+
return measures.map((m) => {
|
|
2163
|
+
const counts = `${m.matched}/${m.total}`;
|
|
2164
|
+
return ` ${fmt(m.ratio)} ${m.id.slice(strip).padEnd(28)} ${counts.padEnd(11)} ${m.what}${m.note ? ` — ${m.note}` : ''}`;
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
export function diffLines(report: DiffReport, labels: { candidate: string; reference: string }): string[] {
|
|
2169
|
+
const lines: string[] = [];
|
|
2170
|
+
lines.push(` candidate ${labels.candidate}`);
|
|
2171
|
+
lines.push(` reference ${labels.reference}`);
|
|
2172
|
+
const keys = Object.keys(report.reference);
|
|
2173
|
+
lines.push(` .. ${keys.map((k) => `${k}=${report.candidate[k]}/${report.reference[k]}`).join(' ')} (candidate/reference)`);
|
|
2174
|
+
lines.push('');
|
|
2175
|
+
// The `skeleton` block, first because that is where it sits in the file, and
|
|
2176
|
+
// with `(no mean)` for the same reason a section's `(reported)` block has one.
|
|
2177
|
+
lines.push(
|
|
2178
|
+
` ${'skeleton (reported)'.padEnd(21)} (no mean) over ${report.header.measures.length} measures` +
|
|
2179
|
+
' — the header\'s setup-pose bounding box, which no reading of the frames could decide',
|
|
2180
|
+
);
|
|
2181
|
+
lines.push(...measureLines(report.header.measures, 'skeleton.'.length));
|
|
2182
|
+
lines.push('');
|
|
2183
|
+
// Wide enough for `bones (name-agnostic)` and `animations (reported)`, both
|
|
2184
|
+
// exactly 21, so that most of a section's headings line their figures up
|
|
2185
|
+
// under each other and read as a pair.
|
|
2186
|
+
//
|
|
2187
|
+
// ⚠️ Two headings are longer and push their own figure right instead:
|
|
2188
|
+
// `attachments (reported)`, which has done so since that block existed, and
|
|
2189
|
+
// `animations (name-agnostic)` (issue #720). Widening the column is the
|
|
2190
|
+
// obvious repair and it is the wrong one — it moves every heading line of
|
|
2191
|
+
// every report, and those lines are quoted verbatim in `docs/LADDER.md` and
|
|
2192
|
+
// in the landed run records under `bench/runs/`, which are sealed. A
|
|
2193
|
+
// cosmetic alignment is not worth a byte change in every transcript already
|
|
2194
|
+
// written, and the overflow is visible rather than silent.
|
|
2195
|
+
const head = (label: string, ratio: number, n: number): string =>
|
|
2196
|
+
` ${label.padEnd(21)} mean ${fmt(ratio)} over ${n} measures`;
|
|
2197
|
+
for (const section of report.sections) {
|
|
2198
|
+
lines.push(head(section.name, section.ratio, section.measures.length));
|
|
2199
|
+
lines.push(...measureLines(section.measures, section.name.length + 1));
|
|
2200
|
+
const agnostic = section.nameAgnostic;
|
|
2201
|
+
if (agnostic) {
|
|
2202
|
+
lines.push('');
|
|
2203
|
+
lines.push(
|
|
2204
|
+
`${head(`${section.name} (name-agnostic)`, agnostic.ratio, agnostic.measures.length)}` +
|
|
2205
|
+
' — the same two skeletons compared with names thrown away' +
|
|
2206
|
+
// The pairing is part of the figure, not decoration: see `DiffAgnostic.pairedBy`.
|
|
2207
|
+
(agnostic.pairedBy === undefined ? '' : `, ${agnostic.pairedBy}`),
|
|
2208
|
+
);
|
|
2209
|
+
lines.push(...measureLines(agnostic.measures, section.name.length + '.agnostic.'.length));
|
|
2210
|
+
}
|
|
2211
|
+
// No mean on this heading, and the absence is the statement: see
|
|
2212
|
+
// `DiffReported`. `(no mean)` sits where `mean 0.000` would, so the column
|
|
2213
|
+
// the eye follows down the report is unbroken.
|
|
2214
|
+
const reported = section.reported;
|
|
2215
|
+
if (reported) {
|
|
2216
|
+
lines.push('');
|
|
2217
|
+
lines.push(
|
|
2218
|
+
` ${`${section.name} (reported)`.padEnd(21)} (no mean) over ${reported.measures.length} ` +
|
|
2219
|
+
`measure${reported.measures.length === 1 ? '' : 's'}` +
|
|
2220
|
+
' — unobservable from the frames, so reported and folded into nothing',
|
|
2221
|
+
);
|
|
2222
|
+
lines.push(...measureLines(reported.measures, section.name.length + 1));
|
|
2223
|
+
}
|
|
2224
|
+
lines.push('');
|
|
2225
|
+
}
|
|
2226
|
+
lines.push(' There is no overall score, on purpose: a section mean is an average of the');
|
|
2227
|
+
lines.push(' measures printed under it, and averaging those together would hide which');
|
|
2228
|
+
lines.push(' half of a rig is wrong. Read the measures.');
|
|
2229
|
+
lines.push('');
|
|
2230
|
+
lines.push(' `bones` and `slots` carry two figures because most of their measures are');
|
|
2231
|
+
lines.push(' keyed on names, and a candidate is entitled to its own. They are two');
|
|
2232
|
+
lines.push(' comparisons, not two halves of one: name-agnostic 1.000 beside a low');
|
|
2233
|
+
lines.push(' name-matched figure means the shape is right and the vocabulary differs.');
|
|
2234
|
+
lines.push('');
|
|
2235
|
+
lines.push(' `animations` carries the same pair, and only once something has PAIRED the two');
|
|
2236
|
+
lines.push(' sides\' shots: `--as <candidate>=<reference>`, or one animation each side, which');
|
|
2237
|
+
lines.push(' pairs by position. With neither there is no reading of which shot is which, so');
|
|
2238
|
+
lines.push(' the block is absent rather than guessed — and its absence beside `names` 0.000');
|
|
2239
|
+
lines.push(' is the report saying the candidate named its shots itself and nothing said how.');
|
|
2240
|
+
lines.push('');
|
|
2241
|
+
lines.push(' `skeleton` is the file\'s own header block and reports two measures for its box.');
|
|
2242
|
+
lines.push(' It has no mean for the reason a `(reported)` block never does, and it never');
|
|
2243
|
+
lines.push(' gates for two: no reading of the frames recovers a setup-pose bounding box, and');
|
|
2244
|
+
lines.push(' each side\'s box is its writer\'s own arithmetic.');
|
|
2245
|
+
lines.push('');
|
|
2246
|
+
lines.push(' A `(reported)` block has no mean because its measures have unlike units, and');
|
|
2247
|
+
lines.push(' it stays out of the section mean above it for the same reason no clause may');
|
|
2248
|
+
lines.push(' read it: no reading of the reference frames could have decided these. They are');
|
|
2249
|
+
lines.push(' findings against the reference export all the same — each states its own');
|
|
2250
|
+
lines.push(' convention, and a rate states which side is the bigger one.');
|
|
2251
|
+
return lines;
|
|
2252
|
+
}
|