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/README.md +8 -3
- package/docs/AUTHORING.md +41 -5
- package/docs/INGEST.md +29 -20
- package/package.json +1 -1
- package/src/assertions/bodies/a06.ts +28 -2
- package/src/assertions/bodies/a19.ts +28 -10
- package/src/compile.ts +4 -1
- package/src/diff.ts +433 -6
- package/src/png.ts +34 -7
- package/src/spine_side.ts +17 -2
- package/src/validate.ts +90 -7
- package/tools/editor_roundtrip.ts +112 -2
- package/tools/plate.ts +100 -27
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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,
|
|
160
|
-
* first
|
|
161
|
-
*
|
|
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
|
-
*
|
|
221
|
-
*
|
|
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
|
|