spine-rigc 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.
@@ -884,8 +1000,15 @@ function runtimeNames(a: Map<string, AttachmentFact>, b: Map<string, AttachmentF
884
1000
 
885
1001
  interface ConstraintFact {
886
1002
  type: string;
887
- /** Every bone or slot the constraint names, sorted — its wiring. */
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
+ */
888
1009
  refs: string;
1010
+ /** The same tokens as a list, for the note that spells a disagreement out. */
1011
+ refList: string[];
889
1012
  }
890
1013
 
891
1014
  /**
@@ -898,6 +1021,29 @@ interface ConstraintFact {
898
1021
  * way, so the report read 1.000 while its own header line counted one constraint
899
1022
  * more than `constraints.count` did, measured at 14/14 against 13/13 on the
900
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.
901
1047
  */
902
1048
  function constraintFacts(root: Json): Map<string, ConstraintFact> {
903
1049
  const out = new Map<string, ConstraintFact>();
@@ -910,11 +1056,115 @@ function constraintFacts(root: Json): Map<string, ConstraintFact> {
910
1056
  const s = str(b);
911
1057
  if (s !== null) refs.add(`bone:${s}`);
912
1058
  }
913
- for (const key of ['bone', 'target', 'source', 'slot'] as const) {
1059
+ for (const key of ['bone', 'target', 'source', 'slot', 'animation', 'property'] as const) {
914
1060
  const s = str(con[key]);
915
1061
  if (s !== null) refs.add(`${key}:${s}`);
916
1062
  }
917
- out.set(at, { type: str(con.type) ?? '(none)', refs: [...refs].sort().join(' ') });
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));
918
1168
  }
919
1169
  return out;
920
1170
  }
@@ -922,6 +1172,12 @@ function constraintFacts(root: Json): Map<string, ConstraintFact> {
922
1172
  function diffConstraints(c: Json, r: Json): DiffSection {
923
1173
  const a = constraintFacts(c);
924
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);
925
1181
  return sectionOf('constraints', [
926
1182
  measure('constraints.count', 'how many constraints', Math.min(a.size, b.size), Math.max(a.size, b.size)),
927
1183
  jaccard('constraints.names', 'the constraint names', new Set(a.keys()), new Set(b.keys())),
@@ -932,10 +1188,33 @@ function diffConstraints(c: Json, r: Json): DiffSection {
932
1188
  counted([...b.values()].map((f) => f.type)),
933
1189
  ),
934
1190
  agreement('constraints.type_by_name', 'each constraint is the same type', a, b, (x, y) => x.type === y.type),
935
- agreement('constraints.refs', 'each constraint names the same bones and slots', a, b, (x, y) => x.refs === y.refs),
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
+ ),
936
1202
  ]);
937
1203
  }
938
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
+
939
1218
  interface AnimationFacts {
940
1219
  names: string[];
941
1220
  /** `<anim>` -> last key time. Skeleton JSON has no duration field. */
@@ -993,6 +1272,136 @@ function animationFacts(root: Json): AnimationFacts {
993
1272
  return facts;
994
1273
  }
995
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
+
996
1405
  /**
997
1406
  * What a side's keying costs, in the two shapes that separate the two ways a
998
1407
  * key total can differ: more timelines, or more keys inside a timeline.
@@ -1161,6 +1570,8 @@ function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPai
1161
1570
  const at = keyingTotals(a);
1162
1571
  const bt = keyingTotals(b);
1163
1572
  const paired = pairAnimations(a, b, pairsStated);
1573
+ const refsA = animationRefs(c);
1574
+ const refsB = animationRefs(r);
1164
1575
  const perSecond = (t: KeyingTotals): number => (t.seconds === 0 ? 0 : t.keys / t.seconds);
1165
1576
  const perTimeline = (t: KeyingTotals): number => (t.timelines === 0 ? 0 : t.keys / t.timelines);
1166
1577
  return sectionOf('animations', [
@@ -1179,6 +1590,18 @@ function diffAnimations(c: Json, r: Json, pairsStated: readonly DiffAnimationPai
1179
1590
  histogram('animations.event_keys', 'as many event firings', a.events, b.events),
1180
1591
  agreement('animations.draw_order', 'a draw-order timeline is present or absent alike', a.hasDrawOrder, b.hasDrawOrder, (x, y) => x === y),
1181
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
+ ),
1182
1605
  ],
1183
1606
  // ── the same two skeletons' shots, paired rather than named (issue #720) ──
1184
1607
  //
@@ -1523,7 +1946,7 @@ export const VALUE_PARSED_ULP = 2 ** -23;
1523
1946
  * Both terms are derived rather than fitted: the first is rigc's models' grid,
1524
1947
  * the second is the parser's storage. Measured over the twelve editor exports
1525
1948
  * in `examples/`, the widest gap between a rebuild and the file it was read
1526
- * 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
1527
1950
  * #716 made every emitted number its float's own name (it was 0.81 of this
1528
1951
  * bound, over 56,951 values that differed, while rigc emitted six decimals) —
1529
1952
  * so the corpus sits inside a bound that was not drawn around it, and no
@@ -1552,7 +1975,11 @@ const VALUE_MEASURES: ReadonlyArray<{ id: string; what: string; prefix: string }
1552
1975
  { id: 'values.bones', what: 'every bone setup pose, its length and its colour', prefix: 'bones/' },
1553
1976
  { id: 'values.slots', what: 'every slot colour, dark colour, blend mode and setup attachment', prefix: 'slots/' },
1554
1977
  { id: 'values.attachments', what: 'every attachment offset, size, vertex, weight, uv and triangle', prefix: 'skins/' },
1555
- { 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
+ },
1556
1983
  { id: 'values.events', what: 'every event payload in the setup pose', prefix: 'events/' },
1557
1984
  { id: 'values.key_times', what: 'every key time, and each animation\'s duration', prefix: 'animations/' },
1558
1985
  { id: 'values.key_values', what: 'every keyed value: poses, deform vertices, draw orders, event payloads', prefix: 'animations/' },
package/src/png.ts CHANGED
@@ -154,11 +154,14 @@ function whatItIs(buf: Uint8Array): string {
154
154
  * decode PNG …: unexpected end of file`, and reached `build --atlas-in` as a
155
155
  * stack trace — three sentences about one file, and none said what it was.
156
156
  *
157
- * It says three things and decodes nothing: what the file is instead (by the
157
+ * It says four things and decodes nothing: what the file is instead (by the
158
158
  * signature it does carry, with its first bytes in hex either way), whether a
159
- * file that begins as a PNG ends before its chunks do, and whether IHDR comes
160
- * first. The chunk walk reads only the eight-byte chunk headers, so it costs a
161
- * skip through the file and no inflate.
159
+ * file that begins as a PNG ends before its chunks do, whether IHDR comes
160
+ * first, and whether IHDR states a size of at least 1x1 (issue #1073). The
161
+ * chunk walk reads only the eight-byte chunk headers, so it costs a skip
162
+ * through the file and no inflate — which is also why it cannot say whether
163
+ * the image data decodes: that is `imageDataProblem` in
164
+ * [`tools/plate.ts`](../tools/plate.ts), beside the decoder (issue #1074).
162
165
  *
163
166
  * ⚠️ **"Truncated" is judged by the chunk walk, not by a length floor.** The
164
167
  * floor that stood here (`buf.length < 26`, "too short") never said truncated,
@@ -208,6 +211,28 @@ export function pngProblem(buf: Uint8Array, path: string): string | null {
208
211
  `its ${type} chunk at byte ${at} declares ${length} byte(s) of data, which with its CRC runs to byte ${next}`,
209
212
  );
210
213
  }
214
+ if (type === 'IHDR') {
215
+ // 🔒 **A dimension of 0 is a file that describes no texel** (issue
216
+ // #1073), and the format says so: IHDR's width and height are each at
217
+ // least 1. Accepted here, such a page was read as a valid PNG of the
218
+ // wrong size — A06 printed its grid sentence over a ratio of 0.0000, A19
219
+ // a "not measured" line once per region on the page (13 on one export),
220
+ // the compiler took the 0 as a loose part's size and was refused by A03
221
+ // and A10 (`non-positive size`, `x NaN`) or by the mesher (`bad part
222
+ // size 0x150`) without the file named, and `render` drew it in silence.
223
+ // Refused here, it is one fact about one file, said by the one reader
224
+ // every other reader asks first.
225
+ const width = view.getUint32(at + 8);
226
+ const height = view.getUint32(at + 12);
227
+ if (width === 0 || height === 0) {
228
+ const zero = width === 0 && height === 0 ? 'its width and its height are' : width === 0 ? 'its width is' : 'its height is';
229
+ return (
230
+ `${path} is a PNG whose IHDR chunk states its size as ${width}x${height}: ${zero} 0, where the format ` +
231
+ 'requires each dimension to be at least 1, so the file describes no texel and nothing in it can be ' +
232
+ 'measured or drawn. Re-export it'
233
+ );
234
+ }
235
+ }
211
236
  if (type === 'IEND') return null;
212
237
  previous = `its ${type} chunk at byte ${at}`;
213
238
  at = next;
@@ -216,9 +241,11 @@ export function pngProblem(buf: Uint8Array, path: string): string | null {
216
241
 
217
242
  /**
218
243
  * A file that is not a PNG rigc can read, thrown by the readers that return a
219
- * value or nothing (`readPngInfo`, `readPlate`). The message is `pngProblem`'s,
220
- * whole; a caller that owns a named failure — the gate, a `CompileError` —
221
- * reads the sentence instead of catching the throw (`readPngHeader`).
244
+ * value or nothing (`readPngInfo`, `readPlate`). The message is `pngProblem`'s
245
+ * — or, from `readPlate`, `imageDataProblem`'s for a file whose header reads
246
+ * and whose image data does not decode (issue #1074) — whole; a caller that
247
+ * owns a named failure — the gate, a `CompileError` — reads the sentence
248
+ * instead of catching the throw (`readPngHeader`).
222
249
  */
223
250
  export class NotAPngError extends Error {
224
251
  readonly path: string;
package/src/spine_side.ts CHANGED
@@ -63,6 +63,21 @@ export const SURVEY_RUNTIME_TAIL =
63
63
  */
64
64
  export const SPINE_SIDE_ABSENT = 'the runtime side is not installed in this entry, which links nothing of spine-core';
65
65
 
66
+ /**
67
+ * What an entry that registered no Spine side adds after the tail: where the
68
+ * runtime is (issue #1079). On an install `bin/rigc.cjs` runs this entry
69
+ * wherever `@esotericsoftware/spine-core` does not resolve, so "the runtime is
70
+ * not linked" there means "it is not installed" — and a clone runs it as
71
+ * `bun cli_core.ts`, so both routes are named, each for its reader.
72
+ *
73
+ * ⚠️ Only the absent side says it. A runtime that is linked and fails to load
74
+ * (`./render.ts`, `./deformmeasure.ts`) is installed already, and "install it
75
+ * beside the package" would be the wrong sentence there.
76
+ */
77
+ export const SPINE_SIDE_ROUTE =
78
+ 'the entry that links it runs them — installed, the same `rigc` once @esotericsoftware/spine-core is installed beside the package; ' +
79
+ 'from a source checkout, `bun cli.ts`';
80
+
66
81
  /** The one sentence a run that needs the runtime and cannot use it is refused in — `tail` says what the runtime is for. */
67
82
  export function spineRuntimeSentence(label: string, why: string, reason: string, tail: string): string {
68
83
  return `${label} is posed through spine-core (${why}), and the runtime could not be used: ${reason}. ${tail}`;
@@ -107,13 +122,13 @@ export function registerSpineSurvey(side: SpineSurveySide): void {
107
122
 
108
123
  /** The posing side, or a refusal of `label` (needed for `why`) naming the runtime as absent. */
109
124
  export function spinePosingFor(label: string, why: string): SpinePosingSide {
110
- if (posing === null) throw new SpineRuntimeError(spineRuntimeSentence(label, why, SPINE_SIDE_ABSENT, POSING_RUNTIME_TAIL));
125
+ if (posing === null) throw new SpineRuntimeError(spineRuntimeSentence(label, why, SPINE_SIDE_ABSENT, `${POSING_RUNTIME_TAIL}; ${SPINE_SIDE_ROUTE}`));
111
126
  return posing;
112
127
  }
113
128
 
114
129
  /** The survey side, or a refusal of `label` (needed for `why`) naming the runtime as absent. */
115
130
  export function spineSurveyFor(label: string, why: string): SpineSurveySide {
116
- if (survey === null) throw new SpineRuntimeError(spineRuntimeSentence(label, why, SPINE_SIDE_ABSENT, SURVEY_RUNTIME_TAIL));
131
+ if (survey === null) throw new SpineRuntimeError(spineRuntimeSentence(label, why, SPINE_SIDE_ABSENT, `${SURVEY_RUNTIME_TAIL}; ${SPINE_SIDE_ROUTE}`));
117
132
  return survey;
118
133
  }
119
134