spine-rigc 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/shared.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * anything it imports: the bodies that reach the runtime are
15
15
  * `./spine_commands.ts`'s, which only `cli.ts` imports.
16
16
  */
17
- import { type AtlasRegion, DEFAULT_PADDING, DEFAULT_PAGE_EDGES, DEFAULT_PAGE_SIZE, packAtlas, PAGE_EDGES, type PageEdges, pageFootprint } from '../atlas.ts';
17
+ import { type AtlasRegion, DEFAULT_PACK_SHAPE, DEFAULT_PADDING, DEFAULT_PAGE_EDGES, DEFAULT_PAGE_SIZE, PACK_SHAPES, packAtlas, type PackInput, packFootprints, type PackShape, PAGE_EDGES, type PageEdges, pageFootprint } from '../atlas.ts';
18
18
  import { copyAtlasPages, plannedPageCopies } from '../emit.ts';
19
19
  import { CLI_DEFAULT_PROFILE, VALIDATE_PROFILES } from '../assertions/report.ts';
20
20
  import type { AssertionProfile } from '../assertions/kinds.ts';
@@ -985,6 +985,10 @@ const FLAG_MEANINGS: Record<string, string> = {
985
985
  'both; free tries every width from the widest part up, takes the height the placement needs and keeps ' +
986
986
  'the least area — a smaller page, at the cost of region attachments sampling within 1 LSB of the loose ' +
987
987
  'build rather than exactly',
988
+ 'pack-shape': `what two packed rectangles may share, --pack only (default ${DEFAULT_PACK_SHAPE}): rect keeps every ` +
989
+ 'region\'s cell apart; polygon packs a region that only meshes draw by its emitted hull, so a neighbour may ' +
990
+ 'sit inside its rectangle where the hull is not, with the padding kept between footprints (so a mesh\'s ' +
991
+ 'rectangle is its own bytes only where it can be sampled) — a region attachment stays its rectangle',
988
992
  'atlas-in':
989
993
  'resolve every part against the regions of this pre-packed .atlas instead of against loose PNGs — region ' +
990
994
  'geometry (bounds/offsets/rotate) is read from the file and the atlas is re-emitted into --out, re-anchored',
@@ -1115,6 +1119,7 @@ const FLAG_VALUES: Record<string, string> = {
1115
1119
  'page-size': '<px>',
1116
1120
  padding: '<px>',
1117
1121
  'page-edges': 'pot|free',
1122
+ 'pack-shape': 'rect|polygon',
1118
1123
  'texture-from': '<path>',
1119
1124
  poser: 'core|spine',
1120
1125
  reference: '<dir|skeleton.json>',
@@ -1239,7 +1244,7 @@ export const COMMANDS: CommandDoc[] = [
1239
1244
  core: {
1240
1245
  usage: [
1241
1246
  'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images] (the same files the round-tripped build writes; gated without spine-core — see build --help)',
1242
- `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] (parts onto shared pages, written into --out)`,
1247
+ `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] (parts onto shared pages, written into --out)`,
1243
1248
  'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
1244
1249
  'rigc build --cut <name> --cuts <cuts.json>',
1245
1250
  ],
@@ -1263,7 +1268,7 @@ export const COMMANDS: CommandDoc[] = [
1263
1268
  spineFormat: true,
1264
1269
  usage: [
1265
1270
  'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
1266
- `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] (parts onto shared pages, written into --out)`,
1271
+ `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] [--pack-shape rect|polygon] (parts onto shared pages, written into --out)`,
1267
1272
  'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
1268
1273
  'rigc build --cut <name> --cuts <cuts.json>',
1269
1274
  ],
@@ -1278,6 +1283,7 @@ export const COMMANDS: CommandDoc[] = [
1278
1283
  'page-size',
1279
1284
  'padding',
1280
1285
  'page-edges',
1286
+ 'pack-shape',
1281
1287
  'atlas-in',
1282
1288
  'cut',
1283
1289
  'cuts',
@@ -1298,7 +1304,7 @@ export const COMMANDS: CommandDoc[] = [
1298
1304
  'this line said "the same arguments as build, minus --profile" and was false in both',
1299
1305
  'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
1300
1306
  'command read what `ingest --art none` writes was reachable and undocumented, while',
1301
- '--pack, --page-size, --padding, --page-edges and --copy-images are build\'s and do nothing here —',
1307
+ '--pack, --page-size, --padding, --page-edges, --pack-shape and --copy-images are build\'s and do nothing here —',
1302
1308
  'they decide what is WRITTEN, and this command writes nothing. What it takes is listed',
1303
1309
  'above, and that is now the whole of it.',
1304
1310
  ],
@@ -1813,6 +1819,42 @@ export function readPageEdges(flags: Record<string, string>): PageEdges {
1813
1819
  return found;
1814
1820
  }
1815
1821
 
1822
+ /**
1823
+ * Read `--pack-shape`, or its default — `rect` (issue #1099).
1824
+ *
1825
+ * Refused on any other value for `readPageEdges`'s reason: a typo that fell
1826
+ * back to `rect` would hand the caller who asked for the denser page the
1827
+ * looser one, green, and say nothing.
1828
+ */
1829
+ export function readPackShape(flags: Record<string, string>): PackShape {
1830
+ const raw = flags['pack-shape'];
1831
+ if (raw === undefined) return DEFAULT_PACK_SHAPE;
1832
+ const found = PACK_SHAPES.find((e) => e === raw);
1833
+ if (!found) throw new UsageError(`--pack-shape ${JSON.stringify(raw)}; known values: ${PACK_SHAPES.join(', ')}`);
1834
+ return found;
1835
+ }
1836
+
1837
+ /**
1838
+ * The parts of a compile as `packAtlas` takes them — under `polygon` each with
1839
+ * the footprint its attachments draw, read off the skeleton text the build
1840
+ * emitted (`packFootprints`); under `rect` without, which is the input every
1841
+ * pack had before #1099.
1842
+ */
1843
+ function packInputs(result: CompileResult, shape: PackShape): PackInput[] {
1844
+ const sizes = new Map(result.images.map((img) => [img.region, { width: img.width, height: img.height }]));
1845
+ const footprints = shape === 'polygon' ? packFootprints(result.skeletonText, (region) => sizes.get(region)) : null;
1846
+ return result.images.map((img) => {
1847
+ const footprint = footprints?.get(img.region) ?? undefined;
1848
+ return {
1849
+ region: img.region,
1850
+ absPath: img.absPath,
1851
+ width: img.width,
1852
+ height: img.height,
1853
+ ...(footprint === undefined ? {} : { footprint }),
1854
+ };
1855
+ });
1856
+ }
1857
+
1816
1858
  /**
1817
1859
  * The rectangle a region occupies **on its page**, for a line that has already
1818
1860
  * said where the region is — and the empty string where the page rectangle is
@@ -1890,7 +1932,7 @@ export function readBuildInvocation(flags: Record<string, string>): { label: str
1890
1932
  // now a build like any other — and it is the only one that puts the renderer's
1891
1933
  // own rulebook over shared-page sampling.
1892
1934
  if (!packing) {
1893
- for (const name of ['page-size', 'padding', 'page-edges'] as const) {
1935
+ for (const name of ['page-size', 'padding', 'page-edges', 'pack-shape'] as const) {
1894
1936
  if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
1895
1937
  }
1896
1938
  }
@@ -2103,15 +2145,10 @@ export function runBuild(flags: Record<string, string>, gate: BuildGate): void {
2103
2145
  pageSize: readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE),
2104
2146
  padding: readIntFlag(flags, 'padding', DEFAULT_PADDING),
2105
2147
  pageEdges: readPageEdges(flags),
2148
+ shape: readPackShape(flags),
2106
2149
  pageStem: 'skeleton',
2107
2150
  };
2108
- const inputs = result.images.map((img) => ({
2109
- region: img.region,
2110
- absPath: img.absPath,
2111
- width: img.width,
2112
- height: img.height,
2113
- }));
2114
- const packed = packAtlas(inputs, packOpts);
2151
+ const packed = packAtlas(packInputs(result, packOpts.shape), packOpts);
2115
2152
  atlasText = packed.atlasText;
2116
2153
  for (const page of packed.pages) {
2117
2154
  page.plate.writePng(join(opts.outDir, page.name));
@@ -2119,7 +2156,11 @@ export function runBuild(flags: Record<string, string>, gate: BuildGate): void {
2119
2156
  ` .. pack: ${page.name} ${page.width}x${page.height}, ` +
2120
2157
  `${packed.placements.filter((p) => packed.pages[p.page].name === page.name).length} region(s), ` +
2121
2158
  `${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}` +
2122
- (packOpts.pageEdges === 'free' ? ', page edges free' : ''),
2159
+ (packOpts.pageEdges === 'free' ? ', page edges free' : '') +
2160
+ // Appended, never inserted: a reader that takes the line whole keeps
2161
+ // every field it read before #1099, and the mode is named under the
2162
+ // default too, so no reader infers it from an absent word.
2163
+ `, shape ${packed.shape}`,
2123
2164
  );
2124
2165
  }
2125
2166
  for (const place of packed.placements) {
@@ -2134,15 +2175,7 @@ export function runBuild(flags: Record<string, string>, gate: BuildGate): void {
2134
2175
  // a region, and A18 compares a second independent compile+pack. Two gates on
2135
2176
  // one build is the cost of shipping a second atlas shape.
2136
2177
  console.log(' .. validate (packed atlas, pages on disk)');
2137
- const packAgain = packAtlas(
2138
- compile(opts).images.map((img) => ({
2139
- region: img.region,
2140
- absPath: img.absPath,
2141
- width: img.width,
2142
- height: img.height,
2143
- })),
2144
- packOpts,
2145
- );
2178
+ const packAgain = packAtlas(packInputs(compile(opts), packOpts.shape), packOpts);
2146
2179
  // The document written is spelled from the packed atlas, and this gate's A18 compares it with a
2147
2180
  // second compile's spelled from the second, independent pack (issue #1016).
2148
2181
  modelText = modelDocument(result.model, result.skeletonText, atlasText);
package/src/compile.ts CHANGED
@@ -7482,7 +7482,10 @@ function buildMesh(
7482
7482
  );
7483
7483
 
7484
7484
  return {
7485
- kind: geometry.kind,
7485
+ // `kind` is what chose the builder above, so it is the claim; `geometry.kind`
7486
+ // is the wider `MeshKind`, which a strict tsc refuses here (and a dependant
7487
+ // with `strict: true` reads this file through `src/render.ts`).
7488
+ kind,
7486
7489
  attachment: {
7487
7490
  kind: 'mesh',
7488
7491
  uvs: geometry.uvs.map(f32),
package/src/diff.ts CHANGED
@@ -689,6 +689,43 @@ interface AttachmentFact {
689
689
  * — see `attachments.runtime_name`.
690
690
  */
691
691
  runtimeName: string;
692
+ /**
693
+ * Every name the attachment resolves, as `<field>:<name>` and sorted — see
694
+ * `attachmentRefTokens` for which fields and why.
695
+ */
696
+ refs: string;
697
+ /** The same tokens as a list, for the note that spells a disagreement out. */
698
+ refList: string[];
699
+ }
700
+
701
+ /**
702
+ * The names an attachment resolves (issue #1085): the atlas region a region or
703
+ * a mesh draws, a clipping polygon's end slot, and the mesh a linked mesh takes
704
+ * its geometry from — with the skin and slot it is found under, where stated.
705
+ *
706
+ * ⭐ The region is read as the runtime resolves it, `path`, else the stated
707
+ * `name`, else the placeholder (SPEC_COVERAGE §1.5's three-level indirection),
708
+ * for the reason `runtime_name` reads its name that way: a file that spells the
709
+ * path out and one that leaves it to the parser name the same region, and a
710
+ * measure that called those different would score every export against every
711
+ * writer that states its paths. Every other field is a token only when the file
712
+ * states it — absent is no token, never a default, as in `constraints.refs`.
713
+ *
714
+ * Measured on public builds through spine-core: a region's `path` moved to the
715
+ * other eye of `spineboy-pro` changes 94 of its 99 sampled draws, its clipping
716
+ * polygon's `end` moved six slots on changes the clipping of 9, and a linked
717
+ * mesh on `gallery/nod` taking `ear_r` instead of `ear_l` as its source changes
718
+ * all 18 of its sampled poses. `diff` compared none of the three, and two of them occur in
719
+ * none of the twelve editor exports (a stated `path` and a linked mesh).
720
+ */
721
+ function attachmentRefTokens(type: string, key: string, att: Json): string[] {
722
+ const out: string[] = [];
723
+ if (type === 'region' || type === 'mesh' || type === 'linkedmesh') out.push(`path:${str(att.path) ?? str(att.name) ?? key}`);
724
+ for (const field of ['end', 'source', 'skin', 'slot'] as const) {
725
+ const s = str(att[field]);
726
+ if (s !== null) out.push(`${field}:${s}`);
727
+ }
728
+ return out.sort();
692
729
  }
693
730
 
694
731
  function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, AttachmentFact> } {
@@ -725,6 +762,8 @@ function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, A
725
762
  edgesPresent: type === 'mesh' ? 'edges' in att : null,
726
763
  size: num(att.width) !== null && num(att.height) !== null ? `${num(att.width)}x${num(att.height)}` : 'unstated',
727
764
  runtimeName: str(att.name) ?? attName,
765
+ refs: attachmentRefTokens(type, attName, att).join(' '),
766
+ refList: attachmentRefTokens(type, attName, att),
728
767
  });
729
768
  }
730
769
  }
@@ -795,6 +834,22 @@ function diffAttachments(c: Json, r: Json): DiffSection {
795
834
  counted([...ar.values()].map((f) => f.size)),
796
835
  counted([...br.values()].map((f) => f.size)),
797
836
  ),
837
+ wiringAgreement(
838
+ 'attachments.refs',
839
+ 'each attachment resolves the same region, clipping end and linked-mesh source',
840
+ a.byKey,
841
+ b.byKey,
842
+ (key) => `attachment ${JSON.stringify(key)}`,
843
+ '`attachments.names`',
844
+ ),
845
+ wiringAgreement(
846
+ 'attachments.skin_members',
847
+ 'each skin activates the same skin-required bones and constraints',
848
+ skinMembers(c),
849
+ skinMembers(r),
850
+ (skin) => `skin ${JSON.stringify(skin)}`,
851
+ '`attachments.skins`',
852
+ ),
798
853
  ],
799
854
  undefined,
800
855
  // ── reported (issue #46) ────────────────────────────────────────────────
@@ -853,6 +908,67 @@ function diffAttachments(c: Json, r: Json): DiffSection {
853
908
  ]);
854
909
  }
855
910
 
911
+ /**
912
+ * What each skin activates (issue #1085): the `bones` and the per-kind
913
+ * constraint lists a skin declares, as `<list>:<name>` tokens, sorted — a skin's
914
+ * MEMBERSHIP, which is what decides whether a bone or constraint marked
915
+ * `skin: true` is active under it.
916
+ *
917
+ * Measured on public builds through spine-core: a `gallery/look` bone made
918
+ * skin-required and left out of the skin's `bones` moves every one of its 27
919
+ * sampled poses, and `gallery/walk`'s `leg_b_ik` left out of the skin's `ik`
920
+ * moves all 9. None of the twelve editor exports declares a membership list, so
921
+ * every one of them reads `1/1` per skin here — two empty lists agree — which is
922
+ * the comparison being made rather than an absence of one.
923
+ */
924
+ function skinMembers(root: Json): Map<string, { refs: string; refList: string[] }> {
925
+ const out = new Map<string, { refs: string; refList: string[] }>();
926
+ for (const skin of objs(root.skins)) {
927
+ const name = str(skin.name) ?? 'default';
928
+ const refList: string[] = [];
929
+ for (const list of ['bones', 'ik', 'transform', 'path', 'physics', 'slider'] as const) {
930
+ for (const member of arr(skin[list])) {
931
+ const s = str(member);
932
+ if (s !== null) refList.push(`${list}:${s}`);
933
+ }
934
+ }
935
+ refList.sort();
936
+ out.set(name, { refs: refList.join(' '), refList });
937
+ }
938
+ return out;
939
+ }
940
+
941
+ /**
942
+ * `agreement` over the larger roster on a `refs` string, with the note
943
+ * `constraints.refs` writes: each entry both sides hold whose names differ,
944
+ * field by field and candidate first, then a count of the entries one side holds
945
+ * alone, which `elsewhere` is the measure that names.
946
+ */
947
+ function wiringAgreement<T extends { refs: string; refList: string[] }>(
948
+ id: string,
949
+ what: string,
950
+ a: Map<string, T>,
951
+ b: Map<string, T>,
952
+ label: (key: string) => string,
953
+ elsewhere: string,
954
+ ): DiffMeasure {
955
+ const base = agreement(id, what, a, b, (x, y) => x.refs === y.refs);
956
+ const missed = base.total - base.matched;
957
+ if (missed === 0) return base;
958
+ const differ: string[] = [];
959
+ for (const [key, fact] of a) {
960
+ const other = b.get(key);
961
+ if (other !== undefined && other.refs !== fact.refs) differ.push(refsDisagreement(label(key), fact, other));
962
+ }
963
+ const oneSided = missed - differ.length;
964
+ const spelled = differ.slice(0, REFS_SPELLED_OUT);
965
+ const note =
966
+ (differ.length === 0 ? '' : `${differ.length} differ: ${spelled.join('; ')}`) +
967
+ (differ.length > spelled.length ? `; …and ${differ.length - spelled.length} more` : '') +
968
+ (oneSided === 0 ? '' : `${differ.length === 0 ? '' : '; '}${oneSided} on one side only, which ${elsewhere} names`);
969
+ return { ...base, note };
970
+ }
971
+
856
972
  /**
857
973
  * `attachments.runtime_name`: of the skin/slot/placeholder keys both sides
858
974
  * hold, how many answer to the same runtime name — see the note at its call.
@@ -921,7 +1037,8 @@ interface ConstraintFact {
921
1037
  * property fields were blinder still: measured on `gallery/look` and `6-arcs-pro`,
922
1038
  * a slider moved from `rotate` to `x` and a transform remapped from `x>x` to
923
1039
  * `x>y` moved no measure here and no value in the value walk either (its skip
924
- * list names `properties`), so nothing in the tree compared them at all.
1040
+ * list named `properties`), so nothing in the tree compared them at all. Since
1041
+ * issue #1084 the walk reads both, by class (`property/kind`, `properties/FromX/to/ToY`).
925
1042
  *
926
1043
  * ⚠️ Absent is no token, never a default: a transform with no `properties` maps
927
1044
  * nothing, and two sides that both omit a field agree about it. And the tokens
@@ -967,8 +1084,8 @@ const REFS_SPELLED_OUT = 3;
967
1084
  * "5" vs "-a"` — the candidate's names for each field that differs, then the
968
1085
  * reference's, `none` where a side has none.
969
1086
  */
970
- function refsDisagreement(at: string, a: ConstraintFact, b: ConstraintFact): string {
971
- const byField = (f: ConstraintFact): Map<string, string[]> => {
1087
+ function refsDisagreement(at: string, a: { refList: string[] }, b: { refList: string[] }): string {
1088
+ const byField = (f: { refList: string[] }): Map<string, string[]> => {
972
1089
  const m = new Map<string, string[]>();
973
1090
  for (const token of f.refList) {
974
1091
  const colon = token.indexOf(':');
@@ -1024,9 +1141,43 @@ function constraintRefs(a: Map<string, ConstraintFact>, b: Map<string, Constrain
1024
1141
  return { ...base, note };
1025
1142
  }
1026
1143
 
1144
+ /**
1145
+ * The constraints in the order the file declares them, by kind and name — the
1146
+ * key `constraintFacts` uses, so that `leg` as an ik and as a transform are two
1147
+ * entries here as well.
1148
+ *
1149
+ * 🔍 **Why the order is data** (issue #1085). 4.3 folds every constraint into one
1150
+ * array and applies them in its order, so two files with the same constraints
1151
+ * wired the same way can pose differently. Measured by reversing the array and
1152
+ * posing both through spine-core: `spineboy-pro` moves 98 of its 99 sampled
1153
+ * poses, `6-arcs-pro` 8 of 9 and `sack-pro` 8 of 36 (33 of 36 with physics
1154
+ * stepped); swapping `aim-torso-ik` and `aim-torso-transform` alone moves the 9
1155
+ * samples of `aim`. Before this nothing in the tree compared it — the value walk
1156
+ * keys constraints by name, so it is blind to the order by construction.
1157
+ *
1158
+ * ⚠️ Not every swap poses differently, and the measure does not pretend to know
1159
+ * which do: `gallery/look`'s two sliders and `gallery/walk`'s two legs drive
1160
+ * disjoint bones and read the same pose either way. A reordering is reported as
1161
+ * what the file states, the way `bones.order` reports one.
1162
+ */
1163
+ function constraintOrder(root: Json): string[] {
1164
+ const out: string[] = [];
1165
+ for (const con of objs(root.constraints)) {
1166
+ const name = str(con.name);
1167
+ if (name !== null) out.push(constraintAt(str(con.type) ?? '(none)', name));
1168
+ }
1169
+ return out;
1170
+ }
1171
+
1027
1172
  function diffConstraints(c: Json, r: Json): DiffSection {
1028
1173
  const a = constraintFacts(c);
1029
1174
  const b = constraintFacts(r);
1175
+ const ao = constraintOrder(c);
1176
+ const bo = constraintOrder(r);
1177
+ const shared = ao.filter((n) => b.has(n));
1178
+ const sharedOther = bo.filter((n) => a.has(n));
1179
+ const inOrder = lcs(shared, sharedOther);
1180
+ const max = Math.max(a.size, b.size);
1030
1181
  return sectionOf('constraints', [
1031
1182
  measure('constraints.count', 'how many constraints', Math.min(a.size, b.size), Math.max(a.size, b.size)),
1032
1183
  jaccard('constraints.names', 'the constraint names', new Set(a.keys()), new Set(b.keys())),
@@ -1038,9 +1189,32 @@ function diffConstraints(c: Json, r: Json): DiffSection {
1038
1189
  ),
1039
1190
  agreement('constraints.type_by_name', 'each constraint is the same type', a, b, (x, y) => x.type === y.type),
1040
1191
  constraintRefs(a, b),
1192
+ measure(
1193
+ 'constraints.order',
1194
+ 'the constraints are declared, and so applied, in the same order',
1195
+ inOrder,
1196
+ max,
1197
+ [
1198
+ ...(inOrder === shared.length ? [] : [outOfOrder(shared, sharedOther)]),
1199
+ ...(shared.length === max ? [] : [`${max - shared.length} of the larger roster not on both sides, which \`constraints.names\` names`]),
1200
+ ].join('; ') || undefined,
1201
+ ),
1041
1202
  ]);
1042
1203
  }
1043
1204
 
1205
+ /**
1206
+ * `constraints.order`'s note when the shared constraints are not in one order:
1207
+ * both sides' order of them, candidate first, spelled out to a limit.
1208
+ */
1209
+ function outOfOrder(a: string[], b: string[]): string {
1210
+ const spell = (list: string[]): string =>
1211
+ list.slice(0, ORDER_SPELLED_OUT).join(', ') + (list.length > ORDER_SPELLED_OUT ? `, …and ${list.length - ORDER_SPELLED_OUT} more` : '');
1212
+ return `${a.length - lcs(a, b)} out of order: candidate ${spell(a)}; reference ${spell(b)}`;
1213
+ }
1214
+
1215
+ /** How many constraints `constraints.order`'s note lists per side before it counts the rest. */
1216
+ const ORDER_SPELLED_OUT = 6;
1217
+
1044
1218
  interface AnimationFacts {
1045
1219
  names: string[];
1046
1220
  /** `<anim>` -> last key time. Skeleton JSON has no duration field. */
@@ -1098,6 +1272,136 @@ function animationFacts(root: Json): AnimationFacts {
1098
1272
  return facts;
1099
1273
  }
1100
1274
 
1275
+ /**
1276
+ * The names the animations resolve, as two multisets of tokens (issue #1085).
1277
+ *
1278
+ * `animationFacts` keeps each timeline's kind and drops its target on purpose —
1279
+ * a candidate's own vocabulary would otherwise be counted against it a second
1280
+ * time inside `timeline_kinds` (#21) — and reads no key's contents beyond its
1281
+ * time and curve. So until this, a bone timeline moved onto another bone, an
1282
+ * attachment key naming another attachment, a draw-order key naming another slot
1283
+ * and an event key firing another event each moved no measure in this file, and
1284
+ * every one of them poses or draws differently: measured on public builds
1285
+ * through spine-core, a retargeted bone timeline moves 7 of `gallery/look`'s 27
1286
+ * sampled poses, two slots' attachment keys swapped move 9 of `spineboy-pro`'s
1287
+ * 99, a draw-order offset moved to the next slot moves 9 of `spineboy-ess`'s 72
1288
+ * and an event key renamed to a second event moves the event block of one sample.
1289
+ *
1290
+ * - `targets`: one token per timeline, `<animation>|<group>|<target>` — the
1291
+ * bone, slot or constraint it keys, a deform's or sequence's
1292
+ * `skin/slot/attachment`, and each slot a draw-order folder holds. The
1293
+ * timeline's own kind is left out on purpose: it is `timeline_kinds`'
1294
+ * subject, and a rotate keyed as a scale on the same bone moves that measure
1295
+ * and must not move this one too.
1296
+ * - `keyed`: one token per name a KEY carries, with its position in the
1297
+ * timeline — an attachment key's attachment (`(none)` for a key that clears
1298
+ * the slot), each draw-order offset's slot, an event key's event.
1299
+ *
1300
+ * Both are names, so both are name-matched, and a candidate with its own names
1301
+ * reads low here exactly as it does on `names` — the name-agnostic block is
1302
+ * where that rig is read. ⚠️ No key VALUE is read: a key's time, an offset's
1303
+ * distance and an event's payload are the value walk's.
1304
+ */
1305
+ interface AnimationRefs {
1306
+ targets: Map<string, number>;
1307
+ keyed: Map<string, number>;
1308
+ }
1309
+
1310
+ /** The physics group's empty name, which applies a timeline to every physics constraint. */
1311
+ const EVERY_PHYSICS_CONSTRAINT = '(every physics constraint)';
1312
+
1313
+ function animationRefs(root: Json): AnimationRefs {
1314
+ const targets = new Map<string, number>();
1315
+ const keyed = new Map<string, number>();
1316
+ const bump = (m: Map<string, number>, k: string): void => {
1317
+ m.set(k, (m.get(k) ?? 0) + 1);
1318
+ };
1319
+ if (!isObj(root.animations)) return { targets, keyed };
1320
+ for (const [anim, body] of Object.entries(root.animations)) {
1321
+ if (!isObj(body)) continue;
1322
+ for (const group of ['bones', 'slots', 'path', 'physics', 'slider'] as const) {
1323
+ const held = body[group];
1324
+ if (!isObj(held)) continue;
1325
+ for (const [target, timelines] of Object.entries(held)) {
1326
+ if (!isObj(timelines)) continue;
1327
+ const named = group === 'physics' && target === '' ? EVERY_PHYSICS_CONSTRAINT : target;
1328
+ for (const [timeline, keys] of Object.entries(timelines)) {
1329
+ if (!Array.isArray(keys)) continue;
1330
+ bump(targets, `${anim}|${group}|${named}`);
1331
+ if (group === 'slots' && timeline === 'attachment') {
1332
+ keys.forEach((key, i) => {
1333
+ if (isObj(key)) bump(keyed, `${anim}|attachment key ${i}|${target}: ${str(key.name) ?? '(none)'}`);
1334
+ });
1335
+ }
1336
+ }
1337
+ }
1338
+ }
1339
+ for (const group of ['ik', 'transform'] as const) {
1340
+ const held = body[group];
1341
+ if (!isObj(held)) continue;
1342
+ for (const [target, keys] of Object.entries(held)) if (Array.isArray(keys)) bump(targets, `${anim}|${group}|${target}`);
1343
+ }
1344
+ if (isObj(body.attachments)) {
1345
+ for (const [skin, bySlot] of Object.entries(body.attachments)) {
1346
+ if (!isObj(bySlot)) continue;
1347
+ for (const [slot, byAttachment] of Object.entries(bySlot)) {
1348
+ if (!isObj(byAttachment)) continue;
1349
+ for (const [attachment, timelines] of Object.entries(byAttachment)) {
1350
+ if (!isObj(timelines)) continue;
1351
+ for (const keys of Object.values(timelines)) {
1352
+ if (Array.isArray(keys)) bump(targets, `${anim}|attachments|${skin}/${slot}/${attachment}`);
1353
+ }
1354
+ }
1355
+ }
1356
+ }
1357
+ }
1358
+ const offsets = (label: string, keys: unknown): void => {
1359
+ arr(keys).forEach((key, i) => {
1360
+ if (!isObj(key)) return;
1361
+ for (const offset of objs(key.offsets)) bump(keyed, `${anim}|${label} key ${i}|${str(offset.slot) ?? '(none)'}`);
1362
+ });
1363
+ };
1364
+ offsets('drawOrder', body.drawOrder);
1365
+ objs(body.drawOrderFolder).forEach((folder, f) => {
1366
+ for (const slot of arr(folder.slots)) bump(targets, `${anim}|drawOrderFolder ${f}|${str(slot) ?? '(none)'}`);
1367
+ offsets(`drawOrderFolder ${f}`, folder.keys);
1368
+ });
1369
+ arr(body.events).forEach((key, i) => {
1370
+ if (isObj(key)) bump(keyed, `${anim}|event key ${i}|${str(key.name) ?? '(none)'}`);
1371
+ });
1372
+ }
1373
+ return { targets, keyed };
1374
+ }
1375
+
1376
+ /** How many tokens a names histogram's note spells out per side before it counts the rest. */
1377
+ const TOKENS_SPELLED_OUT = 3;
1378
+
1379
+ /**
1380
+ * `histogram`, with a note naming what each side holds that the other does not —
1381
+ * the figure alone says some name moved, and a reader acting on it needs which.
1382
+ */
1383
+ function namesHistogram(id: string, what: string, a: Map<string, number>, b: Map<string, number>): DiffMeasure {
1384
+ const base = histogram(id, what, a, b);
1385
+ if (base.matched === base.total) return base;
1386
+ // A token one side holds more often than the other, with how many more — a
1387
+ // bone keyed by three timelines on one side and one on the other is `×2`.
1388
+ const only = (x: Map<string, number>, y: Map<string, number>): string[] => {
1389
+ const out: string[] = [];
1390
+ for (const [k, v] of x) {
1391
+ const extra = v - (y.get(k) ?? 0);
1392
+ if (extra > 0) out.push(`${JSON.stringify(k)}${extra > 1 ? ` ×${extra}` : ''}`);
1393
+ }
1394
+ return out;
1395
+ };
1396
+ const spell = (side: string, list: string[]): string =>
1397
+ list.length === 0
1398
+ ? ''
1399
+ : `${side} only: ${list.slice(0, TOKENS_SPELLED_OUT).join(', ')}` +
1400
+ (list.length > TOKENS_SPELLED_OUT ? `, …and ${list.length - TOKENS_SPELLED_OUT} more` : '');
1401
+ const parts = [spell('candidate', only(a, b)), spell('reference', only(b, a))].filter((p) => p !== '');
1402
+ return { ...base, note: `${base.total - base.matched} differ — ${parts.join('; ')}` };
1403
+ }
1404
+
1101
1405
  /**
1102
1406
  * What a side's keying costs, in the two shapes that separate the two ways a
1103
1407
  * key total can differ: more timelines, or more keys inside a timeline.
@@ -1266,6 +1570,8 @@ function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPai
1266
1570
  const at = keyingTotals(a);
1267
1571
  const bt = keyingTotals(b);
1268
1572
  const paired = pairAnimations(a, b, pairsStated);
1573
+ const refsA = animationRefs(c);
1574
+ const refsB = animationRefs(r);
1269
1575
  const perSecond = (t: KeyingTotals): number => (t.seconds === 0 ? 0 : t.keys / t.seconds);
1270
1576
  const perTimeline = (t: KeyingTotals): number => (t.timelines === 0 ? 0 : t.keys / t.timelines);
1271
1577
  return sectionOf('animations', [
@@ -1284,6 +1590,18 @@ function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPai
1284
1590
  histogram('animations.event_keys', 'as many event firings', a.events, b.events),
1285
1591
  agreement('animations.draw_order', 'a draw-order timeline is present or absent alike', a.hasDrawOrder, b.hasDrawOrder, (x, y) => x === y),
1286
1592
  agreement('animations.deform', 'a deform timeline is present or absent alike', a.hasDeform, b.hasDeform, (x, y) => x === y),
1593
+ namesHistogram(
1594
+ 'animations.targets',
1595
+ 'each timeline keys the same bone, slot, constraint or attachment',
1596
+ refsA.targets,
1597
+ refsB.targets,
1598
+ ),
1599
+ namesHistogram(
1600
+ 'animations.keyed_names',
1601
+ 'each key names the same attachment, draw-order slot or event',
1602
+ refsA.keyed,
1603
+ refsB.keyed,
1604
+ ),
1287
1605
  ],
1288
1606
  // ── the same two skeletons' shots, paired rather than named (issue #720) ──
1289
1607
  //
@@ -1628,7 +1946,7 @@ export const VALUE_PARSED_ULP = 2 ** -23;
1628
1946
  * Both terms are derived rather than fitted: the first is rigc's models' grid,
1629
1947
  * the second is the parser's storage. Measured over the twelve editor exports
1630
1948
  * in `examples/`, the widest gap between a rebuild and the file it was read
1631
- * from is **0** — none of 188,339 numeric values differs at all, since issue
1949
+ * from is **0** — none of 189,699 numeric values differs at all, since issue
1632
1950
  * #716 made every emitted number its float's own name (it was 0.81 of this
1633
1951
  * bound, over 56,951 values that differed, while rigc emitted six decimals) —
1634
1952
  * so the corpus sits inside a bound that was not drawn around it, and no
@@ -1657,7 +1975,11 @@ const VALUE_MEASURES: ReadonlyArray<{ id: string; what: string; prefix: string }
1657
1975
  { id: 'values.bones', what: 'every bone setup pose, its length and its colour', prefix: 'bones/' },
1658
1976
  { id: 'values.slots', what: 'every slot colour, dark colour, blend mode and setup attachment', prefix: 'slots/' },
1659
1977
  { id: 'values.attachments', what: 'every attachment offset, size, vertex, weight, uv and triangle', prefix: 'skins/' },
1660
- { id: 'values.constraints', what: 'every constraint pose field and flag', prefix: 'constraints/' },
1978
+ {
1979
+ id: 'values.constraints',
1980
+ what: 'every constraint field the parser reads: pose, flags, offsets, and the property map and its kinds',
1981
+ prefix: 'constraints/',
1982
+ },
1661
1983
  { id: 'values.events', what: 'every event payload in the setup pose', prefix: 'events/' },
1662
1984
  { id: 'values.key_times', what: 'every key time, and each animation\'s duration', prefix: 'animations/' },
1663
1985
  { id: 'values.key_values', what: 'every keyed value: poses, deform vertices, draw orders, event payloads', prefix: 'animations/' },