spine-rigc 0.22.2 → 0.23.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/README.md +64 -1
- package/cli.ts +205 -11
- package/docs/AUTHORING.md +357 -54
- package/docs/INGEST.md +185 -41
- package/docs/SPEC_COVERAGE.md +13 -1
- package/package.json +1 -1
- package/skills/ingest/SKILL.md +33 -11
- package/src/atlas.ts +57 -13
- package/src/check.ts +83 -1
- package/src/compile.ts +284 -58
- package/src/diff.ts +125 -2
- package/src/ingest.ts +1078 -0
- package/src/render.ts +104 -10
- package/src/rig.ts +92 -8
- package/src/types.ts +39 -7
- package/src/validate.ts +205 -27
- package/tools/editor_roundtrip.ts +172 -21
package/src/validate.ts
CHANGED
|
@@ -114,13 +114,19 @@ export const CLI_DEFAULT_PROFILE: ValidateProfile = 'spine';
|
|
|
114
114
|
* archetype — a structural rule about rigc's own formations, meaningless to a
|
|
115
115
|
* skeleton rigc did not compile.
|
|
116
116
|
*
|
|
117
|
-
*
|
|
117
|
+
* Two assertions are MIXED and are marked `validity` here because their
|
|
118
118
|
* validity half must never stop running; their policy clauses are gated inside
|
|
119
119
|
* the assertion body against `profile`, and each such clause says so where it
|
|
120
120
|
* lives. They are A06 (size-vs-PNG is validity; pma / rotation / full-page
|
|
121
|
-
* coverage are policy)
|
|
122
|
-
*
|
|
123
|
-
*
|
|
121
|
+
* coverage are policy) and A20 (weight coherence is validity; requiring a mesh
|
|
122
|
+
* to be weighted at all is policy).
|
|
123
|
+
*
|
|
124
|
+
* A08 was the third until issue #574 retired its policy clause. It required a
|
|
125
|
+
* skin entry's placeholder to be spelled like the region it resolves to, under
|
|
126
|
+
* a renderer that resolves art by `path` and has never read a placeholder — so
|
|
127
|
+
* restating it as the renderer's own join made it a tautology over the validity
|
|
128
|
+
* half. The argument, with the renderer lines it is measured against, sits above
|
|
129
|
+
* `check('A08_…')`.
|
|
124
130
|
*/
|
|
125
131
|
const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
|
|
126
132
|
A00_ROUNDTRIP_PARSE: 'validity',
|
|
@@ -131,7 +137,7 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
|
|
|
131
137
|
A05_CURVE_ARRAY_LENGTH: 'validity',
|
|
132
138
|
A06_ATLAS_PAGE_SIZE_MATCHES_PNG: 'validity', // mixed — see above
|
|
133
139
|
A07_ATLAS_TEXT_SHAPE: 'validity',
|
|
134
|
-
A08_REGION_NAMES_MATCH_ATTACHMENTS: 'validity', // mixed — see above
|
|
140
|
+
A08_REGION_NAMES_MATCH_ATTACHMENTS: 'validity', // no longer mixed — see above (#574)
|
|
135
141
|
A09_ANIMATION_DURATION_MATCHES_SPEC: 'validity',
|
|
136
142
|
A10_NO_NAN_AFTER_STEPPING: 'validity',
|
|
137
143
|
A11_NO_CLIPPING_ATTACHMENTS: 'renderer',
|
|
@@ -183,6 +189,22 @@ export function assertionCountForProfile(profile: ValidateProfile): number {
|
|
|
183
189
|
return ASSERTION_NAMES.filter((name) => ASSERTION_KIND[name] === 'validity').length;
|
|
184
190
|
}
|
|
185
191
|
|
|
192
|
+
/**
|
|
193
|
+
* What a SKIP says when `A00_ROUNDTRIP_PARSE` handed an assertion nothing to
|
|
194
|
+
* look at.
|
|
195
|
+
*
|
|
196
|
+
* Two of them rather than one, because what an assertion was denied is the whole
|
|
197
|
+
* content of its SKIP: `A20` wanted the loaded skeleton and `A06` wanted the
|
|
198
|
+
* loaded atlas, and a reader who is told which can tell a rule that is waiting
|
|
199
|
+
* on the parse from one that is waiting on the art. Both point at A00 instead of
|
|
200
|
+
* restating its detail, which the FAIL row prints in full.
|
|
201
|
+
*
|
|
202
|
+
* Exported so a control compares against them rather than quoting them — the
|
|
203
|
+
* same reason `ASSERTION_NAMES` is exported.
|
|
204
|
+
*/
|
|
205
|
+
export const SKIP_NO_SKELETON = 'the round trip did not produce a skeleton to measure (A00 owns that failure)';
|
|
206
|
+
export const SKIP_NO_ATLAS = 'the round trip did not produce an atlas to measure (A00 owns that failure)';
|
|
207
|
+
|
|
186
208
|
export interface ValidateInput {
|
|
187
209
|
skeletonText: string;
|
|
188
210
|
atlasText: string;
|
|
@@ -1282,8 +1304,20 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1282
1304
|
check('A14_NO_FULL_FRAME_MESH', () => {
|
|
1283
1305
|
const stageW = data.width || 0;
|
|
1284
1306
|
const stageH = data.height || 0;
|
|
1307
|
+
// ⚠️ A stage-less skeleton has nothing for a mesh to span, and this rule
|
|
1308
|
+
// used to report that as a PASS — the `stageW && stageH` guard below reads
|
|
1309
|
+
// as a measurement of a 0x0 stage that no mesh can reach. It was only ever
|
|
1310
|
+
// reachable from a foreign file until a rig spec could *declare* no stage
|
|
1311
|
+
// (issue #578), and a pass certifying an unmeasured rig is the exact
|
|
1312
|
+
// failure mode `A21_MESH_RIM_PINNED`'s `|| 'ring'` default was (#44).
|
|
1313
|
+
if (!stageW || !stageH) {
|
|
1314
|
+
return skip(
|
|
1315
|
+
'A14_NO_FULL_FRAME_MESH',
|
|
1316
|
+
'the skeleton declares no stage size, so there is no full frame for a mesh to span',
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1285
1319
|
for (const mesh of meshAttachments) {
|
|
1286
|
-
if (
|
|
1320
|
+
if (mesh.width >= stageW && mesh.height >= stageH) {
|
|
1287
1321
|
fail('A14_NO_FULL_FRAME_MESH', `mesh "${mesh.name}" spans the whole ${stageW}x${stageH} stage`);
|
|
1288
1322
|
}
|
|
1289
1323
|
}
|
|
@@ -1307,8 +1341,19 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1307
1341
|
}
|
|
1308
1342
|
}
|
|
1309
1343
|
}
|
|
1344
|
+
// The same shape as A06's and A17's guards, found by auditing for it
|
|
1345
|
+
// (#568): a rig with no `idle` at all has nothing here to be wrong, and a
|
|
1346
|
+
// rule that reports "held" over a subject that does not exist is the
|
|
1347
|
+
// vacuous pass this file's own doctrine refuses. The two states get their
|
|
1348
|
+
// own sentences because they are different absences — no such animation,
|
|
1349
|
+
// versus one that keys no bone.
|
|
1310
1350
|
const idle = isObj(raw?.animations) ? (raw.animations as Json).idle : undefined;
|
|
1311
|
-
if (!isObj(idle)
|
|
1351
|
+
if (!isObj(idle)) {
|
|
1352
|
+
return skip('A15_IDLE_NO_MESH_BONE_KEYS', 'the skeleton declares no "idle" animation, so nothing here can key a mesh-driving bone');
|
|
1353
|
+
}
|
|
1354
|
+
if (!isObj(idle.bones)) {
|
|
1355
|
+
return skip('A15_IDLE_NO_MESH_BONE_KEYS', '"idle" carries no bone timeline at all, so there is no key to hold against the mesh-driving bones');
|
|
1356
|
+
}
|
|
1312
1357
|
for (const boneName of Object.keys(idle.bones as Json)) {
|
|
1313
1358
|
if (meshBoneNames.has(boneName)) {
|
|
1314
1359
|
fail('A15_IDLE_NO_MESH_BONE_KEYS', `idle keys bone "${boneName}", which drives a mesh — meshes never idle-skip`);
|
|
@@ -2486,7 +2531,46 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2486
2531
|
stats.skinMembers = listed;
|
|
2487
2532
|
});
|
|
2488
2533
|
|
|
2489
|
-
// --- A08: region
|
|
2534
|
+
// --- A08: every attachment's path resolves to a region the atlas has ---
|
|
2535
|
+
//
|
|
2536
|
+
// 🗑️ A08 used to be MIXED: the join above is validity, and a second clause
|
|
2537
|
+
// gated on `spine-html` required the skin entry's PLACEHOLDER to be spelled
|
|
2538
|
+
// exactly like the region it resolves to ("v0 requires them identical").
|
|
2539
|
+
// That clause is retired (issue #574), and the reason is that the renderer
|
|
2540
|
+
// it was profiled under never performed the join it described.
|
|
2541
|
+
//
|
|
2542
|
+
// `spine-html@0.4.1` resolves art in two steps and the placeholder is in
|
|
2543
|
+
// neither. `DomTexture.js:78,102` builds the image map with
|
|
2544
|
+
// `put(atlasRegion.name, …)` over every region of the atlas, and
|
|
2545
|
+
// `SpineHtmlRenderer.js:172` reads it back as
|
|
2546
|
+
// `const regionImage = region && this.regionImages.get(region.name)`, where
|
|
2547
|
+
// `region` came off the attachment — which `AtlasAttachmentLoader` resolved
|
|
2548
|
+
// through `path`. Every published version of that renderer keys the same way
|
|
2549
|
+
// (checked 0.1.0 through 0.4.1, the whole series). Nothing in it reads an
|
|
2550
|
+
// attachment's name, let alone its placeholder.
|
|
2551
|
+
//
|
|
2552
|
+
// ⇒ Restated as the join the renderer actually performs, the clause compares
|
|
2553
|
+
// `path` against the region `path` resolved — a tautology, and the same
|
|
2554
|
+
// string the validity check above already requires the atlas to hold. An
|
|
2555
|
+
// assertion that cannot fail is not a gate, so it is removed rather than
|
|
2556
|
+
// kept as a duplicate.
|
|
2557
|
+
//
|
|
2558
|
+
// ⚠️ It was not merely inert, either: it refused rigs on both sides of the
|
|
2559
|
+
// convention it was written for. `path` exists precisely so a placeholder
|
|
2560
|
+
// may differ from the PNG basename (R5), and since issue #567 a placeholder
|
|
2561
|
+
// two named skins share is emitted as `<skin>/<placeholder>` with `path`
|
|
2562
|
+
// restated to the basename — the only spelling the Spine editor holds. Under
|
|
2563
|
+
// the old clause that shape, and any rig that merely named a part something
|
|
2564
|
+
// other than its placeholder, was red under `spine-html` while the editor
|
|
2565
|
+
// imported it and the renderer drew it.
|
|
2566
|
+
//
|
|
2567
|
+
// 📏 What is left, measured rather than assumed: only the LAST loop can
|
|
2568
|
+
// fire. Both attachment-join clauses are preempted by the loader —
|
|
2569
|
+
// `AtlasAttachmentLoader.findRegion` throws
|
|
2570
|
+
// `Region not found in atlas: <path> (attachment: <name>)` for a path that
|
|
2571
|
+
// names no region and for one padded with whitespace alike, so A00 reports
|
|
2572
|
+
// it and A08 never runs. `PS90` is the mutant for the loop that does fire,
|
|
2573
|
+
// and it is the first mutant this assertion has ever had.
|
|
2490
2574
|
check('A08_REGION_NAMES_MATCH_ATTACHMENTS', () => {
|
|
2491
2575
|
const regionNames = new Set(atlas!.regions.map((r) => r.name));
|
|
2492
2576
|
for (const skin of data.skins) {
|
|
@@ -2500,16 +2584,6 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2500
2584
|
if (!regionNames.has(lookup)) {
|
|
2501
2585
|
fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `attachment "${entry.placeholder}" wants region "${lookup}", which the atlas does not have`);
|
|
2502
2586
|
}
|
|
2503
|
-
// 📐 PROFILE. That the join RESOLVES is validity — an attachment
|
|
2504
|
-
// pointing at a region the atlas does not have is a hole in the rig
|
|
2505
|
-
// whoever loads it. That the two names are IDENTICAL is rigc's v0
|
|
2506
|
-
// policy: it holds because a region name IS the PNG basename here, and
|
|
2507
|
-
// rigc's own packer keeps it that way (issue #4), while a foreign
|
|
2508
|
-
// packer renames regions by design (spineboy's `path` differs from its
|
|
2509
|
-
// placeholder in 26 attachments).
|
|
2510
|
-
if (policy && entry.placeholder !== lookup) {
|
|
2511
|
-
fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `attachment "${entry.placeholder}" resolves to region "${lookup}"; v0 requires them identical`);
|
|
2512
|
-
}
|
|
2513
2587
|
}
|
|
2514
2588
|
}
|
|
2515
2589
|
for (const region of atlas!.regions) {
|
|
@@ -2620,15 +2694,26 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2620
2694
|
// --- A06 / A17 / A19: the atlas against the PNGs on disk ------------------
|
|
2621
2695
|
// Case 6h: a `size:` that disagrees with the file loads fine and collapses
|
|
2622
2696
|
// every UV — rigid stays correct, meshes sample a corner scrap.
|
|
2697
|
+
//
|
|
2698
|
+
// 🚨 The guard is a SKIP and never a bare `return` (issue #568). A `return`
|
|
2699
|
+
// inside `check()` leaves the failure count and the skip count where they
|
|
2700
|
+
// were, which is precisely how that function decides an assertion PASSED — so
|
|
2701
|
+
// for as long as this line read `if (!atlas) return;` a candidate whose atlas
|
|
2702
|
+
// the round trip could not load was reported green by the two rules whose only
|
|
2703
|
+
// subject IS the atlas. It was measured on a fixture carrying a page that
|
|
2704
|
+
// declares twice the size of its PNG, the exact defect A06 names when the
|
|
2705
|
+
// parse succeeds: with a region also removed so the round trip threw, A06
|
|
2706
|
+
// printed `PASS`. Everything below reads `atlas.pages`, so a report of "held"
|
|
2707
|
+
// here is a report about zero pages.
|
|
2623
2708
|
check('A17_ATLAS_PAGE_FILES_EXIST', () => {
|
|
2624
|
-
if (!atlas) return;
|
|
2709
|
+
if (!atlas) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS);
|
|
2625
2710
|
for (const page of atlas.pages) {
|
|
2626
2711
|
const abs = resolve(input.atlasDir, page.name);
|
|
2627
2712
|
if (!existsSync(abs)) fail('A17_ATLAS_PAGE_FILES_EXIST', `page "${page.name}" is not on disk at ${abs}`);
|
|
2628
2713
|
}
|
|
2629
2714
|
});
|
|
2630
2715
|
check('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', () => {
|
|
2631
|
-
if (!atlas) return;
|
|
2716
|
+
if (!atlas) return skip('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', SKIP_NO_ATLAS);
|
|
2632
2717
|
for (const page of atlas.pages) {
|
|
2633
2718
|
const abs = resolve(input.atlasDir, page.name);
|
|
2634
2719
|
if (!existsSync(abs)) continue; // A17 owns this
|
|
@@ -2667,8 +2752,12 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2667
2752
|
// overlapping rectangles put one drawing inside another's.
|
|
2668
2753
|
//
|
|
2669
2754
|
// ⚠️ Rotation stays refused either way, and that is not the same clause: it
|
|
2670
|
-
// is about rigc's own packer never turning a region,
|
|
2671
|
-
//
|
|
2755
|
+
// is about rigc's own packer never turning a region, which is a statement
|
|
2756
|
+
// about what rigc WRITES. It stopped being a statement about what rigc can
|
|
2757
|
+
// read in issue #570 — `extractRegion` now lifts a rotated region back off
|
|
2758
|
+
// its page as a transcription of `MeshAttachment.computeUVs` — and the two
|
|
2759
|
+
// must not be re-merged: an artifact under the renderer's own rulebook that
|
|
2760
|
+
// rigc did not pack is still a foreign artifact, whatever rigc can measure.
|
|
2672
2761
|
const regionsPerPage = new Map<string, TextureAtlasRegion[]>();
|
|
2673
2762
|
for (const region of atlas.regions) {
|
|
2674
2763
|
const on = regionsPerPage.get(region.page.name);
|
|
@@ -2745,7 +2834,11 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2745
2834
|
// that does not ask — the reader opted in, and the message is where they find
|
|
2746
2835
|
// out what they opted into.
|
|
2747
2836
|
check('A19_OVERLAY_PNGS_HAVE_ALPHA', () => {
|
|
2748
|
-
|
|
2837
|
+
// A SKIP for the same reason A06's is (#568). This one is invisible under
|
|
2838
|
+
// `spine`, where the profile excludes the rule before its body runs — and
|
|
2839
|
+
// that is exactly why it was worth finding: `--profile spine-html` reported
|
|
2840
|
+
// it, and A27 below, green on an atlas nothing had read.
|
|
2841
|
+
if (!atlas) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS);
|
|
2749
2842
|
const stageW = skeletonData?.width ?? 0;
|
|
2750
2843
|
const stageH = skeletonData?.height ?? 0;
|
|
2751
2844
|
const basePages = new Set<string>();
|
|
@@ -2941,14 +3034,24 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2941
3034
|
}
|
|
2942
3035
|
});
|
|
2943
3036
|
|
|
2944
|
-
// --- A26:
|
|
3037
|
+
// --- A26: the slots array IS the rig's slot table ------------------------
|
|
2945
3038
|
//
|
|
2946
3039
|
// The slots array IS the draw order (z-index = array index), so a formation
|
|
2947
3040
|
// whose illusion depends on one part occluding another depends on one
|
|
2948
3041
|
// adjacency in that array — and nothing in the file objects to the wrong
|
|
2949
3042
|
// order. On a still frame it can even look plausible. The rig's own slot list
|
|
2950
|
-
// is the canonical table
|
|
2951
|
-
//
|
|
3043
|
+
// is the canonical table, and this checks the emitted array against it in
|
|
3044
|
+
// both directions: nothing out of order, and nothing missing.
|
|
3045
|
+
//
|
|
3046
|
+
// ⚠️ The second half is new with issue #575 and the first half is why it had
|
|
3047
|
+
// to be. This clause used to accept any SUBSEQUENCE of the table, because the
|
|
3048
|
+
// compiler dropped a slot no skin filled and the gate was written around that
|
|
3049
|
+
// — so the defect #575 filed was licensed by the assertion that was supposed
|
|
3050
|
+
// to catch it: two production exports declaring 53 and 61 slots built green
|
|
3051
|
+
// at 51 and 57. `compile` now emits every declared slot, empty if nothing
|
|
3052
|
+
// fills it, so a shorter array is a loss and is named as one here. A slot
|
|
3053
|
+
// missing from the array moves every slot below it up one index, which is
|
|
3054
|
+
// what a `drawOrder` key's offsets are counted against.
|
|
2952
3055
|
check('A26_SLOT_DRAW_ORDER', () => {
|
|
2953
3056
|
if (!input.rig) return skip('A26_SLOT_DRAW_ORDER', 'no rig info (validating a bare directory)');
|
|
2954
3057
|
const order = input.rig.slotOrder;
|
|
@@ -2976,6 +3079,17 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2976
3079
|
}
|
|
2977
3080
|
at = found + 1;
|
|
2978
3081
|
}
|
|
3082
|
+
const emitted = new Set(names);
|
|
3083
|
+
const missing = order.filter((name) => !emitted.has(name));
|
|
3084
|
+
if (missing.length > 0) {
|
|
3085
|
+
fail(
|
|
3086
|
+
'A26_SLOT_DRAW_ORDER',
|
|
3087
|
+
`the rig "${input.rig.archetype}" declares ${order.length} slot(s) and the skeleton has ${names.length}: ` +
|
|
3088
|
+
`${missing.map((name) => `"${name}"`).join(', ')} ${missing.length === 1 ? 'is' : 'are'} declared and ` +
|
|
3089
|
+
'not emitted. A slot nothing fills is emitted with no setup attachment, not dropped — dropping one ' +
|
|
3090
|
+
'moves every slot below it up one index',
|
|
3091
|
+
);
|
|
3092
|
+
}
|
|
2979
3093
|
});
|
|
2980
3094
|
|
|
2981
3095
|
// --- A27: region name == the PNG's basename ------------------------------
|
|
@@ -2987,7 +3101,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2987
3101
|
// would load with the wrong pixels under the right name. One part per page (A06
|
|
2988
3102
|
// forces it) makes the check exact.
|
|
2989
3103
|
check('A27_REGION_NAME_MATCHES_PAGE_FILENAME', () => {
|
|
2990
|
-
if (!atlas) return;
|
|
3104
|
+
if (!atlas) return skip('A27_REGION_NAME_MATCHES_PAGE_FILENAME', SKIP_NO_ATLAS);
|
|
2991
3105
|
const perPage = new Map<string, number>();
|
|
2992
3106
|
for (const region of atlas.regions) perPage.set(region.page.name, (perPage.get(region.page.name) ?? 0) + 1);
|
|
2993
3107
|
for (const region of atlas.regions) {
|
|
@@ -3173,6 +3287,52 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3173
3287
|
}
|
|
3174
3288
|
});
|
|
3175
3289
|
|
|
3290
|
+
// --- every assertion leaves a row ----------------------------------------
|
|
3291
|
+
//
|
|
3292
|
+
// 🔒 **A missing row is the vacuous pass one level up** (issue #568). Twenty
|
|
3293
|
+
// assertions live inside `if (skeletonData) {` above, so a round trip that
|
|
3294
|
+
// throws does not skip them — it never reaches them, and they appear in none
|
|
3295
|
+
// of the four lists. Measured on a fixture whose atlas was missing a region
|
|
3296
|
+
// the skeleton names: 13 of 42 printed a verdict, 9 more printed `PROF`, and
|
|
3297
|
+
// 20 printed nothing at all. The run exits 1 and the reader is right to fix
|
|
3298
|
+
// A00 first, which is exactly why the silence survives — nobody counts the
|
|
3299
|
+
// rows on a red run. A report that names 22 of 42 and says nothing about the
|
|
3300
|
+
// rest is telling a reader that those rules are fine, in the only way a
|
|
3301
|
+
// report can: by not mentioning them.
|
|
3302
|
+
//
|
|
3303
|
+
// ⚠️ The sweep DERIVES its list rather than keeping one, because a hand-kept
|
|
3304
|
+
// roster of "assertions behind the round trip" is a second place to update
|
|
3305
|
+
// and would be wrong the first time somebody moved a `check` call. Anything
|
|
3306
|
+
// `ASSERTION_NAMES` knows that reached here with no row did not run, and what
|
|
3307
|
+
// it is honest to say about it is decided by the state:
|
|
3308
|
+
//
|
|
3309
|
+
// * the profile does not carry that kind of rule — `PROF`, the same verdict
|
|
3310
|
+
// `check()`'s own guard would have recorded had the call been reached;
|
|
3311
|
+
// * the round trip produced nothing — `SKIP`, naming that;
|
|
3312
|
+
// * anything else — a FAIL, **by name**. An assertion that vanished while
|
|
3313
|
+
// the skeleton was loaded is a defect in this file, and inventing a SKIP
|
|
3314
|
+
// for it would be the same silence one ring further out. The one state
|
|
3315
|
+
// the sweep is allowed to explain is the one it can prove.
|
|
3316
|
+
const reported = new Set<string>([
|
|
3317
|
+
...passed,
|
|
3318
|
+
...failures.map((f) => f.assertion),
|
|
3319
|
+
...skipped.map((s) => s.assertion),
|
|
3320
|
+
...profileSkipped.map((p) => p.assertion),
|
|
3321
|
+
]);
|
|
3322
|
+
for (const name of ASSERTION_NAMES) {
|
|
3323
|
+
if (reported.has(name)) continue;
|
|
3324
|
+
const kind = ASSERTION_KIND[name];
|
|
3325
|
+
if (kind !== 'validity' && !policy) profileSkipped.push({ assertion: name, kind });
|
|
3326
|
+
else if (!roundTrip) skip(name, SKIP_NO_SKELETON);
|
|
3327
|
+
else {
|
|
3328
|
+
fail(
|
|
3329
|
+
name,
|
|
3330
|
+
'the assertion left no row: its body was never reached, and the round trip that would explain that ' +
|
|
3331
|
+
'succeeded. A guard above returned before the call — find it and make it a SKIP naming what was absent',
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3176
3336
|
return { failures, passed, skipped, profileSkipped, profile, stats };
|
|
3177
3337
|
}
|
|
3178
3338
|
|
|
@@ -3273,6 +3433,24 @@ export function reportLines(report: ValidateReport): string[] {
|
|
|
3273
3433
|
for (const s of report.skipped) lines.push(` SKIP ${s.assertion}: ${s.reason}`);
|
|
3274
3434
|
for (const p of report.profileSkipped) lines.push(` PROF ${p.assertion}: ${p.kind} rule, not in profile "${report.profile}"`);
|
|
3275
3435
|
for (const f of report.failures) lines.push(` FAIL ${f.assertion}: ${f.detail}`);
|
|
3436
|
+
// How many of them MEASURED anything, which is the figure the rows above do
|
|
3437
|
+
// not hand a reader (issue #568). Counting `PASS` lines answers a different
|
|
3438
|
+
// question — before the sweep that closed #568 a run could print seven of
|
|
3439
|
+
// them over a candidate on which five rules had not executed at all.
|
|
3440
|
+
//
|
|
3441
|
+
// ⚠️ Every figure here is a count of ASSERTIONS and not of rows, which is why
|
|
3442
|
+
// the failed side is a Set: `fail()` is called once per finding, so one
|
|
3443
|
+
// assertion can print six `FAIL` lines, and a line-count would report 47 of
|
|
3444
|
+
// 42. The four buckets partition `ASSERTION_NAMES`, so the total is derived
|
|
3445
|
+
// by adding them rather than stated — a `42` written here would be the one
|
|
3446
|
+
// number in the report that no run could contradict.
|
|
3447
|
+
const failed = new Set(report.failures.map((f) => f.assertion));
|
|
3448
|
+
const measured = report.passed.length + failed.size;
|
|
3449
|
+
const total = measured + report.skipped.length + report.profileSkipped.length;
|
|
3450
|
+
lines.push(
|
|
3451
|
+
` .. ${total} assertions: ${measured} measured (${report.passed.length} passed, ${failed.size} failed), ` +
|
|
3452
|
+
`${report.skipped.length} skipped, ${report.profileSkipped.length} not in profile "${report.profile}"`,
|
|
3453
|
+
);
|
|
3276
3454
|
return lines;
|
|
3277
3455
|
}
|
|
3278
3456
|
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* The editor round trip, as a tool (issue #374).
|
|
3
3
|
*
|
|
4
4
|
* `build` → import into the Spine editor → export back to JSON → gate, `diff`,
|
|
5
|
-
* `render` and `check` the export against the build it came from
|
|
6
|
-
*
|
|
5
|
+
* `render` and `check` the export against the build it came from — the last two
|
|
6
|
+
* **once per skin the build declares** (issue #571; see `skinBlocks`). It is the
|
|
7
|
+
* one measurement that answers *"does the editor accept what rigc wrote, and does
|
|
7
8
|
* what comes back still play the same"*, and on its first run it found three
|
|
8
9
|
* emitter defects (#368 `hull`/`edges`, #369 hold curves, #370
|
|
9
10
|
* `skeleton.images`) before proving that a human edit survives the trip.
|
|
@@ -383,6 +384,123 @@ function checkFigures(reportPath: string): string[] {
|
|
|
383
384
|
);
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
/**
|
|
388
|
+
* The largest per-animation `meanMae` in one check report, or `null` when there
|
|
389
|
+
* is no report to read one from.
|
|
390
|
+
*
|
|
391
|
+
* ⚠️ `check`'s exit code is not a verdict — there is no pass mark in it, by
|
|
392
|
+
* design, any more than there is one in `diff` — so a per-skin roll-up built on
|
|
393
|
+
* exit codes alone reports every skin as fine and reports it in the column a
|
|
394
|
+
* reader looks at. This is the figure that actually moves when one skin's art
|
|
395
|
+
* comes back wrong, which is what makes the roll-up a roll-up (issue #571).
|
|
396
|
+
*/
|
|
397
|
+
function worstMeanMae(reportPath: string): number | null {
|
|
398
|
+
if (!existsSync(reportPath)) return null;
|
|
399
|
+
const report = JSON.parse(readFileSync(reportPath, 'utf8')) as { animations?: Array<{ meanMae?: number }> };
|
|
400
|
+
const values = (report.animations ?? []).map((a) => a.meanMae).filter((v): v is number => typeof v === 'number');
|
|
401
|
+
return values.length === 0 ? null : Math.max(...values);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// step 5's per-skin plan (issue #571)
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
//
|
|
408
|
+
// 🚨 Step 5 used to render and check ONCE, with no skin, which draws the default
|
|
409
|
+
// skin and nothing else. On a rig whose named skins carry the contested art that
|
|
410
|
+
// is a comparison of blank against blank: the ninth round trip read `check`
|
|
411
|
+
// 0.0000 on a rig where the construct under test lives in a named skin, and the
|
|
412
|
+
// zero was true and empty at once. So the plan below is one block PER DECLARED
|
|
413
|
+
// SKIN, and a skin whose art the editor moved is a red row with its own name on
|
|
414
|
+
// it rather than a figure nobody rendered.
|
|
415
|
+
//
|
|
416
|
+
// ⭐ Split out as pure functions for the reason `shapeOf`/`shapeDiff` are: the
|
|
417
|
+
// `ERT` suite has no editor and never will, and a loop that can only be read by
|
|
418
|
+
// driving one is a loop nobody has seen work.
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The skins a skeleton file declares, in the order the file lists them.
|
|
422
|
+
*
|
|
423
|
+
* ⚠️ Off the FILE rather than off `spine-core`: this is the build's own
|
|
424
|
+
* `skeleton.json`, read before any of it is loaded, and the tool's other summary
|
|
425
|
+
* (`shapeOf`) reads the same file the same way. A skeleton with no `skins` array
|
|
426
|
+
* at all comes back empty, which is a different fact from `["default"]` and is
|
|
427
|
+
* carried as one — see `skinBlocks`.
|
|
428
|
+
*/
|
|
429
|
+
export function skinsDeclaredBy(path: string): string[] {
|
|
430
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as { skins?: Array<{ name?: unknown }> };
|
|
431
|
+
const out: string[] = [];
|
|
432
|
+
for (const skin of parsed.skins ?? []) {
|
|
433
|
+
// A skin with no usable name is counted under one rather than dropped: a
|
|
434
|
+
// block that vanished would be a skin nobody rendered and nobody missed.
|
|
435
|
+
out.push(typeof skin?.name === 'string' && skin.name !== '' ? skin.name : '(unnamed)');
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** One render-and-check block of step 5: which skin, where its frames go. */
|
|
441
|
+
export interface SkinBlock {
|
|
442
|
+
/** The skin this block poses under — `null` when the skeleton declares none. */
|
|
443
|
+
skin: string | null;
|
|
444
|
+
/** The heading printed above the block, which is where the skin's name is read. */
|
|
445
|
+
heading: string;
|
|
446
|
+
/** `--skin <name>`, or nothing at all when there is no skin to name. */
|
|
447
|
+
args: string[];
|
|
448
|
+
buildFrames: string;
|
|
449
|
+
exportFrames: string;
|
|
450
|
+
checkJson: string;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* A directory name for one skin's frames: its index, then what of its name is a
|
|
455
|
+
* filename.
|
|
456
|
+
*
|
|
457
|
+
* The index leads because **a skin name is not a path**. The Spine editor writes
|
|
458
|
+
* folders into skin names with `/`, so `goblins/green` would land two levels
|
|
459
|
+
* down or collide with a sibling, and any scheme that replaces the offending
|
|
460
|
+
* characters can map two distinct skins onto one directory. The index cannot
|
|
461
|
+
* collide, and it is the skeleton's own ordering rather than a number invented
|
|
462
|
+
* here.
|
|
463
|
+
*/
|
|
464
|
+
function skinDirName(name: string, index: number): string {
|
|
465
|
+
return `${index}-${name.replace(/[^A-Za-z0-9._-]+/g, '_')}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Step 5's plan: one block per declared skin, or exactly one block when the
|
|
470
|
+
* skeleton declares no skin at all.
|
|
471
|
+
*
|
|
472
|
+
* ⭐ The no-skin case keeps the old paths (`render-build`, `render-export`,
|
|
473
|
+
* `check.json`) because for such a skeleton there is nothing to distinguish, and
|
|
474
|
+
* a run whose output moved would be a run whose ledgers all have to be re-read
|
|
475
|
+
* for no measurement gained. A skinned skeleton files each block under its own
|
|
476
|
+
* directory, so the frames of two skins can never overwrite each other — which
|
|
477
|
+
* is the failure that would turn "one block per skin" back into one block.
|
|
478
|
+
*/
|
|
479
|
+
export function skinBlocks(skins: string[], out: string, fps: number): SkinBlock[] {
|
|
480
|
+
if (skins.length === 0) {
|
|
481
|
+
return [
|
|
482
|
+
{
|
|
483
|
+
skin: null,
|
|
484
|
+
heading: `## 5 render both @${fps}fps, check the export against the build's own frames (no skin declared)`,
|
|
485
|
+
args: [],
|
|
486
|
+
buildFrames: join(out, 'render-build'),
|
|
487
|
+
exportFrames: join(out, 'render-export'),
|
|
488
|
+
checkJson: join(out, 'check.json'),
|
|
489
|
+
},
|
|
490
|
+
];
|
|
491
|
+
}
|
|
492
|
+
return skins.map((skin, i) => ({
|
|
493
|
+
skin,
|
|
494
|
+
heading:
|
|
495
|
+
`## 5.${i + 1}/${skins.length} skin "${skin}" — render both @${fps}fps, check the export against the ` +
|
|
496
|
+
"build's own frames",
|
|
497
|
+
args: ['--skin', skin],
|
|
498
|
+
buildFrames: join(out, 'render-build', skinDirName(skin, i)),
|
|
499
|
+
exportFrames: join(out, 'render-export', skinDirName(skin, i)),
|
|
500
|
+
checkJson: join(out, `check-${skinDirName(skin, i)}.json`),
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
|
|
386
504
|
/** The shape of a skeleton file, for the field-by-field comparison. */
|
|
387
505
|
export interface Shape {
|
|
388
506
|
spine: string;
|
|
@@ -719,18 +837,48 @@ function main(): void {
|
|
|
719
837
|
// console layout for them would break the first time that layout is tidied.
|
|
720
838
|
for (const line of movedMeasures(diffJson)) emit(` ${line}`);
|
|
721
839
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
840
|
+
// 🔒 The skins come off the BUILD, which is the side under test: a skin the
|
|
841
|
+
// export dropped altogether then reads as a block whose check fails by name,
|
|
842
|
+
// where enumerating the export's own skins would quietly stop looking for it.
|
|
843
|
+
const blocks = skinBlocks(skinsDeclaredBy(source), opts.out, opts.fps);
|
|
844
|
+
const checks: Array<{ skin: string | null; status: number | null; mae: number | null }> = [];
|
|
845
|
+
for (const block of blocks) {
|
|
846
|
+
emit('');
|
|
847
|
+
emit(block.heading);
|
|
848
|
+
run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', opts.build, '--fps', String(opts.fps), ...block.args, '--out', block.buildFrames], 900);
|
|
849
|
+
run(rigc.cmd, [...rigc.prefix, 'render', '--candidate', cand, '--fps', String(opts.fps), ...block.args, '--out', block.exportFrames], 900);
|
|
850
|
+
const check = run(
|
|
851
|
+
rigc.cmd,
|
|
852
|
+
[...rigc.prefix, 'check', '--candidate', cand, '--frames', block.buildFrames, ...block.args, '--json', block.checkJson],
|
|
853
|
+
900,
|
|
854
|
+
);
|
|
855
|
+
emit(` exit=${check.status}`);
|
|
856
|
+
for (const line of verdictLines(check.stdout)) emit(` ${line}`);
|
|
857
|
+
for (const line of checkFigures(block.checkJson)) emit(` ${line}`);
|
|
858
|
+
checks.push({ skin: block.skin, status: check.status, mae: worstMeanMae(block.checkJson) });
|
|
859
|
+
}
|
|
860
|
+
// The roll-up, so a loss in one skin of many is a line somebody reads rather
|
|
861
|
+
// than a row buried in the block above it. The mark is on the LARGEST figure
|
|
862
|
+
// and only where the skins disagree: `check` has no pass mark to compare
|
|
863
|
+
// against and this tool does not get to invent one, but "these skins did not
|
|
864
|
+
// come back the same" is a fact the run itself produced.
|
|
865
|
+
if (blocks.length > 1) {
|
|
866
|
+
const figures = checks.map((c) => c.mae).filter((v): v is number => v !== null);
|
|
867
|
+
const worst = figures.length === 0 ? null : Math.max(...figures);
|
|
868
|
+
const agreed = figures.length === checks.length && new Set(figures).size === 1;
|
|
869
|
+
emit('');
|
|
870
|
+
emit(` per skin ${checks.length} block(s)`);
|
|
871
|
+
for (const { skin, status, mae } of checks) {
|
|
872
|
+
emit(
|
|
873
|
+
` ${String(skin).padEnd(20)} check exit=${status} worst mean MAE ` +
|
|
874
|
+
`${mae === null ? '(no report)' : mae.toFixed(4)}` +
|
|
875
|
+
`${status === 0 ? '' : ' ⚠️ this skin did not come back'}` +
|
|
876
|
+
`${!agreed && mae !== null && mae === worst ? ` ⚠️ the worst of the ${checks.length} skins` : ''}`,
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
if (agreed) emit(` ⤷ every skin came back at the same figure, so no skin is carrying a difference the others are not.`);
|
|
880
|
+
}
|
|
881
|
+
const checksClean = checks.every((c) => c.status === 0);
|
|
734
882
|
|
|
735
883
|
emit('');
|
|
736
884
|
emit('## 6 what the editor rewrote');
|
|
@@ -741,15 +889,18 @@ function main(): void {
|
|
|
741
889
|
keepLog();
|
|
742
890
|
emit('');
|
|
743
891
|
emit(`log: ${logPath}`);
|
|
744
|
-
// The verdict is the gate's and
|
|
745
|
-
|
|
892
|
+
// The verdict is the gate's and EVERY skin's check, not this tool's opinion of
|
|
893
|
+
// them: one skin coming back wrong is the whole run coming back wrong, which
|
|
894
|
+
// is the half a single un-skinned check could not say.
|
|
895
|
+
process.exit(gate.status === 0 && checksClean ? 0 : 1);
|
|
746
896
|
}
|
|
747
897
|
|
|
748
|
-
// ⭐ Guarded so `shapeOf` and `
|
|
749
|
-
// editor (
|
|
750
|
-
// which is the right shape for a refusal;
|
|
751
|
-
//
|
|
752
|
-
//
|
|
898
|
+
// ⭐ Guarded so `shapeOf`, `shapeDiff`, `skinsDeclaredBy` and `skinBlocks` can be
|
|
899
|
+
// READ by a control that has no editor (issues #561, #571). Every other `ERT`
|
|
900
|
+
// case drives this file as a subprocess, which is the right shape for a refusal;
|
|
901
|
+
// step 6's summary is a pure function of two skeleton files and step 5's plan is
|
|
902
|
+
// a pure function of one, and driving an editor — or four rigc subcommands — to
|
|
903
|
+
// reach either would be paying for a round trip to test arithmetic. Run as a
|
|
753
904
|
// program this is unchanged: `bun tools/editor_roundtrip.ts …` makes this module
|
|
754
905
|
// the entry, so `import.meta.main` is true.
|
|
755
906
|
if (import.meta.main) main();
|