spine-rigc 1.7.0 → 1.9.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/cli.ts +88 -21
- package/docs/AUTHORING.md +18 -12
- package/package.json +1 -1
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a06.ts +195 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a19.ts +295 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +255 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a41.ts +83 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a47.ts +59 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/constraint_words.ts +177 -0
- package/src/assertions/facts/animated_bones.ts +28 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/constraints.ts +131 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +71 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/skin_meshes.ts +49 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +24 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/kinds.ts +102 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +30 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/constraints.ts +207 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +247 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +90 -0
- package/src/assertions/model/parse.ts +138 -0
- package/src/assertions/model/region_joins.ts +59 -0
- package/src/assertions/model/skin_entries.ts +59 -0
- package/src/assertions/model/skin_members.ts +62 -0
- package/src/assertions/model/skin_meshes.ts +90 -0
- package/src/assertions/model/slot_colour.ts +80 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/vertex_polygons.ts +71 -0
- package/src/assertions/reasons.ts +127 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/values.ts +39 -0
- package/src/check.ts +36 -9
- package/src/compile.ts +204 -57
- package/src/core/index.ts +161 -29
- package/src/core/world.ts +21 -20
- package/src/deformmeasure.ts +377 -154
- package/src/deformreport.ts +3 -0
- package/src/deformstructure.ts +344 -0
- package/src/emit_spine.ts +21 -41
- package/src/keyorder.ts +40 -0
- package/src/model.ts +188 -22
- package/src/render.ts +243 -54
- package/src/render_core.ts +101 -13
- package/src/transform.ts +79 -30
- package/src/validate.ts +742 -2781
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A08, the body (issue #1025, step 4c of #380): every region an attachment
|
|
3
|
+
* resolves through is a region the atlas has, spelled exactly.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the atlas's
|
|
6
|
+
* region names and the skin entries' lookups are a fact
|
|
7
|
+
* (`../facts/region_joins.ts`) rather than a `TextureAtlas` built here and the
|
|
8
|
+
* raw JSON's walk. Why the rule runs before the round trip, and what A00 does
|
|
9
|
+
* about a miss it names, is argued above the `check` call in `validate()`,
|
|
10
|
+
* which stays there.
|
|
11
|
+
*
|
|
12
|
+
* `pathsWithNoRegion` is filled, not read: every lookup this body refuses for
|
|
13
|
+
* want of a region, which `validate()`'s A00 reads to defer to this rule rather
|
|
14
|
+
* than restate the miss. The model side hands a set nobody reads.
|
|
15
|
+
*/
|
|
16
|
+
import type { Verdicts } from '../harness.ts';
|
|
17
|
+
import type { RegionJoinFacts } from '../facts/region_joins.ts';
|
|
18
|
+
import { SKIP_NO_ATTACHMENT_REGION_JOIN } from '../reasons.ts';
|
|
19
|
+
|
|
20
|
+
export function a08RegionNamesMatchAttachments({ fail, skip }: Verdicts, facts: RegionJoinFacts, pathsWithNoRegion: Set<string>): void {
|
|
21
|
+
if (facts.regionNames === null) {
|
|
22
|
+
return skip(
|
|
23
|
+
'A08_REGION_NAMES_MATCH_ATTACHMENTS',
|
|
24
|
+
'the atlas text does not parse, so there are no region names to join against (A00 owns that failure)',
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
const regionNames = new Set(facts.regionNames);
|
|
28
|
+
if (facts.joins === null) {
|
|
29
|
+
return skip('A08_REGION_NAMES_MATCH_ATTACHMENTS', 'the skeleton JSON did not parse (A00 owns that failure)');
|
|
30
|
+
}
|
|
31
|
+
let joined = 0;
|
|
32
|
+
for (const join of facts.joins) {
|
|
33
|
+
// A `sequence` the walk will not guess at: say nothing rather than invent
|
|
34
|
+
// a region name. A00 still has the last word on it.
|
|
35
|
+
if (join.lookups === null) continue;
|
|
36
|
+
for (const lookup of join.lookups) {
|
|
37
|
+
joined++;
|
|
38
|
+
const at = `skin "${join.skin}" slot "${join.slot}" placeholder "${join.placeholder}"`;
|
|
39
|
+
const present = regionNames.has(lookup);
|
|
40
|
+
if (!present) pathsWithNoRegion.add(lookup);
|
|
41
|
+
if (lookup !== lookup.trim()) {
|
|
42
|
+
fail(
|
|
43
|
+
'A08_REGION_NAMES_MATCH_ATTACHMENTS',
|
|
44
|
+
`${at}: attachment "${join.name}" resolves through path ${JSON.stringify(lookup)}, which has stray ` +
|
|
45
|
+
`whitespace — the atlas is matched on the exact string, and ${
|
|
46
|
+
present
|
|
47
|
+
? 'the region it finds carries the same padding'
|
|
48
|
+
: regionNames.has(lookup.trim())
|
|
49
|
+
? `the region this atlas has is ${JSON.stringify(lookup.trim())}, without it`
|
|
50
|
+
: 'no region of this atlas carries it'
|
|
51
|
+
}`,
|
|
52
|
+
);
|
|
53
|
+
} else if (!present) {
|
|
54
|
+
// Not a guess and not a repair — a region the atlas DOES hold that
|
|
55
|
+
// differs from the wanted name only by case or padding. It is
|
|
56
|
+
// reported because it was measured, and where there is none the
|
|
57
|
+
// sentence says nothing at all.
|
|
58
|
+
const near = [...regionNames].find((r) => r.trim().toLowerCase() === lookup.toLowerCase());
|
|
59
|
+
fail(
|
|
60
|
+
'A08_REGION_NAMES_MATCH_ATTACHMENTS',
|
|
61
|
+
`${at}: attachment "${join.name}" wants region "${lookup}", which this atlas does not have${
|
|
62
|
+
near === undefined ? '' : ` — it does have ${JSON.stringify(near)}`
|
|
63
|
+
}. Either the skeleton's "path" or the atlas region name is the one that moved`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const region of regionNames) {
|
|
69
|
+
if (region !== region.trim()) {
|
|
70
|
+
fail('A08_REGION_NAMES_MATCH_ATTACHMENTS', `atlas region ${JSON.stringify(region)} has stray whitespace`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (joined === 0 && regionNames.size === 0) {
|
|
74
|
+
return skip('A08_REGION_NAMES_MATCH_ATTACHMENTS', SKIP_NO_ATTACHMENT_REGION_JOIN);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A11, the body (issue #1025, step 4c of #380): no clipping attachment — the
|
|
3
|
+
* renderer skips them silently. A `renderer` rule, so `spine` never runs it.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the count is
|
|
6
|
+
* a fact (`../facts/skin_entries.ts`).
|
|
7
|
+
*/
|
|
8
|
+
import type { Verdicts } from '../harness.ts';
|
|
9
|
+
import type { SkinEntryFacts } from '../facts/skin_entries.ts';
|
|
10
|
+
|
|
11
|
+
export function a11NoClippingAttachments({ fail }: Verdicts, { clippingCount }: SkinEntryFacts): void {
|
|
12
|
+
if (clippingCount > 0) {
|
|
13
|
+
fail('A11_NO_CLIPPING_ATTACHMENTS', `${clippingCount} clipping attachment(s); the renderer skips them silently`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A13, the body (issue #1025, step 4c of #380): the mesh slots and each mesh's
|
|
3
|
+
* triangles stay within the budget the rig declares.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the meshes
|
|
6
|
+
* are a fact (`../facts/skin_meshes.ts`) rather than the lists `validate()`'s
|
|
7
|
+
* prelude filled from spine-core's loaded skins, and the rig info is the
|
|
8
|
+
* caller's. Why the two budgets come from the rig and never from here stays
|
|
9
|
+
* above the `check` call in `validate()`.
|
|
10
|
+
*/
|
|
11
|
+
import type { Verdicts } from '../harness.ts';
|
|
12
|
+
import type { SkinMeshFacts } from '../facts/skin_meshes.ts';
|
|
13
|
+
import { SKIP_NO_MESH_ATTACHMENT } from '../reasons.ts';
|
|
14
|
+
import type { RigInfo } from '../../types.ts';
|
|
15
|
+
|
|
16
|
+
export function a13MeshBudget({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts, input: { rig?: RigInfo }): void {
|
|
17
|
+
const meshAttachments = meshes;
|
|
18
|
+
const meshSlots = new Set(meshes.map((mesh) => mesh.slot));
|
|
19
|
+
const slotBudget = input.rig?.meshSlotBudget ?? null;
|
|
20
|
+
const triangleBudget = input.rig?.meshTriangleBudget ?? null;
|
|
21
|
+
if (slotBudget === null && triangleBudget === null) {
|
|
22
|
+
return skip(
|
|
23
|
+
'A13_MESH_BUDGET',
|
|
24
|
+
input.rig
|
|
25
|
+
? `the rig "${input.rig.archetype}" declares no \`invariants.meshSlots\` or \`invariants.meshTriangles\` budget`
|
|
26
|
+
: 'no rig info (validating a bare directory), so no budget is declared',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
// Two clauses and only one of them has a subject that can vanish (#580).
|
|
30
|
+
// A slot budget is a ceiling on a COUNT, and zero is a count — "this rig
|
|
31
|
+
// uses 0 of its 3 mesh slots" is a measurement — so that half holds on a
|
|
32
|
+
// skeleton with no mesh. A triangle budget is a ceiling on each mesh, so a
|
|
33
|
+
// rig that declares only that one and carries no mesh has measured
|
|
34
|
+
// nothing at all.
|
|
35
|
+
if (slotBudget === null && meshAttachments.length === 0) {
|
|
36
|
+
return skip(
|
|
37
|
+
'A13_MESH_BUDGET',
|
|
38
|
+
`the rig "${input.rig?.archetype}" budgets mesh triangles and nothing else, and ${SKIP_NO_MESH_ATTACHMENT}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (slotBudget !== null && meshSlots.size > slotBudget) {
|
|
42
|
+
fail('A13_MESH_BUDGET', `${meshSlots.size} mesh slots, the rig budgets ${slotBudget}`);
|
|
43
|
+
}
|
|
44
|
+
if (triangleBudget === null) return;
|
|
45
|
+
for (const mesh of meshAttachments) {
|
|
46
|
+
const tris = (mesh.triangles?.length ?? 0) / 3;
|
|
47
|
+
if (tris > triangleBudget) {
|
|
48
|
+
fail('A13_MESH_BUDGET', `mesh "${mesh.name}" has ${tris} triangles, the rig budgets ${triangleBudget}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A14, the body (issue #1025, step 4c of #380): no mesh spans the whole
|
|
3
|
+
* stage.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the meshes
|
|
6
|
+
* and the stage are facts (`../facts/skin_meshes.ts`, `../facts/stage.ts`)
|
|
7
|
+
* rather than spine-core's loaded skins and skeleton header. The `check` call
|
|
8
|
+
* stays in `validate()`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Verdicts } from '../harness.ts';
|
|
11
|
+
import type { SkinMeshFacts } from '../facts/skin_meshes.ts';
|
|
12
|
+
import type { StageFacts } from '../facts/stage.ts';
|
|
13
|
+
|
|
14
|
+
export function a14NoFullFrameMesh({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts, stage: StageFacts): void {
|
|
15
|
+
const meshAttachments = meshes;
|
|
16
|
+
const stageW = stage.width || 0;
|
|
17
|
+
const stageH = stage.height || 0;
|
|
18
|
+
// ⚠️ A stage-less skeleton has nothing for a mesh to span, and this rule
|
|
19
|
+
// used to report that as a PASS — the `stageW && stageH` guard below reads
|
|
20
|
+
// as a measurement of a 0x0 stage that no mesh can reach. It was only ever
|
|
21
|
+
// reachable from a foreign file until a rig spec could *declare* no stage
|
|
22
|
+
// (issue #578), and a pass certifying an unmeasured rig is the exact
|
|
23
|
+
// failure mode `A21_MESH_RIM_PINNED`'s `|| 'ring'` default was (#44).
|
|
24
|
+
if (!stageW || !stageH) {
|
|
25
|
+
return skip(
|
|
26
|
+
'A14_NO_FULL_FRAME_MESH',
|
|
27
|
+
'the skeleton declares no stage size, so there is no full frame for a mesh to span',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
for (const mesh of meshAttachments) {
|
|
31
|
+
if (mesh.width >= stageW && mesh.height >= stageH) {
|
|
32
|
+
fail('A14_NO_FULL_FRAME_MESH', `mesh "${mesh.name}" spans the whole ${stageW}x${stageH} stage`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A15, the body (issue #1025, step 4c of #380): `idle` keys no bone that
|
|
3
|
+
* drives a mesh, unless the rig declares that its idle deforms them.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the meshes
|
|
6
|
+
* and the bones that drive them are a fact (`../facts/skin_meshes.ts`) rather
|
|
7
|
+
* than spine-core's loaded skins and the weight run decoded here, the bones
|
|
8
|
+
* `idle` keys are a fact (`../facts/animated_bones.ts`) rather than the raw
|
|
9
|
+
* JSON, and the rig info is the caller's. Why the rule assumes meshes are
|
|
10
|
+
* mostly static, and why a painting rig may say otherwise, stays above the
|
|
11
|
+
* `check` call in `validate()`.
|
|
12
|
+
*/
|
|
13
|
+
import type { Verdicts } from '../harness.ts';
|
|
14
|
+
import type { MeshEntry, SkinMeshFacts } from '../facts/skin_meshes.ts';
|
|
15
|
+
import type { AnimatedBoneFacts } from '../facts/animated_bones.ts';
|
|
16
|
+
import type { RigInfo } from '../../types.ts';
|
|
17
|
+
|
|
18
|
+
export function a15IdleNoMeshBoneKeys({ fail, skip }: Verdicts, { meshes }: SkinMeshFacts, animated: AnimatedBoneFacts, input: { rig?: RigInfo }): void {
|
|
19
|
+
const A15 = 'A15_IDLE_NO_MESH_BONE_KEYS';
|
|
20
|
+
/**
|
|
21
|
+
* Every mesh attachment, loaded, with the bones that drive it — its slot's
|
|
22
|
+
* bone, and, once weights exist, every bone its weights name. A ring mesh
|
|
23
|
+
* is driven by its CONTROL bone, a different bone from the slot's, and
|
|
24
|
+
* checking only the slot bone would let `idle` key the one bone that
|
|
25
|
+
* actually dirties the canvas every frame.
|
|
26
|
+
*/
|
|
27
|
+
const meshDrivers: Array<{ mesh: MeshEntry; bones: Set<string> }> = [];
|
|
28
|
+
for (const mesh of meshes) {
|
|
29
|
+
const bones = new Set<string>([mesh.slotBone]);
|
|
30
|
+
for (const bone of mesh.weightBones) bones.add(bone);
|
|
31
|
+
meshDrivers.push({ mesh, bones });
|
|
32
|
+
}
|
|
33
|
+
const meshBoneNames = new Set<string>();
|
|
34
|
+
for (const { bones } of meshDrivers) for (const name of bones) meshBoneNames.add(name);
|
|
35
|
+
const declared = input.rig?.idleDrivesMeshes ?? null;
|
|
36
|
+
/**
|
|
37
|
+
* A declaration that switches off nothing is refused rather than skipped,
|
|
38
|
+
* the standard `consumerDrivenMix` is held to: an opt-out that exempts
|
|
39
|
+
* nothing reads exactly like one that worked, and the next reader cannot
|
|
40
|
+
* tell the rig that needs it from the rig it was copied onto.
|
|
41
|
+
*/
|
|
42
|
+
const stale = (why: string): void => {
|
|
43
|
+
fail(
|
|
44
|
+
A15,
|
|
45
|
+
`the rig "${input.rig?.archetype}" declares invariants.idleDrivesMeshes ("${declared}"), but ${why}, so the ` +
|
|
46
|
+
'declaration switches off nothing — remove it',
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
// The same shape as A06's and A17's guards, found by auditing for it
|
|
50
|
+
// (#568): a rig with no `idle` at all has nothing here to be wrong, and a
|
|
51
|
+
// rule that reports "held" over a subject that does not exist is the
|
|
52
|
+
// vacuous pass this file's own doctrine refuses. The two states get their
|
|
53
|
+
// own sentences because they are different absences — no such animation,
|
|
54
|
+
// versus one that keys no bone.
|
|
55
|
+
const idleBones = animated.bonesKeyedBy('idle');
|
|
56
|
+
if (idleBones === undefined) {
|
|
57
|
+
if (declared !== null) return stale('the skeleton declares no "idle" animation');
|
|
58
|
+
return skip(A15, 'the skeleton declares no "idle" animation, so nothing here can key a mesh-driving bone');
|
|
59
|
+
}
|
|
60
|
+
if (idleBones === null) {
|
|
61
|
+
if (declared !== null) return stale('"idle" carries no bone timeline at all');
|
|
62
|
+
return skip(A15, '"idle" carries no bone timeline at all, so there is no key to hold against the mesh-driving bones');
|
|
63
|
+
}
|
|
64
|
+
const keyed = idleBones.filter((name) => meshBoneNames.has(name));
|
|
65
|
+
if (declared !== null) {
|
|
66
|
+
if (keyed.length === 0) return stale('"idle" keys no bone that drives a mesh');
|
|
67
|
+
const keyedSet = new Set(keyed);
|
|
68
|
+
const moved = meshDrivers.filter(({ bones }) => [...bones].some((name) => keyedSet.has(name)));
|
|
69
|
+
// `worldVerticesLength` is two numbers per vertex whatever the encoding,
|
|
70
|
+
// which is the count the runtime recomputes; reading `vertices.length`
|
|
71
|
+
// would count a weighted mesh's bone entries instead.
|
|
72
|
+
const vertices = moved.reduce((sum, { mesh }) => sum + mesh.worldVerticesLength / 2, 0);
|
|
73
|
+
const SHOWN = 8;
|
|
74
|
+
const names =
|
|
75
|
+
keyed.slice(0, SHOWN).map((name) => `"${name}"`).join(', ') + (keyed.length > SHOWN ? ` +${keyed.length - SHOWN}` : '');
|
|
76
|
+
return skip(
|
|
77
|
+
A15,
|
|
78
|
+
`declared by the rig ("${declared}"): idle keys ${keyed.length} bone(s) that drive ${moved.length} mesh ` +
|
|
79
|
+
`attachment(s) totalling ${vertices} vertices — ${names} — and each of those meshes is recomputed on every ` +
|
|
80
|
+
'frame it is shown',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
for (const [i, boneName] of keyed.entries()) {
|
|
84
|
+
// The case is named once, on the first line an agent reads, rather than
|
|
85
|
+
// after every bone: the first painting rig this met printed 42 of these,
|
|
86
|
+
// and 42 identical sentences read as 42 separate mistakes. It rides the
|
|
87
|
+
// first finding rather than being a finding of its own, so the count of
|
|
88
|
+
// FAIL lines is still the count of bones.
|
|
89
|
+
const hint =
|
|
90
|
+
i > 0
|
|
91
|
+
? ''
|
|
92
|
+
: `. ${keyed.length} bone(s) keyed by idle drive meshes; if this idle is meant to deform them (a painting ` +
|
|
93
|
+
'rig), declare invariants.idleDrivesMeshes: { "why": … } in the rig spec — or, where the motion belongs to ' +
|
|
94
|
+
'a pivot above the mesh, key that pivot one link up (FACE.md §3)';
|
|
95
|
+
fail(A15, `idle keys bone "${boneName}", which drives a mesh — meshes never idle-skip${hint}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A17, the body (issue #1025, step 4c of #380): every page the atlas names is
|
|
3
|
+
* a file on disk, resolved against the directory the atlas sits in.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the pages are
|
|
6
|
+
* a fact (`../facts/atlas_pages.ts`), and the directory is the caller's. Why
|
|
7
|
+
* the guard is a SKIP and never a bare `return` (issues #568, #608) is argued
|
|
8
|
+
* above the `check` call in `validate()`, which stays there with A06 and A19.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import type { Verdicts } from '../harness.ts';
|
|
13
|
+
import type { AtlasPageFacts } from '../facts/atlas_pages.ts';
|
|
14
|
+
import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
|
|
15
|
+
|
|
16
|
+
export function a17AtlasPageFilesExist({ fail, skip }: Verdicts, { atlas }: AtlasPageFacts, input: { atlasDir: string }): void {
|
|
17
|
+
if (!atlas) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS);
|
|
18
|
+
// The other absence, one ring in (#580): the atlas LOADED and declares no
|
|
19
|
+
// page, so there is no file to find missing. `!atlas` and "zero pages" print
|
|
20
|
+
// different reasons because they are different facts about the artifact.
|
|
21
|
+
if (atlas.pages.length === 0) return skip('A17_ATLAS_PAGE_FILES_EXIST', SKIP_NO_ATLAS_PAGE);
|
|
22
|
+
for (const page of atlas.pages) {
|
|
23
|
+
const abs = resolve(input.atlasDir, page.name);
|
|
24
|
+
if (!existsSync(abs)) fail('A17_ATLAS_PAGE_FILES_EXIST', `page "${page.name}" is not on disk at ${abs}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A19, the body (issue #1025, step 4c of #380): every overlay part can draw a
|
|
3
|
+
* transparent pixel — only the base plate may be opaque.
|
|
4
|
+
*
|
|
5
|
+
* Moved out of `src/validate.ts` unchanged but for what it reads: the pages and
|
|
6
|
+
* regions are a fact (`../facts/atlas_regions.ts`), the stage is a fact
|
|
7
|
+
* (`../facts/stage.ts`), the region attachments are the skins' fact
|
|
8
|
+
* (`../facts/skin_entries.ts`), and the directory and the rig info are the
|
|
9
|
+
* caller's. Why transparency is not the same thing as an alpha channel (#215)
|
|
10
|
+
* stays above the `check` call in `validate()`.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import type { Verdicts } from '../harness.ts';
|
|
15
|
+
import type { AtlasRegionEntry, AtlasRegionFacts } from '../facts/atlas_regions.ts';
|
|
16
|
+
import type { SkinEntryFacts } from '../facts/skin_entries.ts';
|
|
17
|
+
import type { StageFacts } from '../facts/stage.ts';
|
|
18
|
+
import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_PAGE } from '../reasons.ts';
|
|
19
|
+
import { pageFootprint } from '../../atlas.ts';
|
|
20
|
+
import { colourTypeName, readPngHeader } from '../../png.ts';
|
|
21
|
+
import { readPlate } from '../../../tools/plate.ts';
|
|
22
|
+
import type { RigInfo } from '../../types.ts';
|
|
23
|
+
|
|
24
|
+
export function a19OverlayPngsHaveAlpha({ fail, skip }: Verdicts, { atlas }: AtlasRegionFacts, stage: StageFacts, { regionAttachments }: Pick<SkinEntryFacts, 'regionAttachments'>, input: { atlasDir: string; rig?: RigInfo }): void {
|
|
25
|
+
// A SKIP for the same reason A06's is (#568). This one is invisible under
|
|
26
|
+
// `spine`, where the profile excludes the rule before its body runs — and
|
|
27
|
+
// that is exactly why it was worth finding: `--profile spine-html` reported
|
|
28
|
+
// it, and A27 below, green on an atlas nothing had read.
|
|
29
|
+
if (!atlas) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS);
|
|
30
|
+
if (atlas.pages.length === 0) return skip('A19_OVERLAY_PNGS_HAVE_ALPHA', SKIP_NO_ATLAS_PAGE);
|
|
31
|
+
const stageW = stage.width ?? 0;
|
|
32
|
+
const stageH = stage.height ?? 0;
|
|
33
|
+
// 🔒 **Which image is the base plate is decided ONCE, and the rig's own
|
|
34
|
+
// statement decides it** (issue #770). A cut manifest names its base plate
|
|
35
|
+
// — the part whose window IS the crop — and that needs no stage box; the
|
|
36
|
+
// size of an attachment against the stage is read only when the build
|
|
37
|
+
// names none, because it is then the only statement there is (every build
|
|
38
|
+
// from a rig spec, and `validate <dir>`, which has no rig at all). Both
|
|
39
|
+
// routes below read the same two sets, so they cannot disagree about it.
|
|
40
|
+
//
|
|
41
|
+
// ⚠️ Before this, "at least the stage's size" was the only reading, and a
|
|
42
|
+
// stageless manifest rig came out with no base plate: the packed build was
|
|
43
|
+
// refused for its plate's opaque texels while the loose build of the same
|
|
44
|
+
// rig passed on the file's colour type. The order is a rule rather than a
|
|
45
|
+
// coincidence of the fixtures, where the two readings agree: a stage stated
|
|
46
|
+
// small enough for an overlay to "cover" is exactly where they part.
|
|
47
|
+
const named = input.rig?.basePlates ?? [];
|
|
48
|
+
const basePages = new Set<string>();
|
|
49
|
+
const baseRegions = new Set<string>();
|
|
50
|
+
const exempt = (name: string): void => {
|
|
51
|
+
const region = atlas.findRegion(name);
|
|
52
|
+
if (region) {
|
|
53
|
+
basePages.add(region.page.name);
|
|
54
|
+
baseRegions.add(region.name);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
if (named.length > 0) {
|
|
58
|
+
for (const name of named) exempt(name);
|
|
59
|
+
} else {
|
|
60
|
+
for (const att of regionAttachments) {
|
|
61
|
+
if (stageW && stageH && att.width >= stageW && att.height >= stageH) exempt(att.path || att.name);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// The escape hatch is named the way it is reachable. With a base plate the
|
|
65
|
+
// rig names, that plate; with none but a stage, the size reading; and with
|
|
66
|
+
// neither, nothing here decides which image is the plate — so the sentence
|
|
67
|
+
// says what would, rather than pointing at a door that is not there.
|
|
68
|
+
const undecided =
|
|
69
|
+
'Only a base plate may be opaque, and nothing here decides which image that is: this skeleton declares no ' +
|
|
70
|
+
'stage size to measure one against, and ';
|
|
71
|
+
const exemption =
|
|
72
|
+
named.length > 0
|
|
73
|
+
? `Only the base plate the rig names (${named.map((name) => JSON.stringify(name)).join(', ')}, the part ` +
|
|
74
|
+
"whose window is the cut manifest's crop) may be opaque."
|
|
75
|
+
: stageW && stageH
|
|
76
|
+
? `Only the one image big enough to cover the whole stage (${stageW}x${stageH}) may be opaque.`
|
|
77
|
+
: input.rig
|
|
78
|
+
? `${undecided}the build names no base plate. Either would decide it: give the rig spec a "skeleton" ` +
|
|
79
|
+
'stage the plate covers, or build from a cut manifest, whose base plate is the part whose window is ' +
|
|
80
|
+
'the crop.'
|
|
81
|
+
: `${undecided}no rig was given to name one. Either would decide it: state a "skeleton" stage the plate ` +
|
|
82
|
+
'covers, or validate with the specs it was built from (--rig, --motion and the --manifest, whose base ' +
|
|
83
|
+
'plate is the part whose window is the crop).';
|
|
84
|
+
// 🚨 Counted per page for the unpacked convention and per REGION on a shared
|
|
85
|
+
// page, and the split is not a convenience (issue #266, follow-up 2). A
|
|
86
|
+
// packed page's own file all but always declares transparency — the gutter
|
|
87
|
+
// and whatever is left over of the page are transparent — so the file-level
|
|
88
|
+
// question is answered "yes" by the packing itself, whatever the parts on it
|
|
89
|
+
// look like. Asking it that way once packs became gateable under this profile
|
|
90
|
+
// would have turned this assertion into a pass that measures nothing, which
|
|
91
|
+
// is the failure mode this file exists to prevent. So a shared page is opened
|
|
92
|
+
// and each region's own rectangle is measured instead.
|
|
93
|
+
const sharedPages = new Map<string, AtlasRegionEntry[]>();
|
|
94
|
+
for (const region of atlas.regions) {
|
|
95
|
+
const on = sharedPages.get(region.page.name);
|
|
96
|
+
if (on) on.push(region);
|
|
97
|
+
else sharedPages.set(region.page.name, [region]);
|
|
98
|
+
}
|
|
99
|
+
for (const page of atlas.pages) {
|
|
100
|
+
const abs = resolve(input.atlasDir, page.name);
|
|
101
|
+
if (!existsSync(abs)) continue;
|
|
102
|
+
const on = sharedPages.get(page.name) ?? [];
|
|
103
|
+
// 🔒 **A page that is not a PNG is a non-measurement for every part on
|
|
104
|
+
// it** (issue #732), stated the way #705's and #715's are: a FAIL naming
|
|
105
|
+
// what was not read, because `skip()` is per assertion and would delete
|
|
106
|
+
// the verdicts on every other page of the same report. The file's
|
|
107
|
+
// identity is `A06`'s to judge, and this sentence points there. A page
|
|
108
|
+
// holding nothing but the full-stage base plate has no part to judge and
|
|
109
|
+
// says nothing, exactly as a readable one would.
|
|
110
|
+
const header = readPngHeader(abs);
|
|
111
|
+
if (header.problem !== null) {
|
|
112
|
+
const shared = on.length > 1;
|
|
113
|
+
const parts = shared ? on.filter((region) => !baseRegions.has(region.name)).map((region) => region.name) : [];
|
|
114
|
+
if (shared ? parts.length === 0 : basePages.has(page.name)) continue;
|
|
115
|
+
const subject = shared
|
|
116
|
+
? `the ${parts.length} part(s) on shared page "${page.name}" (${parts.map((name) => JSON.stringify(name)).join(', ')}) are`
|
|
117
|
+
: `part image "${page.name}" is`;
|
|
118
|
+
fail(
|
|
119
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
120
|
+
`${subject} not measured: this rule reads a page's alpha out of its PNG, and the file cannot be read as ` +
|
|
121
|
+
'one, so it states nothing about whether any of them can draw a transparent pixel. What the file is ' +
|
|
122
|
+
"belongs to A06_ATLAS_PAGE_SIZE_MATCHES_PNG, which names it. This is renderer policy, and it belongs to " +
|
|
123
|
+
'--profile spine-html: the default --profile spine does not run this check.',
|
|
124
|
+
);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (on.length > 1) {
|
|
128
|
+
// 🚨 A rotated region is refused by A06 under this profile, so this
|
|
129
|
+
// reading was assumed to be cosmetic — a rectangle printed beside a
|
|
130
|
+
// failure already standing. It is not: the loop below OPENS the
|
|
131
|
+
// rectangle and stops at the first transparent texel, so a rectangle
|
|
132
|
+
// wider than the drawing runs into the transparent gutter, finds its
|
|
133
|
+
// texel there and names nothing. With the transpose applied at 90 only,
|
|
134
|
+
// two fully opaque parts on one page were measured green at
|
|
135
|
+
// `rotate: 270` and red at 0, 90 and 180 — this assertion's own verdict,
|
|
136
|
+
// flipped by the rotation it does not judge (issue #579). The footprint
|
|
137
|
+
// is `pageFootprint`'s, which every other reader of it now calls.
|
|
138
|
+
// 🚨 **The scan counts what it READ, and zero texels read is not a
|
|
139
|
+
// verdict** (issue #705). The `continue` above walks past every
|
|
140
|
+
// coordinate that is not on the page, so a rectangle none of whose
|
|
141
|
+
// texels are on it came out of this loop with `transparent` still
|
|
142
|
+
// false — indistinguishable from a solid drawing — and the sentence
|
|
143
|
+
// below then stated opacity over texels nobody had opened. Measured on
|
|
144
|
+
// a pack shaped like #707's: `part "block" is opaque in every one of
|
|
145
|
+
// its 12x8 texels`, over **0 of 96**, on art carrying 36 clear texels
|
|
146
|
+
// where it was packed. That is the message-as-UI defect in one line —
|
|
147
|
+
// the reader is sent to re-export a part whose alpha was never the
|
|
148
|
+
// problem, and the rectangle that is the problem belongs to A06.
|
|
149
|
+
//
|
|
150
|
+
// ⚠️ It is a FAIL rather than a SKIP, and the report's own shape
|
|
151
|
+
// decides that rather than taste. `skip()` is per ASSERTION, so
|
|
152
|
+
// skipping here would delete the verdicts on every other part of the
|
|
153
|
+
// page — on that same pack the second part is genuinely opaque and is
|
|
154
|
+
// named — and adding a skip BESIDE those failures puts A19 in two of
|
|
155
|
+
// the four buckets `reportLines` adds up, which prints `45 assertions`
|
|
156
|
+
// where the registry holds 44. What is left is a failure that says
|
|
157
|
+
// what was not measured, which is also what "green means measured"
|
|
158
|
+
// requires: a part this rule could not read must not be certified by
|
|
159
|
+
// it.
|
|
160
|
+
const plate = readPlate(abs);
|
|
161
|
+
// 🚨 **The scan's coordinates are the atlas's, so a file that is not the
|
|
162
|
+
// declared grid is a non-measurement for every region on it** (issue
|
|
163
|
+
// #715), and #705's clause above does not cover it. That one fires when
|
|
164
|
+
// a rectangle has NO texel on the page; a page whose image is a rescale
|
|
165
|
+
// of the declared size leaves most rectangles partly on it, at
|
|
166
|
+
// coordinates that address a different part of the picture. Measured on
|
|
167
|
+
// a two-region pack at a uniform 0.5: the opaque part's failure
|
|
168
|
+
// DISAPPEARED — the scan found a transparent texel 32 texels away from
|
|
169
|
+
// it and returned — while the other printed #705's sentence over 0 of
|
|
170
|
+
// 256 texels. A verdict and a silence, both about texels nobody located.
|
|
171
|
+
//
|
|
172
|
+
// A FAIL for #705's reason, word for word: `skip()` is per assertion and
|
|
173
|
+
// would delete the verdicts on every other page in the same report.
|
|
174
|
+
if (plate.width !== page.width || plate.height !== page.height) {
|
|
175
|
+
for (const region of on) {
|
|
176
|
+
if (baseRegions.has(region.name)) continue;
|
|
177
|
+
const { width, height } = pageFootprint(region);
|
|
178
|
+
fail(
|
|
179
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
180
|
+
`part "${region.name}" is not measured: this rule opens the page at the coordinates the atlas ` +
|
|
181
|
+
`states, and page "${page.name}" declares ${page.width}x${page.height} over a ` +
|
|
182
|
+
`${plate.width}x${plate.height} image, so the ${width}x${height} rectangle at ${region.x},` +
|
|
183
|
+
`${region.y} is not where "${region.name}"'s texels are on this file and this rule states ` +
|
|
184
|
+
'nothing about whether it can draw a transparent pixel. The page grid is ' +
|
|
185
|
+
"A06_ATLAS_PAGE_SIZE_MATCHES_PNG's to judge, and it names the ratio and how to declare the page " +
|
|
186
|
+
'honestly. This is renderer policy, and it belongs to --profile spine-html: the default ' +
|
|
187
|
+
'--profile spine does not run this check.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
for (const region of on) {
|
|
193
|
+
if (baseRegions.has(region.name)) continue;
|
|
194
|
+
const { width, height } = pageFootprint(region);
|
|
195
|
+
const declared = width * height;
|
|
196
|
+
let read = 0;
|
|
197
|
+
let transparent = false;
|
|
198
|
+
for (let y = region.y; y < region.y + height && !transparent; y++) {
|
|
199
|
+
for (let x = region.x; x < region.x + width; x++) {
|
|
200
|
+
if (x < 0 || y < 0 || x >= plate.width || y >= plate.height) continue;
|
|
201
|
+
read++;
|
|
202
|
+
if (plate.get(x, y)[3] < 255) {
|
|
203
|
+
transparent = true;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (transparent) continue;
|
|
209
|
+
// The page's size here is the DECODED image's and not the `size:`
|
|
210
|
+
// line's, because it is the bound this scan actually clipped
|
|
211
|
+
// against; where the two disagree A06 says so in its own sentence.
|
|
212
|
+
if (read === 0) {
|
|
213
|
+
fail(
|
|
214
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
215
|
+
`part "${region.name}" is not measured: this rule read 0 of the ${declared} texels of its ` +
|
|
216
|
+
`${width}x${height} rectangle at ${region.x},${region.y} on page "${page.name}", whose image is ` +
|
|
217
|
+
`${plate.width}x${plate.height}, so it states nothing about whether "${region.name}" can draw a ` +
|
|
218
|
+
"transparent pixel. A region's rectangle is A06_ATLAS_PAGE_SIZE_MATCHES_PNG's to judge, and one " +
|
|
219
|
+
'that runs off its page is refused there by name. This is renderer policy, and it belongs to ' +
|
|
220
|
+
'--profile spine-html: the default --profile spine does not run this check.',
|
|
221
|
+
);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// A rectangle partly on the page states the verdict over the texels
|
|
225
|
+
// it read and says how many of the declared ones that was. A whole
|
|
226
|
+
// rectangle prints the sentence it has always printed, to the byte.
|
|
227
|
+
const over =
|
|
228
|
+
read === declared
|
|
229
|
+
? `every one of its ${width}x${height} texels on shared page "${page.name}"`
|
|
230
|
+
: `every one of the ${read} texels of its ${width}x${height} rectangle at ${region.x},${region.y} ` +
|
|
231
|
+
`that are on shared page "${page.name}", whose image is ${plate.width}x${plate.height} — the ` +
|
|
232
|
+
`other ${declared - read} of the ${declared} it declares are not on the page and are not ` +
|
|
233
|
+
'measured here';
|
|
234
|
+
fail(
|
|
235
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
236
|
+
`part "${region.name}" is opaque in ${over}, so it would paint a solid rectangle over whatever is ` +
|
|
237
|
+
`drawn behind it. Re-export the part with transparency and pack again. ${exemption} This is ` +
|
|
238
|
+
'renderer policy, and it belongs to --profile spine-html: the default --profile spine does not run ' +
|
|
239
|
+
'this check.',
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const info = header.info;
|
|
245
|
+
if (basePages.has(page.name)) continue; // the base plate: opaque is correct
|
|
246
|
+
// The header is the FAST NEGATIVE and only that (#215's rule, unchanged):
|
|
247
|
+
// a colour type 0, 2 or 3 file with no tRNS chunk has nowhere to keep a
|
|
248
|
+
// transparent texel, so it is refused without opening it.
|
|
249
|
+
if (!info.hasTransparency) {
|
|
250
|
+
fail(
|
|
251
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
252
|
+
`part image "${page.name}" cannot be transparent anywhere: it is colour type ${info.colourType} ` +
|
|
253
|
+
`(${colourTypeName(info.colourType)}) with no tRNS chunk, so it would paint a solid rectangle over ` +
|
|
254
|
+
'whatever is drawn behind it. Re-export it with transparency — as RGBA, or as an indexed or greyscale ' +
|
|
255
|
+
`PNG that keeps its tRNS chunk. ${exemption} This is renderer policy, and it belongs to --profile ` +
|
|
256
|
+
'spine-html: the default --profile spine does not run this check.',
|
|
257
|
+
);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
// 🚨 **A header that says "could be transparent" is not an answer, and
|
|
261
|
+
// the texels decide it exactly as they do on a shared page** (issue
|
|
262
|
+
// #777). Until this the loose route stopped here, so a part saved as
|
|
263
|
+
// RGBA passed whether or not any texel used the channel: on the
|
|
264
|
+
// articulated fixture, stageless, an overlay rewritten as colour type 6
|
|
265
|
+
// with 0 of its 16,000 texels below full alpha PASSED loose and was
|
|
266
|
+
// refused by `--pack` of the same rig, over the same texels. The runtime
|
|
267
|
+
// draws those texels, not the file's declaration, so the loose verdict
|
|
268
|
+
// was the false green. The rectangle is the whole decoded image — on a
|
|
269
|
+
// loose page the file IS the part, so no atlas coordinate is read and
|
|
270
|
+
// #705's and #715's non-measurements cannot arise here — and the scan
|
|
271
|
+
// stops at the first clear texel, as the shared page's does.
|
|
272
|
+
const plate = readPlate(abs);
|
|
273
|
+
let transparent = false;
|
|
274
|
+
for (let y = 0; y < plate.height && !transparent; y++) {
|
|
275
|
+
for (let x = 0; x < plate.width; x++) {
|
|
276
|
+
if (plate.get(x, y)[3] < 255) {
|
|
277
|
+
transparent = true;
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (transparent) continue;
|
|
283
|
+
const holds = info.hasAlpha
|
|
284
|
+
? `colour type ${info.colourType} (${colourTypeName(info.colourType)}) carries an alpha channel`
|
|
285
|
+
: `colour type ${info.colourType} (${colourTypeName(info.colourType)}) carries a tRNS chunk`;
|
|
286
|
+
fail(
|
|
287
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
288
|
+
`part image "${page.name}" is opaque in every one of its ${plate.width}x${plate.height} texels, so it ` +
|
|
289
|
+
`would paint a solid rectangle over whatever is drawn behind it: its file can hold transparency — ${holds} ` +
|
|
290
|
+
'— and no texel uses it. Re-export the part with the transparency it is meant to have. ' +
|
|
291
|
+
`${exemption} This is renderer policy, and it belongs to --profile spine-html: the default --profile ` +
|
|
292
|
+
'spine does not run this check.',
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|