spine-rigc 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +76 -0
- package/README.md +558 -0
- package/cli.ts +739 -0
- package/docs/AUTHORING.md +1303 -0
- package/docs/SPEC_COVERAGE.md +1109 -0
- package/package.json +65 -0
- package/src/check.ts +1714 -0
- package/src/compile.ts +1861 -0
- package/src/diff.ts +847 -0
- package/src/errors.ts +22 -0
- package/src/framing.ts +539 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +433 -0
- package/src/png.ts +50 -0
- package/src/render.ts +974 -0
- package/src/rig.ts +731 -0
- package/src/slots.ts +603 -0
- package/src/timelines.ts +253 -0
- package/src/transform.ts +130 -0
- package/src/types.ts +586 -0
- package/src/validate.ts +1586 -0
- package/tools/font5x7.ts +101 -0
- package/tools/plate.ts +286 -0
package/src/diff.ts
ADDED
|
@@ -0,0 +1,847 @@
|
|
|
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
|
+
* Pure JSON reading — no spine-core, no filesystem. Validity is `validate.ts`'s
|
|
50
|
+
* job and this file assumes nothing about it. In particular the name-agnostic
|
|
51
|
+
* measures resolve nothing through the atlas: see `attachments.region_size`.
|
|
52
|
+
*/
|
|
53
|
+
import { walkTimelines } from './timelines.ts';
|
|
54
|
+
|
|
55
|
+
type Json = Record<string, unknown>;
|
|
56
|
+
|
|
57
|
+
function isObj(v: unknown): v is Json {
|
|
58
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function arr(v: unknown): unknown[] {
|
|
62
|
+
return Array.isArray(v) ? v : [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function objs(v: unknown): Json[] {
|
|
66
|
+
return arr(v).filter(isObj);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function str(v: unknown): string | null {
|
|
70
|
+
return typeof v === 'string' ? v : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function num(v: unknown): number | null {
|
|
74
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One comparable quantity. `total` 0 means neither side had anything to compare. */
|
|
78
|
+
export interface DiffMeasure {
|
|
79
|
+
/** Stable dotted id, e.g. `bones.parent_by_name`. Tests name these. */
|
|
80
|
+
id: string;
|
|
81
|
+
matched: number;
|
|
82
|
+
total: number;
|
|
83
|
+
ratio: number;
|
|
84
|
+
/** What the measure is, in one line, for the human table. */
|
|
85
|
+
what: string;
|
|
86
|
+
/** Set when the measure is vacuous or otherwise needs a caveat. */
|
|
87
|
+
note?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A second comparison of one section, made without consulting a name anywhere.
|
|
92
|
+
*
|
|
93
|
+
* Its `ratio` is an unweighted mean of its OWN measures and is not comparable,
|
|
94
|
+
* term for term, with the section's — the two sets overlap but neither contains
|
|
95
|
+
* the other. `count` / `depth_histogram` / `degree_sequence` appear in both,
|
|
96
|
+
* once under their section id and once under `<section>.agnostic.*`, so that
|
|
97
|
+
* each report reads on its own without the other open beside it.
|
|
98
|
+
*/
|
|
99
|
+
export interface DiffAgnostic {
|
|
100
|
+
/** Unweighted mean of the measures below. NOT a quality score either. */
|
|
101
|
+
ratio: number;
|
|
102
|
+
measures: DiffMeasure[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface DiffSection {
|
|
106
|
+
name: string;
|
|
107
|
+
/** Unweighted mean of this section's measures. NOT a quality score. */
|
|
108
|
+
ratio: number;
|
|
109
|
+
measures: DiffMeasure[];
|
|
110
|
+
/**
|
|
111
|
+
* The same section compared with names thrown away — `bones` and `slots`
|
|
112
|
+
* only, because they are the two sections whose measures are dominated by
|
|
113
|
+
* name-keyed ones (#21). Absent elsewhere rather than empty: a section with
|
|
114
|
+
* no name-agnostic comparison defined should say so by having none.
|
|
115
|
+
*/
|
|
116
|
+
nameAgnostic?: DiffAgnostic;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface DiffReport {
|
|
120
|
+
sections: DiffSection[];
|
|
121
|
+
/** Raw counts either side, for orientation. Never combined into anything. */
|
|
122
|
+
candidate: Record<string, number>;
|
|
123
|
+
reference: Record<string, number>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// measure primitives
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
const FRAME = 1 / 60;
|
|
131
|
+
|
|
132
|
+
function ratioOf(matched: number, total: number): number {
|
|
133
|
+
return total === 0 ? 1 : matched / total;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function measure(id: string, what: string, matched: number, total: number, note?: string): DiffMeasure {
|
|
137
|
+
return {
|
|
138
|
+
id,
|
|
139
|
+
what,
|
|
140
|
+
matched,
|
|
141
|
+
total,
|
|
142
|
+
ratio: ratioOf(matched, total),
|
|
143
|
+
...(total === 0 ? { note: note ?? 'neither side has any' } : note ? { note } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** |A ∩ B| over |A ∪ B| — the set measure. */
|
|
148
|
+
function jaccard(id: string, what: string, a: Set<string>, b: Set<string>): DiffMeasure {
|
|
149
|
+
let shared = 0;
|
|
150
|
+
for (const x of a) if (b.has(x)) shared++;
|
|
151
|
+
return measure(id, what, shared, a.size + b.size - shared);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Histogram intersection: sum of the per-bucket minima over the larger total.
|
|
156
|
+
* Used for every name-agnostic comparison, because it degrades smoothly — one
|
|
157
|
+
* bone in the wrong bucket costs one bone, not the whole measure.
|
|
158
|
+
*/
|
|
159
|
+
function histogram(id: string, what: string, a: Map<string, number>, b: Map<string, number>): DiffMeasure {
|
|
160
|
+
let shared = 0;
|
|
161
|
+
for (const [k, v] of a) shared += Math.min(v, b.get(k) ?? 0);
|
|
162
|
+
const sum = (m: Map<string, number>) => [...m.values()].reduce((x, y) => x + y, 0);
|
|
163
|
+
return measure(id, what, shared, Math.max(sum(a), sum(b)));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Longest common subsequence length — order agreement that survives an insert. */
|
|
167
|
+
function lcs(a: string[], b: string[]): number {
|
|
168
|
+
const rows: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
|
|
169
|
+
for (let i = 1; i <= a.length; i++) {
|
|
170
|
+
for (let j = 1; j <= b.length; j++) {
|
|
171
|
+
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]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return rows[a.length][b.length];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function counted(values: Iterable<string>): Map<string, number> {
|
|
178
|
+
const out = new Map<string, number>();
|
|
179
|
+
for (const v of values) out.set(v, (out.get(v) ?? 0) + 1);
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Agreement over the names both sides have, scored against the larger roster.
|
|
185
|
+
*
|
|
186
|
+
* ⚠️ The denominator is deliberately `max(candidate, reference)` rather than the
|
|
187
|
+
* shared count. Scoring only the shared names would let a candidate with two of
|
|
188
|
+
* twenty bones report 1.000 for "parents agree", which is the shape of a
|
|
189
|
+
* measurement that flatters whatever is missing.
|
|
190
|
+
*/
|
|
191
|
+
function agreement<T>(
|
|
192
|
+
id: string,
|
|
193
|
+
what: string,
|
|
194
|
+
a: Map<string, T>,
|
|
195
|
+
b: Map<string, T>,
|
|
196
|
+
same: (x: T, y: T) => boolean,
|
|
197
|
+
): DiffMeasure {
|
|
198
|
+
let agree = 0;
|
|
199
|
+
for (const [k, v] of a) {
|
|
200
|
+
const other = b.get(k);
|
|
201
|
+
if (other !== undefined && same(v, other)) agree++;
|
|
202
|
+
}
|
|
203
|
+
return measure(id, what, agree, Math.max(a.size, b.size));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function meanRatio(measures: DiffMeasure[]): number {
|
|
207
|
+
return measures.length === 0 ? 1 : measures.reduce((s, m) => s + m.ratio, 0) / measures.length;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sectionOf(name: string, measures: DiffMeasure[], nameAgnostic?: DiffMeasure[]): DiffSection {
|
|
211
|
+
return {
|
|
212
|
+
name,
|
|
213
|
+
ratio: meanRatio(measures),
|
|
214
|
+
measures,
|
|
215
|
+
...(nameAgnostic === undefined ? {} : { nameAgnostic: { ratio: meanRatio(nameAgnostic), measures: nameAgnostic } }),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Order agreement over a sequence of SHAPES rather than names — the
|
|
221
|
+
* name-agnostic half of an `order` measure.
|
|
222
|
+
*
|
|
223
|
+
* LCS over the signatures, against the larger roster, exactly as the
|
|
224
|
+
* name-matched `order` measures do it, so the two read on the same scale. Two
|
|
225
|
+
* elements with the same signature are interchangeable here and swapping them
|
|
226
|
+
* is correctly invisible: name-agnostically they ARE the same element. The
|
|
227
|
+
* name-matched `order` measure is what catches that swap.
|
|
228
|
+
*/
|
|
229
|
+
function orderShape(id: string, what: string, a: string[], b: string[]): DiffMeasure {
|
|
230
|
+
return measure(id, what, lcs(a, b), Math.max(a.length, b.length));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// per-section extraction
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
interface BoneFacts {
|
|
238
|
+
order: string[];
|
|
239
|
+
parent: Map<string, string | null>;
|
|
240
|
+
hasLength: Map<string, boolean>;
|
|
241
|
+
hasInherit: Map<string, boolean>;
|
|
242
|
+
depths: Map<string, number>;
|
|
243
|
+
children: Map<string, number>;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function boneFacts(root: Json): BoneFacts {
|
|
247
|
+
const bones = objs(root.bones);
|
|
248
|
+
const order: string[] = [];
|
|
249
|
+
const parent = new Map<string, string | null>();
|
|
250
|
+
const hasLength = new Map<string, boolean>();
|
|
251
|
+
const hasInherit = new Map<string, boolean>();
|
|
252
|
+
const children = new Map<string, number>();
|
|
253
|
+
for (const b of bones) {
|
|
254
|
+
const name = str(b.name);
|
|
255
|
+
if (name === null) continue;
|
|
256
|
+
order.push(name);
|
|
257
|
+
parent.set(name, str(b.parent));
|
|
258
|
+
hasLength.set(name, 'length' in b);
|
|
259
|
+
hasInherit.set(name, 'inherit' in b);
|
|
260
|
+
children.set(name, children.get(name) ?? 0);
|
|
261
|
+
}
|
|
262
|
+
for (const [, p] of parent) {
|
|
263
|
+
if (p !== null) children.set(p, (children.get(p) ?? 0) + 1);
|
|
264
|
+
}
|
|
265
|
+
// Depth by walking to the root. A parent declared after its child is invalid
|
|
266
|
+
// Spine, so the walk terminates on any file the parser would accept; the
|
|
267
|
+
// `seen` guard is there so a malformed candidate cannot hang the comparison.
|
|
268
|
+
const depths = new Map<string, number>();
|
|
269
|
+
for (const name of order) {
|
|
270
|
+
let depth = 0;
|
|
271
|
+
const seen = new Set<string>([name]);
|
|
272
|
+
for (let at = parent.get(name) ?? null; at !== null; at = parent.get(at) ?? null) {
|
|
273
|
+
depth++;
|
|
274
|
+
if (seen.has(at)) break;
|
|
275
|
+
seen.add(at);
|
|
276
|
+
}
|
|
277
|
+
depths.set(name, depth);
|
|
278
|
+
}
|
|
279
|
+
return { order, parent, hasLength, hasInherit, depths, children };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The shape of a bone nothing declares — a slot bound to a name no bone
|
|
284
|
+
* carries. A real answer rather than a gap: it says "this hangs off nothing".
|
|
285
|
+
*/
|
|
286
|
+
const UNDECLARED = '?';
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A bone's place in the tree, written without its name: how far it sits from a
|
|
290
|
+
* root, and how many children hang off it. `d1c3` is "one hop down, three
|
|
291
|
+
* children".
|
|
292
|
+
*
|
|
293
|
+
* Nothing else in a bone's declaration is name-free AND structural. `x`, `y`,
|
|
294
|
+
* `rotation`, `scale` are the setup pose, which this section does not compare
|
|
295
|
+
* on either side of the split; `length` and `inherit` are compared by name
|
|
296
|
+
* above and have no name-free counterpart, because there is no way to say
|
|
297
|
+
* WHICH bone is missing its length without naming one.
|
|
298
|
+
*/
|
|
299
|
+
function boneShape(facts: BoneFacts, name: string): string {
|
|
300
|
+
if (!facts.parent.has(name)) return UNDECLARED;
|
|
301
|
+
return `d${facts.depths.get(name) ?? 0}c${facts.children.get(name) ?? 0}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function boneShapes(facts: BoneFacts): string[] {
|
|
305
|
+
return facts.order.map((n) => boneShape(facts, n));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function diffBones(c: Json, r: Json): DiffSection {
|
|
309
|
+
const a = boneFacts(c);
|
|
310
|
+
const b = boneFacts(r);
|
|
311
|
+
const shared = a.order.filter((n) => b.parent.has(n));
|
|
312
|
+
const max = Math.max(a.order.length, b.order.length);
|
|
313
|
+
const aShapes = boneShapes(a);
|
|
314
|
+
const bShapes = boneShapes(b);
|
|
315
|
+
return sectionOf(
|
|
316
|
+
'bones',
|
|
317
|
+
[
|
|
318
|
+
measure('bones.count', 'how many bones', Math.min(a.order.length, b.order.length), max),
|
|
319
|
+
jaccard('bones.names', 'the bone names themselves', new Set(a.order), new Set(b.order)),
|
|
320
|
+
agreement('bones.parent_by_name', 'each bone hangs off the same parent', a.parent, b.parent, (x, y) => x === y),
|
|
321
|
+
measure(
|
|
322
|
+
'bones.order',
|
|
323
|
+
'the bones are declared in the same order',
|
|
324
|
+
lcs(shared, b.order.filter((n) => a.parent.has(n))),
|
|
325
|
+
max,
|
|
326
|
+
),
|
|
327
|
+
agreement('bones.length_present', 'a setup `length` is present or absent alike', a.hasLength, b.hasLength, (x, y) => x === y),
|
|
328
|
+
agreement('bones.inherit_present', 'a setup `inherit` is present or absent alike', a.hasInherit, b.hasInherit, (x, y) => x === y),
|
|
329
|
+
histogram(
|
|
330
|
+
'bones.depth_histogram',
|
|
331
|
+
'NAME-AGNOSTIC: as many bones at each depth',
|
|
332
|
+
counted([...a.depths.values()].map(String)),
|
|
333
|
+
counted([...b.depths.values()].map(String)),
|
|
334
|
+
),
|
|
335
|
+
histogram(
|
|
336
|
+
'bones.degree_sequence',
|
|
337
|
+
'NAME-AGNOSTIC: as many bones with each child count',
|
|
338
|
+
counted([...a.children.values()].map(String)),
|
|
339
|
+
counted([...b.children.values()].map(String)),
|
|
340
|
+
),
|
|
341
|
+
],
|
|
342
|
+
// The tree compared as a tree — no name is consulted anywhere below.
|
|
343
|
+
[
|
|
344
|
+
measure('bones.agnostic.count', 'how many bones', Math.min(a.order.length, b.order.length), max),
|
|
345
|
+
histogram(
|
|
346
|
+
'bones.agnostic.depth_histogram',
|
|
347
|
+
'as many bones at each depth',
|
|
348
|
+
counted([...a.depths.values()].map(String)),
|
|
349
|
+
counted([...b.depths.values()].map(String)),
|
|
350
|
+
),
|
|
351
|
+
histogram(
|
|
352
|
+
'bones.agnostic.degree_sequence',
|
|
353
|
+
'as many bones with each child count',
|
|
354
|
+
counted([...a.children.values()].map(String)),
|
|
355
|
+
counted([...b.children.values()].map(String)),
|
|
356
|
+
),
|
|
357
|
+
// Strictly stronger than the two above it, and it earns its place for
|
|
358
|
+
// that reason: two trees can hold the same depths and the same child
|
|
359
|
+
// counts while pairing them up differently — a deep leaf and a shallow
|
|
360
|
+
// fork against a shallow leaf and a deep fork. This asks for the pair.
|
|
361
|
+
histogram(
|
|
362
|
+
'bones.agnostic.shape_histogram',
|
|
363
|
+
'as many bones of each depth-and-child-count shape (`d1c3` = one hop down, three children)',
|
|
364
|
+
counted(aShapes),
|
|
365
|
+
counted(bShapes),
|
|
366
|
+
),
|
|
367
|
+
orderShape(
|
|
368
|
+
'bones.agnostic.order_shape',
|
|
369
|
+
'the bones are declared in the same order of shapes',
|
|
370
|
+
aShapes,
|
|
371
|
+
bShapes,
|
|
372
|
+
),
|
|
373
|
+
],
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
interface SlotFacts {
|
|
378
|
+
order: string[];
|
|
379
|
+
bone: Map<string, string | null>;
|
|
380
|
+
attachment: Map<string, string | null>;
|
|
381
|
+
blend: Map<string, string>;
|
|
382
|
+
hasColor: Map<string, boolean>;
|
|
383
|
+
/**
|
|
384
|
+
* `<slot>` -> the TYPE of what it shows in setup: `region`, `mesh`, and so
|
|
385
|
+
* on; `none` when the slot shows nothing; `absent` when it names an
|
|
386
|
+
* attachment no skin carries. The type is name-free — it is *what kind of
|
|
387
|
+
* thing is drawn here*, which survives every rename — while the attachment's
|
|
388
|
+
* own name is compared by `slots.attachment` above.
|
|
389
|
+
*/
|
|
390
|
+
setupType: Map<string, string>;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** `<slot>/<attachment>` -> type, over every skin. */
|
|
394
|
+
function attachmentTypes(root: Json): Map<string, string> {
|
|
395
|
+
const out = new Map<string, string>();
|
|
396
|
+
for (const skin of objs(root.skins)) {
|
|
397
|
+
if (!isObj(skin.attachments)) continue;
|
|
398
|
+
for (const [slotName, slotMap] of Object.entries(skin.attachments)) {
|
|
399
|
+
if (!isObj(slotMap)) continue;
|
|
400
|
+
for (const [attName, att] of Object.entries(slotMap)) {
|
|
401
|
+
if (!isObj(att)) continue;
|
|
402
|
+
// First skin wins. Every skeleton on the ladder has exactly one skin
|
|
403
|
+
// (`default`), and a per-skin type comparison would need a skin
|
|
404
|
+
// correspondence, which is a naming question by another route.
|
|
405
|
+
if (!out.has(`${slotName}/${attName}`)) out.set(`${slotName}/${attName}`, str(att.type) ?? 'region');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function slotFacts(root: Json): SlotFacts {
|
|
413
|
+
const order: string[] = [];
|
|
414
|
+
const bone = new Map<string, string | null>();
|
|
415
|
+
const attachment = new Map<string, string | null>();
|
|
416
|
+
const blend = new Map<string, string>();
|
|
417
|
+
const hasColor = new Map<string, boolean>();
|
|
418
|
+
const setupType = new Map<string, string>();
|
|
419
|
+
const types = attachmentTypes(root);
|
|
420
|
+
for (const s of objs(root.slots)) {
|
|
421
|
+
const name = str(s.name);
|
|
422
|
+
if (name === null) continue;
|
|
423
|
+
const setup = str(s.attachment);
|
|
424
|
+
order.push(name);
|
|
425
|
+
bone.set(name, str(s.bone));
|
|
426
|
+
attachment.set(name, setup);
|
|
427
|
+
blend.set(name, str(s.blend) ?? 'normal');
|
|
428
|
+
hasColor.set(name, 'color' in s);
|
|
429
|
+
setupType.set(name, setup === null ? 'none' : (types.get(`${name}/${setup}`) ?? 'absent'));
|
|
430
|
+
}
|
|
431
|
+
return { order, bone, attachment, blend, hasColor, setupType };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** What a slot is, written without a name: what it draws, on what shape of bone. */
|
|
435
|
+
function slotShapes(facts: SlotFacts, bones: BoneFacts): string[] {
|
|
436
|
+
return facts.order.map((n) => `${facts.setupType.get(n) ?? 'none'}@${boneShape(bones, facts.bone.get(n) ?? '')}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function diffSlots(c: Json, r: Json): DiffSection {
|
|
440
|
+
const a = slotFacts(c);
|
|
441
|
+
const b = slotFacts(r);
|
|
442
|
+
const ab = boneFacts(c);
|
|
443
|
+
const bb = boneFacts(r);
|
|
444
|
+
const max = Math.max(a.order.length, b.order.length);
|
|
445
|
+
const aShapes = slotShapes(a, ab);
|
|
446
|
+
const bShapes = slotShapes(b, bb);
|
|
447
|
+
const byPosition = (f: SlotFacts): Map<string, number> =>
|
|
448
|
+
counted(f.order.map((n, i) => `${i}:${f.setupType.get(n) ?? 'none'}`));
|
|
449
|
+
const boundTo = (f: SlotFacts, bones: BoneFacts): Map<string, number> =>
|
|
450
|
+
counted(f.order.map((n) => boneShape(bones, f.bone.get(n) ?? '')));
|
|
451
|
+
return sectionOf(
|
|
452
|
+
'slots',
|
|
453
|
+
[
|
|
454
|
+
measure('slots.count', 'how many slots', Math.min(a.order.length, b.order.length), max),
|
|
455
|
+
jaccard('slots.names', 'the slot names themselves', new Set(a.order), new Set(b.order)),
|
|
456
|
+
measure(
|
|
457
|
+
'slots.order',
|
|
458
|
+
'the slots array IS the draw order, so its order is data',
|
|
459
|
+
lcs(
|
|
460
|
+
a.order.filter((n) => b.bone.has(n)),
|
|
461
|
+
b.order.filter((n) => a.bone.has(n)),
|
|
462
|
+
),
|
|
463
|
+
max,
|
|
464
|
+
),
|
|
465
|
+
agreement('slots.bone', 'each slot is bound to the same bone', a.bone, b.bone, (x, y) => x === y),
|
|
466
|
+
agreement('slots.attachment', 'each slot shows the same setup attachment', a.attachment, b.attachment, (x, y) => x === y),
|
|
467
|
+
agreement('slots.blend', 'each slot uses the same blend mode', a.blend, b.blend, (x, y) => x === y),
|
|
468
|
+
agreement('slots.color_present', 'a tint is present or absent alike', a.hasColor, b.hasColor, (x, y) => x === y),
|
|
469
|
+
],
|
|
470
|
+
[
|
|
471
|
+
measure('slots.agnostic.count', 'how many slots', Math.min(a.order.length, b.order.length), max),
|
|
472
|
+
// Positional on purpose, and paired with the LCS measure below for the
|
|
473
|
+
// same reason `slots.order` is paired with `slots.names`: this one says
|
|
474
|
+
// "position 3 draws a mesh on both sides", the LCS one degrades smoothly
|
|
475
|
+
// when a slot is inserted rather than counting every later slot wrong.
|
|
476
|
+
histogram(
|
|
477
|
+
'slots.agnostic.attachment_types_by_position',
|
|
478
|
+
'the same kind of attachment sits at each position in the draw order',
|
|
479
|
+
byPosition(a),
|
|
480
|
+
byPosition(b),
|
|
481
|
+
),
|
|
482
|
+
histogram(
|
|
483
|
+
'slots.agnostic.bone_binding_shape',
|
|
484
|
+
'as many slots hang off a bone of each shape (`?` = no such bone is declared)',
|
|
485
|
+
boundTo(a, ab),
|
|
486
|
+
boundTo(b, bb),
|
|
487
|
+
),
|
|
488
|
+
orderShape(
|
|
489
|
+
'slots.agnostic.order_shape',
|
|
490
|
+
'the draw order is the same order of `<attachment type>@<bone shape>`',
|
|
491
|
+
aShapes,
|
|
492
|
+
bShapes,
|
|
493
|
+
),
|
|
494
|
+
],
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
interface AttachmentFact {
|
|
499
|
+
type: string;
|
|
500
|
+
/** uv pairs, i.e. the real vertex count of a mesh. */
|
|
501
|
+
vertices: number | null;
|
|
502
|
+
triangles: number | null;
|
|
503
|
+
weighted: boolean | null;
|
|
504
|
+
hull: number | null;
|
|
505
|
+
/** `<width>x<height>` as stated, or `unstated` — see `attachments.region_size`. */
|
|
506
|
+
size: string;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, AttachmentFact> } {
|
|
510
|
+
const skins = new Set<string>();
|
|
511
|
+
const byKey = new Map<string, AttachmentFact>();
|
|
512
|
+
for (const skin of objs(root.skins)) {
|
|
513
|
+
const skinName = str(skin.name) ?? 'default';
|
|
514
|
+
skins.add(skinName);
|
|
515
|
+
if (!isObj(skin.attachments)) continue;
|
|
516
|
+
for (const [slotName, slotMap] of Object.entries(skin.attachments)) {
|
|
517
|
+
if (!isObj(slotMap)) continue;
|
|
518
|
+
for (const [attName, att] of Object.entries(slotMap)) {
|
|
519
|
+
if (!isObj(att)) continue;
|
|
520
|
+
const type = str(att.type) ?? 'region';
|
|
521
|
+
const uvs = arr(att.uvs);
|
|
522
|
+
const verticesRun = arr(att.vertices);
|
|
523
|
+
const vertexCount = uvs.length > 0 ? uvs.length / 2 : num(att.vertexCount);
|
|
524
|
+
// Weighted versus unweighted carries no marker: the run is longer than
|
|
525
|
+
// 2 numbers per vertex exactly when it holds bone/weight triples.
|
|
526
|
+
const weighted =
|
|
527
|
+
vertexCount === null || verticesRun.length === 0 ? null : verticesRun.length !== vertexCount * 2;
|
|
528
|
+
byKey.set(`${skinName}/${slotName}/${attName}`, {
|
|
529
|
+
type,
|
|
530
|
+
vertices: type === 'mesh' ? vertexCount : null,
|
|
531
|
+
triangles: type === 'mesh' ? arr(att.triangles).length / 3 : null,
|
|
532
|
+
weighted: type === 'mesh' ? weighted : null,
|
|
533
|
+
hull: type === 'mesh' ? num(att.hull) : null,
|
|
534
|
+
size: num(att.width) !== null && num(att.height) !== null ? `${num(att.width)}x${num(att.height)}` : 'unstated',
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return { skins, byKey };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function diffAttachments(c: Json, r: Json): DiffSection {
|
|
543
|
+
const a = attachmentFacts(c);
|
|
544
|
+
const b = attachmentFacts(r);
|
|
545
|
+
const meshes = (m: Map<string, AttachmentFact>) => new Map([...m].filter(([, f]) => f.type === 'mesh'));
|
|
546
|
+
const regions = (m: Map<string, AttachmentFact>) => new Map([...m].filter(([, f]) => f.type === 'region'));
|
|
547
|
+
const am = meshes(a.byKey);
|
|
548
|
+
const bm = meshes(b.byKey);
|
|
549
|
+
const ar = regions(a.byKey);
|
|
550
|
+
const br = regions(b.byKey);
|
|
551
|
+
return sectionOf('attachments', [
|
|
552
|
+
jaccard('attachments.skins', 'the skin names', a.skins, b.skins),
|
|
553
|
+
measure(
|
|
554
|
+
'attachments.count',
|
|
555
|
+
'how many attachments',
|
|
556
|
+
Math.min(a.byKey.size, b.byKey.size),
|
|
557
|
+
Math.max(a.byKey.size, b.byKey.size),
|
|
558
|
+
),
|
|
559
|
+
jaccard('attachments.names', 'skin/slot/attachment keys', new Set(a.byKey.keys()), new Set(b.byKey.keys())),
|
|
560
|
+
histogram(
|
|
561
|
+
'attachments.type_counts',
|
|
562
|
+
'as many of each attachment type',
|
|
563
|
+
counted([...a.byKey.values()].map((f) => f.type)),
|
|
564
|
+
counted([...b.byKey.values()].map((f) => f.type)),
|
|
565
|
+
),
|
|
566
|
+
agreement('attachments.mesh_vertices', 'each mesh has the same vertex count', am, bm, (x, y) => x.vertices === y.vertices),
|
|
567
|
+
agreement('attachments.mesh_triangles', 'each mesh has the same triangle count', am, bm, (x, y) => x.triangles === y.triangles),
|
|
568
|
+
agreement('attachments.mesh_weighted', 'each mesh is weighted, or is not, alike', am, bm, (x, y) => x.weighted === y.weighted),
|
|
569
|
+
agreement('attachments.mesh_hull', 'each mesh declares the same hull length', am, bm, (x, y) => x.hull === y.hull),
|
|
570
|
+
// Replaces `attachments.region_size_present` (issue #28), which asked
|
|
571
|
+
// whether each region STATED a size and read near zero on every honest run.
|
|
572
|
+
//
|
|
573
|
+
// The issue's diagnosis was that Spine's exporter omits `width`/`height`
|
|
574
|
+
// when they match the atlas region, so a rigc rig — which always states
|
|
575
|
+
// them (AUTHORING R1/R5) — could never agree. That is not what the corpus
|
|
576
|
+
// says: all twelve reference exports state a size on every one of their 168
|
|
577
|
+
// regions, so there is nothing for an atlas lookup to resolve and the
|
|
578
|
+
// `--atlas` plumbing the issue proposed would be dead code against every
|
|
579
|
+
// rung on the ladder.
|
|
580
|
+
//
|
|
581
|
+
// What the measure actually reported was the naming gap, a third time. It
|
|
582
|
+
// was keyed by `skin/slot/attachment`, so it could never exceed the name
|
|
583
|
+
// overlap: rung 1 read `0/8` where `attachments.names` read `0/16`, and
|
|
584
|
+
// `3/5` where three names matched. That is #21's defect in this section.
|
|
585
|
+
//
|
|
586
|
+
// So it is name-agnostic and numeric instead: do the two rigs agree about
|
|
587
|
+
// how big their regions are? A rig that states a size a differently-named
|
|
588
|
+
// rig also states now agrees, which is what a structural diff is for.
|
|
589
|
+
histogram(
|
|
590
|
+
'attachments.region_size',
|
|
591
|
+
'NAME-AGNOSTIC: as many regions of each stated size (`unstated` is its own size)',
|
|
592
|
+
counted([...ar.values()].map((f) => f.size)),
|
|
593
|
+
counted([...br.values()].map((f) => f.size)),
|
|
594
|
+
),
|
|
595
|
+
]);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
interface ConstraintFact {
|
|
599
|
+
type: string;
|
|
600
|
+
/** Every bone or slot the constraint names, sorted — its wiring. */
|
|
601
|
+
refs: string;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function constraintFacts(root: Json): Map<string, ConstraintFact> {
|
|
605
|
+
const out = new Map<string, ConstraintFact>();
|
|
606
|
+
for (const con of objs(root.constraints)) {
|
|
607
|
+
const name = str(con.name);
|
|
608
|
+
if (name === null) continue;
|
|
609
|
+
const refs = new Set<string>();
|
|
610
|
+
for (const b of arr(con.bones)) {
|
|
611
|
+
const s = str(b);
|
|
612
|
+
if (s !== null) refs.add(`bone:${s}`);
|
|
613
|
+
}
|
|
614
|
+
for (const key of ['bone', 'target', 'source', 'slot'] as const) {
|
|
615
|
+
const s = str(con[key]);
|
|
616
|
+
if (s !== null) refs.add(`${key}:${s}`);
|
|
617
|
+
}
|
|
618
|
+
out.set(name, { type: str(con.type) ?? '(none)', refs: [...refs].sort().join(' ') });
|
|
619
|
+
}
|
|
620
|
+
return out;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function diffConstraints(c: Json, r: Json): DiffSection {
|
|
624
|
+
const a = constraintFacts(c);
|
|
625
|
+
const b = constraintFacts(r);
|
|
626
|
+
return sectionOf('constraints', [
|
|
627
|
+
measure('constraints.count', 'how many constraints', Math.min(a.size, b.size), Math.max(a.size, b.size)),
|
|
628
|
+
jaccard('constraints.names', 'the constraint names', new Set(a.keys()), new Set(b.keys())),
|
|
629
|
+
histogram(
|
|
630
|
+
'constraints.type_counts',
|
|
631
|
+
'as many of each constraint type',
|
|
632
|
+
counted([...a.values()].map((f) => f.type)),
|
|
633
|
+
counted([...b.values()].map((f) => f.type)),
|
|
634
|
+
),
|
|
635
|
+
agreement('constraints.type_by_name', 'each constraint is the same type', a, b, (x, y) => x.type === y.type),
|
|
636
|
+
agreement('constraints.refs', 'each constraint names the same bones and slots', a, b, (x, y) => x.refs === y.refs),
|
|
637
|
+
]);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
interface AnimationFacts {
|
|
641
|
+
names: string[];
|
|
642
|
+
/** `<anim>` -> last key time. Skeleton JSON has no duration field. */
|
|
643
|
+
duration: Map<string, number>;
|
|
644
|
+
/** `<anim>|<path>` -> 1, one entry per timeline that exists. */
|
|
645
|
+
kinds: Map<string, number>;
|
|
646
|
+
/** `<anim>|<kind>` -> number of keys. */
|
|
647
|
+
keys: Map<string, number>;
|
|
648
|
+
/** `<anim>|linear|stepped|bezier` -> number of keys with that curve. */
|
|
649
|
+
curves: Map<string, number>;
|
|
650
|
+
events: Map<string, number>;
|
|
651
|
+
hasDrawOrder: Map<string, boolean>;
|
|
652
|
+
hasDeform: Map<string, boolean>;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function animationFacts(root: Json): AnimationFacts {
|
|
656
|
+
const facts: AnimationFacts = {
|
|
657
|
+
names: [],
|
|
658
|
+
duration: new Map(),
|
|
659
|
+
kinds: new Map(),
|
|
660
|
+
keys: new Map(),
|
|
661
|
+
curves: new Map(),
|
|
662
|
+
events: new Map(),
|
|
663
|
+
hasDrawOrder: new Map(),
|
|
664
|
+
hasDeform: new Map(),
|
|
665
|
+
};
|
|
666
|
+
if (!isObj(root.animations)) return facts;
|
|
667
|
+
for (const name of Object.keys(root.animations)) {
|
|
668
|
+
facts.names.push(name);
|
|
669
|
+
facts.duration.set(name, 0);
|
|
670
|
+
facts.events.set(name, 0);
|
|
671
|
+
facts.hasDrawOrder.set(name, false);
|
|
672
|
+
facts.hasDeform.set(name, false);
|
|
673
|
+
}
|
|
674
|
+
const bump = (m: Map<string, number>, k: string, by = 1) => m.set(k, (m.get(k) ?? 0) + by);
|
|
675
|
+
walkTimelines(root, (path, kind, name, keys) => {
|
|
676
|
+
const anim = path.slice(0, path.indexOf('.'));
|
|
677
|
+
// The path carries the target's name (bone `face`, constraint `probe_ik`),
|
|
678
|
+
// which is compared under bones/constraints already. Here only the SHAPE
|
|
679
|
+
// matters, so the target is dropped and the kind kept.
|
|
680
|
+
bump(facts.kinds, `${anim}|${kind}.${name}`);
|
|
681
|
+
bump(facts.keys, `${anim}|${kind}.${name}`, keys.length);
|
|
682
|
+
if (kind === 'drawOrder' || kind === 'drawOrderFolder') facts.hasDrawOrder.set(anim, true);
|
|
683
|
+
if (kind === 'attachment' && name === 'deform') facts.hasDeform.set(anim, true);
|
|
684
|
+
if (kind === 'event') facts.events.set(anim, (facts.events.get(anim) ?? 0) + keys.length);
|
|
685
|
+
for (const key of keys) {
|
|
686
|
+
if (!isObj(key)) continue;
|
|
687
|
+
const time = num(key.time) ?? 0;
|
|
688
|
+
if (time > (facts.duration.get(anim) ?? 0)) facts.duration.set(anim, time);
|
|
689
|
+
const curve = key.curve;
|
|
690
|
+
const shape = curve === undefined ? 'linear' : curve === 'stepped' ? 'stepped' : Array.isArray(curve) ? 'bezier' : 'other';
|
|
691
|
+
bump(facts.curves, `${anim}|${shape}`);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
return facts;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function diffAnimations(c: Json, r: Json): DiffSection {
|
|
698
|
+
const a = animationFacts(c);
|
|
699
|
+
const b = animationFacts(r);
|
|
700
|
+
return sectionOf('animations', [
|
|
701
|
+
measure('animations.count', 'how many animations', Math.min(a.names.length, b.names.length), Math.max(a.names.length, b.names.length)),
|
|
702
|
+
jaccard('animations.names', 'the animation names', new Set(a.names), new Set(b.names)),
|
|
703
|
+
agreement(
|
|
704
|
+
'animations.duration',
|
|
705
|
+
'each animation runs as long (last key time, within one frame)',
|
|
706
|
+
a.duration,
|
|
707
|
+
b.duration,
|
|
708
|
+
(x, y) => Math.abs(x - y) <= FRAME,
|
|
709
|
+
),
|
|
710
|
+
histogram('animations.timeline_kinds', 'the same timelines exist', a.kinds, b.kinds),
|
|
711
|
+
histogram('animations.key_counts', 'those timelines carry as many keys', a.keys, b.keys),
|
|
712
|
+
histogram('animations.curve_kinds', 'as many linear / stepped / bezier keys', a.curves, b.curves),
|
|
713
|
+
histogram('animations.event_keys', 'as many event firings', a.events, b.events),
|
|
714
|
+
agreement('animations.draw_order', 'a draw-order timeline is present or absent alike', a.hasDrawOrder, b.hasDrawOrder, (x, y) => x === y),
|
|
715
|
+
agreement('animations.deform', 'a deform timeline is present or absent alike', a.hasDeform, b.hasDeform, (x, y) => x === y),
|
|
716
|
+
]);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function eventFacts(root: Json): Map<string, string> {
|
|
720
|
+
const out = new Map<string, string>();
|
|
721
|
+
if (!isObj(root.events)) return out;
|
|
722
|
+
for (const [name, def] of Object.entries(root.events)) {
|
|
723
|
+
const d = isObj(def) ? def : {};
|
|
724
|
+
// The typed payload, in the parser's own defaults (`:469-484`), so that a
|
|
725
|
+
// field written explicitly at its default reads the same as one omitted.
|
|
726
|
+
out.set(
|
|
727
|
+
name,
|
|
728
|
+
JSON.stringify({
|
|
729
|
+
int: num(d.int) ?? 0,
|
|
730
|
+
float: num(d.float) ?? 0,
|
|
731
|
+
string: str(d.string) ?? '',
|
|
732
|
+
audio: str(d.audio) ?? '',
|
|
733
|
+
volume: num(d.volume) ?? 1,
|
|
734
|
+
balance: num(d.balance) ?? 0,
|
|
735
|
+
}),
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return out;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function diffEvents(c: Json, r: Json): DiffSection {
|
|
742
|
+
const a = eventFacts(c);
|
|
743
|
+
const b = eventFacts(r);
|
|
744
|
+
return sectionOf('events', [
|
|
745
|
+
jaccard('events.names', 'the event names', new Set(a.keys()), new Set(b.keys())),
|
|
746
|
+
agreement('events.payloads', 'each event carries the same typed payload', a, b, (x, y) => x === y),
|
|
747
|
+
]);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
// the report
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
|
|
754
|
+
function orientation(root: Json): Record<string, number> {
|
|
755
|
+
const attachments = attachmentFacts(root);
|
|
756
|
+
return {
|
|
757
|
+
bones: objs(root.bones).length,
|
|
758
|
+
slots: objs(root.slots).length,
|
|
759
|
+
skins: objs(root.skins).length,
|
|
760
|
+
attachments: attachments.byKey.size,
|
|
761
|
+
constraints: objs(root.constraints).length,
|
|
762
|
+
animations: isObj(root.animations) ? Object.keys(root.animations).length : 0,
|
|
763
|
+
events: isObj(root.events) ? Object.keys(root.events).length : 0,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export function diffSkeletons(candidate: unknown, reference: unknown): DiffReport {
|
|
768
|
+
const c = isObj(candidate) ? candidate : {};
|
|
769
|
+
const r = isObj(reference) ? reference : {};
|
|
770
|
+
return {
|
|
771
|
+
sections: [diffBones(c, r), diffSlots(c, r), diffAttachments(c, r), diffConstraints(c, r), diffAnimations(c, r), diffEvents(c, r)],
|
|
772
|
+
candidate: orientation(c),
|
|
773
|
+
reference: orientation(r),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Every NAME-MATCHED measure that is not a perfect match, by id. What a test
|
|
779
|
+
* asserts on.
|
|
780
|
+
*
|
|
781
|
+
* The name-agnostic reports are deliberately not folded in here. They are a
|
|
782
|
+
* separate comparison with its own measure set, and a single flattened list
|
|
783
|
+
* would make one edit's footprint depend on how many measures the other report
|
|
784
|
+
* happens to define — which is the opposite of what these assertions are for.
|
|
785
|
+
* `movedAgnosticMeasures` is the other half, and a case pins both.
|
|
786
|
+
*/
|
|
787
|
+
export function movedMeasures(report: DiffReport): string[] {
|
|
788
|
+
return report.sections.flatMap((s) => s.measures.filter((m) => m.ratio < 1).map((m) => m.id));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** The same, over the name-agnostic reports. Empty when every shape agrees. */
|
|
792
|
+
export function movedAgnosticMeasures(report: DiffReport): string[] {
|
|
793
|
+
return report.sections.flatMap((s) => (s.nameAgnostic?.measures ?? []).filter((m) => m.ratio < 1).map((m) => m.id));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const fmt = (n: number): string => n.toFixed(3);
|
|
797
|
+
|
|
798
|
+
/** `bones 0.567 (name-matched) · 1.000 (name-agnostic)`, or just the one figure. */
|
|
799
|
+
export function sectionFigures(section: DiffSection): string {
|
|
800
|
+
const matched = `${fmt(section.ratio)} (name-matched)`;
|
|
801
|
+
return section.nameAgnostic === undefined
|
|
802
|
+
? `${section.name} ${matched}`
|
|
803
|
+
: `${section.name} ${matched} · ${fmt(section.nameAgnostic.ratio)} (name-agnostic)`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function measureLines(measures: DiffMeasure[], strip: number): string[] {
|
|
807
|
+
return measures.map((m) => {
|
|
808
|
+
const counts = `${m.matched}/${m.total}`;
|
|
809
|
+
return ` ${fmt(m.ratio)} ${m.id.slice(strip).padEnd(28)} ${counts.padEnd(11)} ${m.what}${m.note ? ` — ${m.note}` : ''}`;
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export function diffLines(report: DiffReport, labels: { candidate: string; reference: string }): string[] {
|
|
814
|
+
const lines: string[] = [];
|
|
815
|
+
lines.push(` candidate ${labels.candidate}`);
|
|
816
|
+
lines.push(` reference ${labels.reference}`);
|
|
817
|
+
const keys = Object.keys(report.reference);
|
|
818
|
+
lines.push(` .. ${keys.map((k) => `${k}=${report.candidate[k]}/${report.reference[k]}`).join(' ')} (candidate/reference)`);
|
|
819
|
+
lines.push('');
|
|
820
|
+
// Wide enough for `<longest section> (name-agnostic)`, so that a section's two
|
|
821
|
+
// headings line their figures up under each other and read as a pair.
|
|
822
|
+
const head = (label: string, ratio: number, n: number): string =>
|
|
823
|
+
` ${label.padEnd(21)} mean ${fmt(ratio)} over ${n} measures`;
|
|
824
|
+
for (const section of report.sections) {
|
|
825
|
+
lines.push(head(section.name, section.ratio, section.measures.length));
|
|
826
|
+
lines.push(...measureLines(section.measures, section.name.length + 1));
|
|
827
|
+
const agnostic = section.nameAgnostic;
|
|
828
|
+
if (agnostic) {
|
|
829
|
+
lines.push('');
|
|
830
|
+
lines.push(
|
|
831
|
+
`${head(`${section.name} (name-agnostic)`, agnostic.ratio, agnostic.measures.length)}` +
|
|
832
|
+
' — the same two skeletons compared with names thrown away',
|
|
833
|
+
);
|
|
834
|
+
lines.push(...measureLines(agnostic.measures, section.name.length + '.agnostic.'.length));
|
|
835
|
+
}
|
|
836
|
+
lines.push('');
|
|
837
|
+
}
|
|
838
|
+
lines.push(' There is no overall score, on purpose: a section mean is an average of the');
|
|
839
|
+
lines.push(' measures printed under it, and averaging those together would hide which');
|
|
840
|
+
lines.push(' half of a rig is wrong. Read the measures.');
|
|
841
|
+
lines.push('');
|
|
842
|
+
lines.push(' `bones` and `slots` carry two figures because most of their measures are');
|
|
843
|
+
lines.push(' keyed on names, and a candidate is entitled to its own. They are two');
|
|
844
|
+
lines.push(' comparisons, not two halves of one: name-agnostic 1.000 beside a low');
|
|
845
|
+
lines.push(' name-matched figure means the shape is right and the vocabulary differs.');
|
|
846
|
+
return lines;
|
|
847
|
+
}
|