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/NOTICE.md +2 -2
- package/README.md +9 -7
- package/docs/AUTHORING.md +104 -14
- package/docs/FACE.md +1 -1
- package/docs/INGEST.md +35 -26
- package/package.json +1 -1
- package/src/assertions/bodies/a06.ts +19 -53
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/emitted/index.ts +1 -1
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/kinds.ts +6 -3
- package/src/assertions/model/index.ts +3 -1
- package/src/assertions/reasons.ts +2 -0
- package/src/atlas.ts +712 -11
- package/src/cli/shared.ts +55 -22
- package/src/compile.ts +4 -1
- package/src/diff.ts +327 -5
- package/src/validate.ts +98 -7
- package/tools/editor_roundtrip.ts +75 -1
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
|
|
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
|
|
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
|
|
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:
|
|
971
|
-
const byField = (f:
|
|
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
|
|
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
|
-
{
|
|
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/' },
|