spine-rigc 0.28.0 → 0.30.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 +4 -4
- package/cli.ts +10 -0
- package/docs/AUTHORING.md +290 -105
- package/docs/INGEST.md +15 -2
- package/docs/SPEC_COVERAGE.md +5 -4
- package/package.json +1 -1
- package/src/compile.ts +480 -308
- package/src/ingest.ts +248 -22
- package/src/png.ts +162 -7
- package/src/rig.ts +32 -21
- package/src/timelines.ts +101 -13
- package/src/types.ts +13 -7
- package/src/validate.ts +541 -28
- package/tools/editor_roundtrip.ts +1 -1
- package/tools/plate.ts +6 -0
package/src/validate.ts
CHANGED
|
@@ -65,13 +65,14 @@ import {
|
|
|
65
65
|
TOPLEVEL_CONSTRAINT_ARRAYS,
|
|
66
66
|
type SpineGeneration,
|
|
67
67
|
} from './generation.ts';
|
|
68
|
-
import { colourTypeName,
|
|
68
|
+
import { colourTypeName, readPngHeader } from './png.ts';
|
|
69
69
|
import {
|
|
70
70
|
CHANNELS_BY_KIND,
|
|
71
71
|
KEY_TIME_EPSILON,
|
|
72
72
|
PHYSICS_POSE_RULES,
|
|
73
73
|
physicsKeyRefusal,
|
|
74
74
|
physicsRuleFor,
|
|
75
|
+
SLOT_COLOR_CHANNELS,
|
|
75
76
|
walkTimelines,
|
|
76
77
|
} from './timelines.ts';
|
|
77
78
|
import type { RigInfo } from './types.ts';
|
|
@@ -195,6 +196,7 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
|
|
|
195
196
|
A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER: 'validity',
|
|
196
197
|
A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
|
|
197
198
|
A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
|
|
199
|
+
A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
|
|
198
200
|
};
|
|
199
201
|
|
|
200
202
|
/**
|
|
@@ -297,7 +299,9 @@ export const SKIP_NO_ATLAS_REGION = 'the atlas declares no region';
|
|
|
297
299
|
export const SKIP_NO_ATTACHMENT_REGION_JOIN =
|
|
298
300
|
'no attachment names a region and the atlas declares none, so there is no attachment-to-region join to hold';
|
|
299
301
|
export const SKIP_NO_TWO_COLOR_TINT =
|
|
300
|
-
'no slot declares a "dark" colour and no animation keys an "rgba2" timeline, so there is no two-colour tint to read back';
|
|
302
|
+
'no slot declares a "dark" colour and no animation keys an "rgba2" or "rgb2" timeline, so there is no two-colour tint to read back';
|
|
303
|
+
export const SKIP_NO_SEPARABLE_COLOR =
|
|
304
|
+
'no animation keys an "rgb" or "alpha" timeline, so there is no separable slot colour to read back';
|
|
301
305
|
export const SKIP_NO_LINKED_MESH = 'no attachment in this skeleton takes its geometry from another one';
|
|
302
306
|
/**
|
|
303
307
|
* A09's, which predates this list and joins it rather than being rewritten: it
|
|
@@ -464,9 +468,24 @@ const EDITOR_PHYSICS_COMPONENTS: ReadonlySet<(typeof PHYSICS_COMPONENTS)[number]
|
|
|
464
468
|
* is worth nothing to a compiler refusing a key. `PHYSICS_POSE_RULES` is the
|
|
465
469
|
* index, so a rule added there with no sentence here fails to type-check rather
|
|
466
470
|
* than printing `undefined`.
|
|
471
|
+
*
|
|
472
|
+
* ⚠️ These are the SETUP pose's sentences and nothing else, which matters on the
|
|
473
|
+
* two rows whose keyed bound is wider than their resting one — `mix` since issue
|
|
474
|
+
* #610 and `strength` since #727. A key of 0 on either is accepted, so "it is
|
|
475
|
+
* muted" and "nothing pulls it back" are read here by a rig that states the
|
|
476
|
+
* number at rest, and what a key is refused with is the row's own `why`.
|
|
477
|
+
*
|
|
478
|
+
* `animations` is how many animations the skeleton declares, and only the `mix`
|
|
479
|
+
* sentence reads it: that bound is the one a key can satisfy instead of the
|
|
480
|
+
* setup pose (`inertAtSetup`, issue #743), so the refusal has to say that the
|
|
481
|
+
* other half was looked for and how wide the search was. A rig with no animation
|
|
482
|
+
* at all then says "none of the 0 animations" rather than implying somebody
|
|
483
|
+
* keyed something.
|
|
467
484
|
*/
|
|
468
|
-
const SETUP_POSE_SAYS: Record<string, (pose: PhysicsConstraintPose) => string> = {
|
|
469
|
-
mix: (pose) =>
|
|
485
|
+
const SETUP_POSE_SAYS: Record<string, (pose: PhysicsConstraintPose, animations: number) => string> = {
|
|
486
|
+
mix: (pose, animations) =>
|
|
487
|
+
`has mix ${pose.mix} and none of the ${animations} animation${animations === 1 ? '' : 's'} keys its mix above 0; ` +
|
|
488
|
+
'it is muted — rest it above 0, or key its mix above 0 in an animation',
|
|
470
489
|
mass: (pose) => `has massInverse ${pose.massInverse} (mass must be > 0)`,
|
|
471
490
|
strength: (pose) => `has strength ${pose.strength}; nothing pulls it back`,
|
|
472
491
|
damping: (pose) => `has damping ${pose.damping}; outside (0,1) it never settles`,
|
|
@@ -941,6 +960,127 @@ export function timelineAddBehaviour(data: ReturnType<SkeletonJson['readSkeleton
|
|
|
941
960
|
return wrote ? 'overwrites' : 'inert';
|
|
942
961
|
}
|
|
943
962
|
|
|
963
|
+
/**
|
|
964
|
+
* The one page rectangle every region on a page shares: the size the atlas
|
|
965
|
+
* declares for it against the size of the file it names.
|
|
966
|
+
*
|
|
967
|
+
* ⭐ **What a size that disagrees with the file is and is not**, measured rather
|
|
968
|
+
* than assumed (issue #715). `TextureAtlas` computes every region's UVs as a
|
|
969
|
+
* fraction of the DECLARED size — `region.u = region.x / page.width`, spine-core
|
|
970
|
+
* 4.3.13 `dist/TextureAtlas.js:162-171` — `MeshAttachment.computeUVs` takes its
|
|
971
|
+
* `textureWidth` from the same field (`dist/attachments/MeshAttachment.js:125`),
|
|
972
|
+
* `RegionAttachment.computeUVs` reads nothing but `u/v/u2/v2`
|
|
973
|
+
* (`dist/attachments/RegionAttachment.js:152-167`), and `TextureAtlasPage.setTexture`
|
|
974
|
+
* never writes `width`/`height`. So **nothing in the region mapping reads the
|
|
975
|
+
* texture's own size**, and a page whose PNG is the declared page RESCALED is
|
|
976
|
+
* addressed at the same fraction of the picture whatever size the file is:
|
|
977
|
+
* measured on a coordinate-ramp page where every texel names its own position,
|
|
978
|
+
* rigc's own rasteriser drew 116,480 pixels in both and 0 in exactly one at a
|
|
979
|
+
* uniform 0.5.
|
|
980
|
+
*
|
|
981
|
+
* ⇒ That is why this is a clause about **texel** readers rather than about
|
|
982
|
+
* drawing, and why it is nevertheless not renderer policy. Three readers address
|
|
983
|
+
* the page at the coordinates the atlas states, and two of them are rigc's own:
|
|
984
|
+
* `A19`'s alpha scan below, the region lift `partPlate` traces a mesh generator
|
|
985
|
+
* over in [`src/compile.ts`](compile.ts), and `spine-html`'s region tier, which
|
|
986
|
+
* cuts each part with `drawImage(image, x, y, w, h, …)` and says in its own
|
|
987
|
+
* comment that it tests against the image rather than the `size:` line. Measured
|
|
988
|
+
* on the same page: the lift returned 768 of 768 texels from somewhere else.
|
|
989
|
+
*
|
|
990
|
+
* 🔑 And the format already states coarser texels honestly — `scale:`, which
|
|
991
|
+
* rigc reads (`AtlasPage.scale`) and `--atlas-in` divides by. The same art
|
|
992
|
+
* declared that way builds green and renders identically, so this refusal names
|
|
993
|
+
* a repair the format provides rather than one rigc invented.
|
|
994
|
+
*/
|
|
995
|
+
interface PageGridReading {
|
|
996
|
+
/** file width / declared width, and the same for height. Both 1 when they agree. */
|
|
997
|
+
readonly x: number;
|
|
998
|
+
readonly y: number;
|
|
999
|
+
/** One ratio for both axes — the only relation a `scale:` line can state. */
|
|
1000
|
+
readonly uniform: boolean;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** `null` when the page declares no positive size for the ratios to divide by. */
|
|
1004
|
+
function pageGridReading(
|
|
1005
|
+
declared: { width: number; height: number },
|
|
1006
|
+
file: { width: number; height: number },
|
|
1007
|
+
): PageGridReading | null {
|
|
1008
|
+
if (declared.width <= 0 || declared.height <= 0) return null;
|
|
1009
|
+
return {
|
|
1010
|
+
x: file.width / declared.width,
|
|
1011
|
+
y: file.height / declared.height,
|
|
1012
|
+
// Cross-multiplied rather than compared as two divisions: the question is
|
|
1013
|
+
// whether one rational number describes both axes, and two floats that
|
|
1014
|
+
// round to the same digits are not that.
|
|
1015
|
+
uniform: file.width * declared.height === file.height * declared.width,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/** A ratio, printed the one way every message here prints one. */
|
|
1020
|
+
function gridRatio(n: number): string {
|
|
1021
|
+
return n.toFixed(4);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* The first number on this page that a `scale:` re-declaration could not carry,
|
|
1026
|
+
* or `null` when every one of them lands on a whole texel of the file.
|
|
1027
|
+
*
|
|
1028
|
+
* Every number in a region block is in the page's own texel units — `bounds` and
|
|
1029
|
+
* `offsets` alike — so re-declaring the page at the file's size means scaling all
|
|
1030
|
+
* eight by the same ratio, and a region that then lands between texels is one no
|
|
1031
|
+
* reader could cut out. Stated as integer arithmetic (`n * file % declared`)
|
|
1032
|
+
* rather than as a float test, so the answer does not depend on how the ratio
|
|
1033
|
+
* rounded. ⚠️ One ratio for both axes, so this is the UNIFORM case's question
|
|
1034
|
+
* and is only ever asked there — a page whose two axes differ has no `scale:`
|
|
1035
|
+
* line to be re-declared with at all.
|
|
1036
|
+
*/
|
|
1037
|
+
function firstNumberOffTheCoarseGrid(
|
|
1038
|
+
regions: ReadonlyArray<{
|
|
1039
|
+
name: string;
|
|
1040
|
+
x: number;
|
|
1041
|
+
y: number;
|
|
1042
|
+
width: number;
|
|
1043
|
+
height: number;
|
|
1044
|
+
offsetX: number;
|
|
1045
|
+
offsetY: number;
|
|
1046
|
+
originalWidth: number;
|
|
1047
|
+
originalHeight: number;
|
|
1048
|
+
}>,
|
|
1049
|
+
declared: number,
|
|
1050
|
+
file: number,
|
|
1051
|
+
): string | null {
|
|
1052
|
+
for (const region of regions) {
|
|
1053
|
+
const numbers: Array<[string, number]> = [
|
|
1054
|
+
['bounds x', region.x],
|
|
1055
|
+
['bounds y', region.y],
|
|
1056
|
+
['bounds width', region.width],
|
|
1057
|
+
['bounds height', region.height],
|
|
1058
|
+
['offsets offsetX', region.offsetX],
|
|
1059
|
+
['offsets offsetY', region.offsetY],
|
|
1060
|
+
['offsets originalWidth', region.originalWidth],
|
|
1061
|
+
['offsets originalHeight', region.originalHeight],
|
|
1062
|
+
];
|
|
1063
|
+
for (const [field, value] of numbers) {
|
|
1064
|
+
if ((value * file) % declared === 0) continue;
|
|
1065
|
+
return (
|
|
1066
|
+
`region ${JSON.stringify(region.name.trim())}'s \`${field}\` of ${value} becomes ` +
|
|
1067
|
+
`${((value * file) / declared).toFixed(4)} on the file's own grid, which is not a whole texel`
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** What every size-mismatch sentence says before it says what to do about it. */
|
|
1075
|
+
const PAGE_GRID_PREAMBLE =
|
|
1076
|
+
'A runtime does not read the file\'s own size anywhere in the region mapping — `TextureAtlas` computes every ' +
|
|
1077
|
+
"region's UVs as a fraction of the DECLARED size (`region.u = region.x / page.width`, spine-core " +
|
|
1078
|
+
'`dist/TextureAtlas.js:162-171`) — so a page whose PNG is the declared page RESCALED draws the same picture at ' +
|
|
1079
|
+
"the file's resolution, and one whose PNG is anything else draws whatever sits at those fractions. What a " +
|
|
1080
|
+
'declared size that disagrees with the file breaks is every reader that addresses the page in TEXELS: this ' +
|
|
1081
|
+
"validator's own `A19` alpha scan, the region lift rigc's mesh generators trace, and a canvas renderer that " +
|
|
1082
|
+
'cuts each part out of the page by source rectangle.';
|
|
1083
|
+
|
|
944
1084
|
export function validate(input: ValidateInput): ValidateReport {
|
|
945
1085
|
const failures: Failure[] = [];
|
|
946
1086
|
const passed: string[] = [];
|
|
@@ -2873,6 +3013,63 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2873
3013
|
}
|
|
2874
3014
|
}
|
|
2875
3015
|
}
|
|
3016
|
+
// --- which constraints an animation switches ON (issue #743) ----------
|
|
3017
|
+
//
|
|
3018
|
+
// 🔑 The setup pose is the rig AT REST, and one of the four bounds is a
|
|
3019
|
+
// state rather than a break there: `PHYSICS_POSE_RULES` marks `mix`
|
|
3020
|
+
// `inertAtSetup`, because `update` opens with `if (mix === 0) return;`
|
|
3021
|
+
// (`PhysicsConstraint.js:109-111`) and nothing else in the pose has such a
|
|
3022
|
+
// branch. So a constraint that rests muted and is keyed above 0 by an
|
|
3023
|
+
// animation is a rig the runtime plays as authored, and refusing it would
|
|
3024
|
+
// refuse a design: physics off at rest, switched on by the animation that
|
|
3025
|
+
// needs it.
|
|
3026
|
+
//
|
|
3027
|
+
// 📏 Measured on a generated physics fixture, 36 steps at 60 fps with the
|
|
3028
|
+
// constraint's own bone swung by its parent: resting at `mix` 0 with an
|
|
3029
|
+
// animation keying `mix` to 1 poses the bone IDENTICALLY to the same rig
|
|
3030
|
+
// resting at 1 (max |dx| 0.000000) and up to 7.771177 away from the twin
|
|
3031
|
+
// that keys nothing — which poses identically to one keyed to 0 only
|
|
3032
|
+
// (max |dx| 0.000000). Two states, and the file says which.
|
|
3033
|
+
//
|
|
3034
|
+
// ⚠️ The escape is the rule's own field rather than the word "mix": a
|
|
3035
|
+
// setup `mass` of 0 is `massInverse` Infinity BEFORE anything plays, and
|
|
3036
|
+
// [measured] at rest it reads NaN on every frame although an animation
|
|
3037
|
+
// keys `mass` to 1. A key cannot rescue a value that has already broken
|
|
3038
|
+
// the rig it is resting in.
|
|
3039
|
+
const unmuted = new Map<PhysicsConstraintData, Set<string>>();
|
|
3040
|
+
const raised = new PhysicsConstraintPose();
|
|
3041
|
+
for (const animation of data.animations) {
|
|
3042
|
+
for (const timeline of animation.timelines) {
|
|
3043
|
+
if (!(timeline instanceof PhysicsConstraintTimeline)) continue;
|
|
3044
|
+
const rule = physicsRuleFor(PHYSICS_TIMELINE_NAMES[Number(timeline.getPropertyIds()[0].split('|')[0])] ?? '');
|
|
3045
|
+
if (rule === undefined || !rule.inertAtSetup) continue;
|
|
3046
|
+
const entries = timeline.getFrameEntries();
|
|
3047
|
+
let keysInside = false;
|
|
3048
|
+
for (let i = 0; i < timeline.frames.length && !keysInside; i += entries) {
|
|
3049
|
+
// The runtime's own accessor, for the reason the second arm uses it:
|
|
3050
|
+
// the pose field is where the integrator reads the number, and for
|
|
3051
|
+
// `mass` that is not the number the key states.
|
|
3052
|
+
timeline.set(raised, timeline.frames[i + 1]);
|
|
3053
|
+
keysInside = rule.poseOk(raised[rule.field]);
|
|
3054
|
+
}
|
|
3055
|
+
if (!keysInside) continue;
|
|
3056
|
+
// -1 is the global form: the timeline names no constraint and the
|
|
3057
|
+
// runtime applies it to every physics constraint whose own data
|
|
3058
|
+
// declares that property global (`Animation.js:2067-2075`). No rig
|
|
3059
|
+
// spec can reach it — the compiler refuses a track naming a constraint
|
|
3060
|
+
// the skeleton has not got, the empty name among them — but a file
|
|
3061
|
+
// rigc did not write can carry one, and `timeline.global` is the
|
|
3062
|
+
// runtime's own answer to which constraints it reaches.
|
|
3063
|
+
for (const one of data.constraints) {
|
|
3064
|
+
if (!(one instanceof PhysicsConstraintData)) continue;
|
|
3065
|
+
if (timeline.constraintIndex === -1 ? !timeline.global(one) : data.constraints[timeline.constraintIndex] !== one) continue;
|
|
3066
|
+
const by = unmuted.get(one) ?? new Set<string>();
|
|
3067
|
+
by.add(rule.timeline);
|
|
3068
|
+
unmuted.set(one, by);
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
let mutedUntilKeyed = 0;
|
|
2876
3073
|
for (const constraint of data.constraints) {
|
|
2877
3074
|
if (!(constraint instanceof PhysicsConstraintData)) continue;
|
|
2878
3075
|
const where = `physics "${constraint.name}"`;
|
|
@@ -2888,10 +3085,14 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2888
3085
|
// sentence.
|
|
2889
3086
|
for (const rule of PHYSICS_POSE_RULES) {
|
|
2890
3087
|
if (rule.poseOk(pose[rule.field])) continue;
|
|
3088
|
+
if (rule.inertAtSetup && unmuted.get(constraint)?.has(rule.timeline)) {
|
|
3089
|
+
mutedUntilKeyed++;
|
|
3090
|
+
continue;
|
|
3091
|
+
}
|
|
2891
3092
|
const drivesAMesh = rule.timeline === 'damping' && meshBoneNames.has(constraint.bone.name);
|
|
2892
3093
|
fail(
|
|
2893
3094
|
'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
|
|
2894
|
-
`${where} ${SETUP_POSE_SAYS[rule.timeline](pose)}` +
|
|
3095
|
+
`${where} ${SETUP_POSE_SAYS[rule.timeline](pose, data.animations.length)}` +
|
|
2895
3096
|
(drivesAMesh ? ' — and this bone drives a mesh, so the canvas never rests' : ''),
|
|
2896
3097
|
);
|
|
2897
3098
|
}
|
|
@@ -2899,6 +3100,15 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2899
3100
|
fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has step ${constraint.step} (fps must be > 0)`);
|
|
2900
3101
|
}
|
|
2901
3102
|
}
|
|
3103
|
+
// What the PASS would otherwise not say: this rig rests with physics off
|
|
3104
|
+
// on that many constraints and an animation is what switches them on. A
|
|
3105
|
+
// pass carries no detail — `passed` is a list of names — so `stats` is the
|
|
3106
|
+
// channel that exists, and a number is what belongs in a line printed as
|
|
3107
|
+
// `k=v`: naming every such constraint and the animations that reach it
|
|
3108
|
+
// would be a paragraph on one line, and the rig where nobody meant it is
|
|
3109
|
+
// the rig where the NUMBER is the surprise. Absent rather than 0 when
|
|
3110
|
+
// nothing rests muted, so it appears only where it says something.
|
|
3111
|
+
if (mutedUntilKeyed) stats.physicsMutedUntilKeyed = mutedUntilKeyed;
|
|
2902
3112
|
|
|
2903
3113
|
// --- the same criterion, on every physics timeline key (issue #610) ----
|
|
2904
3114
|
//
|
|
@@ -3038,12 +3248,24 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3038
3248
|
/**
|
|
3039
3249
|
* Does any animation key `<group>.<constraint>.<timeline>`?
|
|
3040
3250
|
*
|
|
3041
|
-
* ⭐ The reason the two assertions below need this
|
|
3042
|
-
*
|
|
3043
|
-
*
|
|
3044
|
-
*
|
|
3045
|
-
*
|
|
3046
|
-
*
|
|
3251
|
+
* ⭐ The reason the two assertions below need this: a constraint whose mixes
|
|
3252
|
+
* are all 0 at setup is **the idiom**, not a defect — spineboy's aim rig is
|
|
3253
|
+
* exactly that, and issue #88 landed the timelines that turn one on. So
|
|
3254
|
+
* "muted" is only a finding when nothing turns it on, and that question
|
|
3255
|
+
* lives in the animations rather than in the constraint.
|
|
3256
|
+
*
|
|
3257
|
+
* ⚠️ This comment used to add "and the reason they are not A23 with a
|
|
3258
|
+
* different type name", which said the idiom stops at path and slider
|
|
3259
|
+
* constraints. It does not: a production rig rests 87 of its 87 physics
|
|
3260
|
+
* constraints at `mix` 0 and keys them up in four of its six animations
|
|
3261
|
+
* (issue #743), so A23 asks the same question now — and does NOT ask it
|
|
3262
|
+
* here. Two reasons, both measured: this reads the raw JSON and takes a
|
|
3263
|
+
* non-empty key array as a rescue, so a `mix` timeline that keys **0 only**
|
|
3264
|
+
* counts, and [measured] such a rig poses its bone exactly where one with no
|
|
3265
|
+
* timeline at all does; and it cannot see the unnamed global form, which the
|
|
3266
|
+
* runtime applies to every constraint declaring that property global. A23's
|
|
3267
|
+
* arm reads the key VALUES through the runtime's own accessor and asks
|
|
3268
|
+
* `PhysicsConstraintTimeline.global` who they reach.
|
|
3047
3269
|
*/
|
|
3048
3270
|
const keyedBy = (group: string, name: string, timeline: string): boolean => {
|
|
3049
3271
|
if (!raw || !isObj(raw.animations)) return false;
|
|
@@ -3770,7 +3992,8 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3770
3992
|
// `parseInt` over fixed offsets and stores whatever comes back, so
|
|
3771
3993
|
// `"4020"` loads `b = NaN` — a colour that is neither the author's nor
|
|
3772
3994
|
// an error.
|
|
3773
|
-
// 3. An `rgba2` timeline on a slot with no
|
|
3995
|
+
// 3. An `rgba2` (or, since issue #730, `rgb2`) timeline on a slot with no
|
|
3996
|
+
// `dark` at all. `Slot`'s
|
|
3774
3997
|
// constructor allocates `SlotPose.darkColor` only `if
|
|
3775
3998
|
// (data.setupPose.darkColor != null)`, and `RGBA2Timeline.apply1` then
|
|
3776
3999
|
// writes `dark.r` unconditionally — so the file parses, and the first
|
|
@@ -3811,20 +4034,31 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3811
4034
|
for (const slot of Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : []) {
|
|
3812
4035
|
if (isObj(slot) && typeof slot.dark === 'string') declared.set(String(slot.name), slot.dark);
|
|
3813
4036
|
}
|
|
3814
|
-
|
|
4037
|
+
// `rgb2` joined in issue #730, and it is this rule's subject rather than a
|
|
4038
|
+
// new one's because it is the same tint with the light alpha left out:
|
|
4039
|
+
// `RGB2Timeline` writes the light rgb and the dark colour, and on a slot
|
|
4040
|
+
// with no dark colour it throws in `apply1` exactly as `RGBA2Timeline`
|
|
4041
|
+
// does (measured on a forged file: `TypeError: null is not an object
|
|
4042
|
+
// (evaluating 'dark.r = …')`). The one thing it does differently is the
|
|
4043
|
+
// alpha it does NOT pose, which is `A45`'s question, not this one's.
|
|
4044
|
+
const keyed: Array<{ anim: string; slot: string; timeline: 'rgba2' | 'rgb2'; keys: unknown[] }> = [];
|
|
3815
4045
|
const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
|
|
3816
4046
|
for (const [animName, anim] of Object.entries(rawAnimations)) {
|
|
3817
4047
|
if (!isObj(anim) || !isObj(anim.slots)) continue;
|
|
3818
4048
|
for (const [slotName, timelines] of Object.entries(anim.slots)) {
|
|
3819
|
-
if (!isObj(timelines)
|
|
3820
|
-
|
|
4049
|
+
if (!isObj(timelines)) continue;
|
|
4050
|
+
for (const timeline of ['rgba2', 'rgb2'] as const) {
|
|
4051
|
+
const keys = timelines[timeline];
|
|
4052
|
+
if (Array.isArray(keys)) keyed.push({ anim: animName, slot: slotName, timeline, keys });
|
|
4053
|
+
}
|
|
3821
4054
|
}
|
|
3822
4055
|
}
|
|
3823
4056
|
if (declared.size === 0 && keyed.length === 0) {
|
|
3824
4057
|
return skip('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', SKIP_NO_TWO_COLOR_TINT);
|
|
3825
4058
|
}
|
|
3826
4059
|
stats.darkSlots = declared.size;
|
|
3827
|
-
stats.rgba2Timelines = keyed.length;
|
|
4060
|
+
stats.rgba2Timelines = keyed.filter((t) => t.timeline === 'rgba2').length;
|
|
4061
|
+
stats.rgb2Timelines = keyed.filter((t) => t.timeline === 'rgb2').length;
|
|
3828
4062
|
|
|
3829
4063
|
// -- clause 1: the setup pose ----------------------------------------
|
|
3830
4064
|
for (const [name, hex] of declared) {
|
|
@@ -3864,7 +4098,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3864
4098
|
}
|
|
3865
4099
|
}
|
|
3866
4100
|
|
|
3867
|
-
// -- clauses 2 and 3: every rgba2 timeline
|
|
4101
|
+
// -- clauses 2 and 3: every rgba2 and rgb2 timeline --------------------
|
|
3868
4102
|
//
|
|
3869
4103
|
// ⚠️ Clause 2 poisons its whole ANIMATION, not only its own timeline: the
|
|
3870
4104
|
// throw happens inside `state.apply`, which applies every timeline of the
|
|
@@ -3872,13 +4106,14 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3872
4106
|
// same animation would take this assertion down with a `threw:` line
|
|
3873
4107
|
// instead of the two named failures it has already worked out.
|
|
3874
4108
|
const cannotPose = new Set(keyed.filter((t) => !declared.has(t.slot)).map((t) => t.anim));
|
|
3875
|
-
for (const { anim: animName, slot: slotName, keys } of keyed) {
|
|
3876
|
-
const where = `animation "${animName}" slot "${slotName}"
|
|
4109
|
+
for (const { anim: animName, slot: slotName, timeline, keys } of keyed) {
|
|
4110
|
+
const where = `animation "${animName}" slot "${slotName}" ${timeline}`;
|
|
3877
4111
|
if (!declared.has(slotName)) {
|
|
3878
4112
|
fail(
|
|
3879
4113
|
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3880
4114
|
`${where}: slot "${slotName}" declares no setup "dark", so the runtime allocates no dark colour for ` +
|
|
3881
|
-
|
|
4115
|
+
`it and applying this animation throws instead of tinting — give the slot a \`dark\`, or key ` +
|
|
4116
|
+
`"${timeline === 'rgba2' ? 'rgba' : 'rgb'}"`,
|
|
3882
4117
|
);
|
|
3883
4118
|
continue;
|
|
3884
4119
|
}
|
|
@@ -3925,16 +4160,21 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3925
4160
|
fail(
|
|
3926
4161
|
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3927
4162
|
`${where} (t=${time}): the key states light ${JSON.stringify(rawKey.light)} and dark ` +
|
|
3928
|
-
`${JSON.stringify(rawKey.dark)} — an
|
|
4163
|
+
`${JSON.stringify(rawKey.dark)} — an ${timeline} key needs both, each six or eight hex digits — and the ` +
|
|
3929
4164
|
`runtime poses light (${show(foundLight)}), dark (${show(foundDark)})`,
|
|
3930
4165
|
);
|
|
3931
4166
|
continue;
|
|
3932
4167
|
}
|
|
3933
|
-
|
|
4168
|
+
// An `rgb2` key's light colour is three channels, and the posed alpha
|
|
4169
|
+
// is not its to state — `RGB2Timeline` leaves it where it was — so the
|
|
4170
|
+
// comparison is over the channels the timeline writes. For `rgba2`
|
|
4171
|
+
// that is all four, and the line reads as it always did.
|
|
4172
|
+
const lightChannels = timeline === 'rgba2' ? 4 : 3;
|
|
4173
|
+
if (foundLight.slice(0, lightChannels).some((n, i) => off(n, wantLight[i]))) {
|
|
3934
4174
|
fail(
|
|
3935
4175
|
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3936
|
-
`${where} (t=${time}): light posed (${show(foundLight)}), the key states
|
|
3937
|
-
|
|
4176
|
+
`${where} (t=${time}): light posed (${show(foundLight.slice(0, lightChannels))}), the key states ` +
|
|
4177
|
+
`${JSON.stringify(rawKey.light)} = (${show(wantLight.slice(0, lightChannels))})`,
|
|
3938
4178
|
);
|
|
3939
4179
|
}
|
|
3940
4180
|
if (foundDark.some((n, i) => off(n, wantDark[i]))) {
|
|
@@ -3948,6 +4188,175 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3948
4188
|
}
|
|
3949
4189
|
});
|
|
3950
4190
|
|
|
4191
|
+
// --- A45: the separable colour timelines own their channels ------------
|
|
4192
|
+
//
|
|
4193
|
+
// ⭐ **Why this is its own rule and not a clause on `A43`.** `rgb` and
|
|
4194
|
+
// `alpha` pose no dark colour, so under A43's name a failure would be a
|
|
4195
|
+
// verdict saying "two-colour tint" about a file that states one colour —
|
|
4196
|
+
// the name would say something other than what it decides (the rule #712
|
|
4197
|
+
// applied to A43 itself, against a clause on A10). And the SKIP is the
|
|
4198
|
+
// sharper half: a rig with a `dark` and no separable timeline has A43's
|
|
4199
|
+
// subject present, so a clause there could only PASS on it, which is a pass
|
|
4200
|
+
// for an absent subject.
|
|
4201
|
+
//
|
|
4202
|
+
// 🚨 **What it decides is what makes `rgb` + `alpha` not `rgba`**, and both
|
|
4203
|
+
// clauses are about the file against the runtime:
|
|
4204
|
+
//
|
|
4205
|
+
// 1. **One channel, one timeline.** Each colour timeline poses its
|
|
4206
|
+
// channels at EVERY time — before its first key it writes the setup
|
|
4207
|
+
// value — so when two of one slot share a channel, the one applied
|
|
4208
|
+
// later (the one the file states later) overwrites the other
|
|
4209
|
+
// everywhere and the first one's keys on it are read by nothing
|
|
4210
|
+
// (`SLOT_COLOR_CHANNELS`). It is the shape a converter that writes a
|
|
4211
|
+
// separable `rgb` as `rgba` leaves beside the `alpha` it kept, and it
|
|
4212
|
+
// loads without a word.
|
|
4213
|
+
// 2. **Posed as written.** Stepped to each key's own time, the channels
|
|
4214
|
+
// the timeline writes are the key's: a colour that is not six hex
|
|
4215
|
+
// digits loads as NaN, and a key whose time another key repeats is
|
|
4216
|
+
// read by nothing, and both parse in silence.
|
|
4217
|
+
//
|
|
4218
|
+
// ⚠️ An `rgb` timeline alone that a converter wrote as `rgba` with the
|
|
4219
|
+
// setup alpha is NOT this rule's to see, and nothing that reads only the
|
|
4220
|
+
// file can see it: the result is a correct `rgba`, which is also what an
|
|
4221
|
+
// author keying the light colour and holding its alpha would write. What
|
|
4222
|
+
// differs is what happens UNDER another track that moves the alpha, which
|
|
4223
|
+
// is the consumer's composing rather than the object (CLAUDE.md). That
|
|
4224
|
+
// file SKIPs here — it keys no `rgb` or `alpha` — and says so.
|
|
4225
|
+
//
|
|
4226
|
+
// ⚠️ The required values are parsed HERE, as A43's are, and the channel
|
|
4227
|
+
// table is `timelines.ts`'s rather than the loaded timelines' property ids:
|
|
4228
|
+
// a check that asked the parser what a timeline writes would agree with it
|
|
4229
|
+
// whatever it did. A selftest control holds that table to the runtime's ids.
|
|
4230
|
+
check('A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN', () => {
|
|
4231
|
+
const NAME = 'A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN';
|
|
4232
|
+
/** `rrggbb`, or `rrggbbaa` whose alpha an `rgb` key cannot pose. Null for anything else. */
|
|
4233
|
+
const readRgbHex = (hex: unknown): [number, number, number] | null => {
|
|
4234
|
+
if (typeof hex !== 'string') return null;
|
|
4235
|
+
const body = hex.startsWith('#') ? hex.slice(1) : hex;
|
|
4236
|
+
if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
|
|
4237
|
+
return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
|
|
4238
|
+
};
|
|
4239
|
+
/** Half a byte step for a hex channel; a stored `Float32` for an alpha `value`. */
|
|
4240
|
+
const HEX_STEP = 1 / 510;
|
|
4241
|
+
const FLOAT_STEP = 1e-6;
|
|
4242
|
+
const off = (found: number, want: number, step: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > step;
|
|
4243
|
+
const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
|
|
4244
|
+
|
|
4245
|
+
// -- the subjects, read off the FILE ---------------------------------
|
|
4246
|
+
const subjects: Array<{ anim: string; slot: string; timelines: Record<string, unknown> }> = [];
|
|
4247
|
+
const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
|
|
4248
|
+
for (const [animName, anim] of Object.entries(rawAnimations)) {
|
|
4249
|
+
if (!isObj(anim) || !isObj(anim.slots)) continue;
|
|
4250
|
+
for (const [slotName, timelines] of Object.entries(anim.slots)) {
|
|
4251
|
+
if (!isObj(timelines)) continue;
|
|
4252
|
+
if (Array.isArray(timelines.rgb) || Array.isArray(timelines.alpha)) {
|
|
4253
|
+
subjects.push({ anim: animName, slot: slotName, timelines });
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
}
|
|
4257
|
+
if (subjects.length === 0) return skip(NAME, SKIP_NO_SEPARABLE_COLOR);
|
|
4258
|
+
stats.separableColorTimelines = subjects.reduce(
|
|
4259
|
+
(n, s) => n + (Array.isArray(s.timelines.rgb) ? 1 : 0) + (Array.isArray(s.timelines.alpha) ? 1 : 0),
|
|
4260
|
+
0,
|
|
4261
|
+
);
|
|
4262
|
+
|
|
4263
|
+
for (const { anim: animName, slot: slotName, timelines } of subjects) {
|
|
4264
|
+
const at = `animation "${animName}" slot "${slotName}"`;
|
|
4265
|
+
// In FILE order, which is the order `readAnimation` pushes them and so
|
|
4266
|
+
// the order they apply in.
|
|
4267
|
+
const colour = Object.keys(timelines).filter((name) => name in SLOT_COLOR_CHANNELS && Array.isArray(timelines[name]));
|
|
4268
|
+
|
|
4269
|
+
// -- clause 1: one channel, one timeline ---------------------------
|
|
4270
|
+
let shared = false;
|
|
4271
|
+
for (const channel of ['rgb', 'alpha', 'dark'] as const) {
|
|
4272
|
+
const writers = colour.filter((name) => SLOT_COLOR_CHANNELS[name].includes(channel));
|
|
4273
|
+
if (writers.length < 2 || !writers.some((name) => name === 'rgb' || name === 'alpha')) continue;
|
|
4274
|
+
shared = true;
|
|
4275
|
+
const last = writers[writers.length - 1];
|
|
4276
|
+
fail(
|
|
4277
|
+
NAME,
|
|
4278
|
+
`${at}: ${writers.map((name) => `"${name}"`).join(' and ')} ${writers.length === 2 ? 'both' : 'all'} key the ${channel === 'rgb' ? 'light rgb' : channel === 'alpha' ? 'alpha' : 'dark colour'} ` +
|
|
4279
|
+
`— each poses it at every time, its setup value before its first key included, so "${last}", which the ` +
|
|
4280
|
+
`file states last, overwrites ${writers.length === 2 ? `"${writers[0]}"` : 'the others'} everywhere and ` +
|
|
4281
|
+
'those keys are read by nothing. Key each channel once: "rgb" and "alpha" on their own key times, or one "rgba"',
|
|
4282
|
+
);
|
|
4283
|
+
}
|
|
4284
|
+
if (shared) continue;
|
|
4285
|
+
|
|
4286
|
+
// -- clause 2: posed as written ------------------------------------
|
|
4287
|
+
//
|
|
4288
|
+
// ⚠️ Two things this does NOT do, both measured rather than skipped:
|
|
4289
|
+
// it does not read the loaded timeline's CLASS back, and it does not
|
|
4290
|
+
// hold a channel the timeline leaves alone to the setup pose. Against
|
|
4291
|
+
// the linked parser neither can fail — every `rgb` / `alpha` array that
|
|
4292
|
+
// loads at all loads as an `RGBTimeline` / `AlphaTimeline`, and the
|
|
4293
|
+
// three other outcomes (an empty array, a slot the skeleton lacks, a
|
|
4294
|
+
// name outside the switch) throw at `A00` — so either would be a clause
|
|
4295
|
+
// nobody can see fire. The selftest reads both off spine-core directly
|
|
4296
|
+
// (`S82`–`S84`), where they are measurements of the runtime rather than
|
|
4297
|
+
// checks on a file.
|
|
4298
|
+
if (!data.findAnimation(animName)) {
|
|
4299
|
+
fail(NAME, `${at}: the loaded skeleton has no animation "${animName}"`);
|
|
4300
|
+
continue;
|
|
4301
|
+
}
|
|
4302
|
+
for (const name of ['rgb', 'alpha'] as const) {
|
|
4303
|
+
const keys = timelines[name];
|
|
4304
|
+
if (!Array.isArray(keys)) continue;
|
|
4305
|
+
const where = `${at} ${name}`;
|
|
4306
|
+
for (const rawKey of keys) {
|
|
4307
|
+
if (!isObj(rawKey)) continue;
|
|
4308
|
+
const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
|
|
4309
|
+
const skeleton = new Skeleton(data);
|
|
4310
|
+
const state = new AnimationState(new AnimationStateData(data));
|
|
4311
|
+
state.setAnimation(0, animName, false);
|
|
4312
|
+
skeleton.setupPose();
|
|
4313
|
+
skeleton.update(0);
|
|
4314
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
4315
|
+
state.update(time);
|
|
4316
|
+
state.apply(skeleton);
|
|
4317
|
+
const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
|
|
4318
|
+
if (!posed) {
|
|
4319
|
+
fail(NAME, `${where} (t=${time}): the posed skeleton has no slot "${slotName}" to read`);
|
|
4320
|
+
continue;
|
|
4321
|
+
}
|
|
4322
|
+
const light = [posed.color.r, posed.color.g, posed.color.b, posed.color.a];
|
|
4323
|
+
if (name === 'rgb') {
|
|
4324
|
+
const stated = readRgbHex(rawKey.color);
|
|
4325
|
+
if (stated === null) {
|
|
4326
|
+
fail(
|
|
4327
|
+
NAME,
|
|
4328
|
+
`${where} (t=${time}): the key states color ${JSON.stringify(rawKey.color)} — an rgb key is six hex ` +
|
|
4329
|
+
`digits — and the runtime poses (${show(light.slice(0, 3))})`,
|
|
4330
|
+
);
|
|
4331
|
+
} else if (light.slice(0, 3).some((n, i) => off(n, stated[i], HEX_STEP))) {
|
|
4332
|
+
fail(
|
|
4333
|
+
NAME,
|
|
4334
|
+
`${where} (t=${time}): rgb posed (${show(light.slice(0, 3))}), the key states ` +
|
|
4335
|
+
`${JSON.stringify(rawKey.color)} = (${show(stated)})`,
|
|
4336
|
+
);
|
|
4337
|
+
}
|
|
4338
|
+
} else {
|
|
4339
|
+
// `readTimeline1(…, 0, 1)`: an absent `value` IS 0, and an editor
|
|
4340
|
+
// omits it there, so absence is read the parser's way rather than
|
|
4341
|
+
// as a malformed key.
|
|
4342
|
+
const stated = rawKey.value === undefined ? 0 : rawKey.value;
|
|
4343
|
+
if (typeof stated !== 'number' || off(light[3], stated, FLOAT_STEP)) {
|
|
4344
|
+
fail(
|
|
4345
|
+
NAME,
|
|
4346
|
+
`${where} (t=${time}): alpha posed ${show([light[3]])}, the key states value ${JSON.stringify(rawKey.value)}` +
|
|
4347
|
+
(typeof stated !== 'number'
|
|
4348
|
+
? ' — an alpha key\'s value is a number'
|
|
4349
|
+
: stated < 0 || stated > 1
|
|
4350
|
+
? ' — the runtime clamps a posed alpha to 0..1'
|
|
4351
|
+
: ''),
|
|
4352
|
+
);
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
}
|
|
4358
|
+
});
|
|
4359
|
+
|
|
3951
4360
|
// --- A44: a linked mesh states no geometry of its own ------------------
|
|
3952
4361
|
//
|
|
3953
4362
|
// 🚨 The one shape the parser reads in SILENCE. `readAttachment` returns from
|
|
@@ -4053,13 +4462,62 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4053
4462
|
for (const page of atlas.pages) {
|
|
4054
4463
|
const abs = resolve(input.atlasDir, page.name);
|
|
4055
4464
|
if (!existsSync(abs)) continue; // A17 owns this
|
|
4056
|
-
|
|
4057
|
-
|
|
4465
|
+
// 🔒 **A page that is not a PNG is a named failure, not a throw** (issue
|
|
4466
|
+
// #732). The reader's throw used to reach this rule's catch and print as
|
|
4467
|
+
// `threw: not a PNG (bad signature)`: the verdict right, the sentence a
|
|
4468
|
+
// stack message that named neither what the file was nor what rigc reads.
|
|
4469
|
+
// `pngProblem` ([`src/png.ts`](png.ts)) is the one sentence every reader
|
|
4470
|
+
// of a page states; this rule prefixes the size the atlas declares, the
|
|
4471
|
+
// value the file would have had to carry.
|
|
4472
|
+
//
|
|
4473
|
+
// ⚠️ No size is read off a file rigc cannot decode, deliberately. A WebP
|
|
4474
|
+
// header carries its dimensions in a fixed field, and reading them would
|
|
4475
|
+
// print a number no oracle in this tree has checked (rigc links no WebP
|
|
4476
|
+
// reader to compare a parse against), about a page that is refused here
|
|
4477
|
+
// either way — and measured, the variant that PASSED a WebP page whose
|
|
4478
|
+
// size agreed built green under the default profile and wrote a
|
|
4479
|
+
// directory that `render` then refused.
|
|
4480
|
+
const header = readPngHeader(abs);
|
|
4481
|
+
if (header.problem !== null) {
|
|
4058
4482
|
fail(
|
|
4059
4483
|
'A06_ATLAS_PAGE_SIZE_MATCHES_PNG',
|
|
4060
|
-
`page "${page.name}" declares ${page.width}x${page.height}
|
|
4484
|
+
`page "${page.name}" declares ${page.width}x${page.height} and its file cannot be read as PNG, so the ` +
|
|
4485
|
+
`size was not measured: ${header.problem}`,
|
|
4061
4486
|
);
|
|
4062
4487
|
}
|
|
4488
|
+
const info = header.info;
|
|
4489
|
+
if (info !== null && (page.width !== info.width || page.height !== info.height)) {
|
|
4490
|
+
// 🔒 **Still validity, and now for a measured reason rather than an
|
|
4491
|
+
// inherited one** (issue #715). The card that opened this expected the
|
|
4492
|
+
// clause to move behind the profile switch once the runtime's mapping
|
|
4493
|
+
// was measured — it is declared-size-relative, so a rescaled page
|
|
4494
|
+
// draws. What settles it the other way is that the sentence below can
|
|
4495
|
+
// name a repair the FORMAT already provides (`scale:`), and that two of
|
|
4496
|
+
// the three readers a wrong grid breaks are rigc's own and run under
|
|
4497
|
+
// both profiles. See `pageGridReading` for the routines and the figures.
|
|
4498
|
+
const grid = pageGridReading(page, info);
|
|
4499
|
+
const said = `page "${page.name}" declares ${page.width}x${page.height} and its PNG is ${info.width}x${info.height}`;
|
|
4500
|
+
if (grid === null) {
|
|
4501
|
+
fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `${said}. ${PAGE_GRID_PREAMBLE}`);
|
|
4502
|
+
} else {
|
|
4503
|
+
const ratios =
|
|
4504
|
+
`${said} — ${gridRatio(grid.x)} of the declared width and ${gridRatio(grid.y)} of the declared ` +
|
|
4505
|
+
'height.';
|
|
4506
|
+
const onPage = atlas.regions.filter((region) => region.page.name === page.name);
|
|
4507
|
+
const off = grid.uniform ? firstNumberOffTheCoarseGrid(onPage, page.width, info.width) : null;
|
|
4508
|
+
const repair = !grid.uniform
|
|
4509
|
+
? 'The `scale:` header states one ratio for both axes, so a page whose axes differ cannot be ' +
|
|
4510
|
+
`declared honestly at all: re-export the page at ${page.width}x${page.height}, or repack it.`
|
|
4511
|
+
: off !== null
|
|
4512
|
+
? 'The format states coarser texels with the `scale:` header, but this page cannot be re-declared ' +
|
|
4513
|
+
`that way: ${off}. Re-export the page at ${page.width}x${page.height}, or repack it.`
|
|
4514
|
+
: 'The format states coarser texels with the `scale:` header and rigc builds that: declare ' +
|
|
4515
|
+
`\`size: ${info.width}, ${info.height}\` with \`scale: ${gridRatio(grid.x)}\` and multiply every ` +
|
|
4516
|
+
`\`bounds\`/\`offsets\` on this page by ${gridRatio(grid.x)}, and every part keeps the size it ` +
|
|
4517
|
+
'has now.';
|
|
4518
|
+
fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', `${ratios} ${PAGE_GRID_PREAMBLE} ${repair}`);
|
|
4519
|
+
}
|
|
4520
|
+
}
|
|
4063
4521
|
// 📐 PROFILE, from here down. `pma: false` and no rotation are rigc's atlas
|
|
4064
4522
|
// CONVENTION, not the atlas format's rules — a packed page with
|
|
4065
4523
|
// `rotate: 90` is what the Spine packer produces and every official example
|
|
@@ -4255,6 +4713,30 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4255
4713
|
const abs = resolve(input.atlasDir, page.name);
|
|
4256
4714
|
if (!existsSync(abs)) continue;
|
|
4257
4715
|
const on = sharedPages.get(page.name) ?? [];
|
|
4716
|
+
// 🔒 **A page that is not a PNG is a non-measurement for every part on
|
|
4717
|
+
// it** (issue #732), stated the way #705's and #715's are: a FAIL naming
|
|
4718
|
+
// what was not read, because `skip()` is per assertion and would delete
|
|
4719
|
+
// the verdicts on every other page of the same report. The file's
|
|
4720
|
+
// identity is `A06`'s to judge, and this sentence points there. A page
|
|
4721
|
+
// holding nothing but the full-stage base plate has no part to judge and
|
|
4722
|
+
// says nothing, exactly as a readable one would.
|
|
4723
|
+
const header = readPngHeader(abs);
|
|
4724
|
+
if (header.problem !== null) {
|
|
4725
|
+
const shared = on.length > 1;
|
|
4726
|
+
const parts = shared ? on.filter((region) => !baseRegions.has(region.name)).map((region) => region.name) : [];
|
|
4727
|
+
if (shared ? parts.length === 0 : basePages.has(page.name)) continue;
|
|
4728
|
+
const subject = shared
|
|
4729
|
+
? `the ${parts.length} part(s) on shared page "${page.name}" (${parts.map((name) => JSON.stringify(name)).join(', ')}) are`
|
|
4730
|
+
: `part image "${page.name}" is`;
|
|
4731
|
+
fail(
|
|
4732
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
4733
|
+
`${subject} not measured: this rule reads a page's alpha out of its PNG, and the file cannot be read as ` +
|
|
4734
|
+
'one, so it states nothing about whether any of them can draw a transparent pixel. What the file is ' +
|
|
4735
|
+
"belongs to A06_ATLAS_PAGE_SIZE_MATCHES_PNG, which names it. This is renderer policy, and it belongs to " +
|
|
4736
|
+
'--profile spine-html: the default --profile spine does not run this check.',
|
|
4737
|
+
);
|
|
4738
|
+
continue;
|
|
4739
|
+
}
|
|
4258
4740
|
if (on.length > 1) {
|
|
4259
4741
|
// 🚨 A rotated region is refused by A06 under this profile, so this
|
|
4260
4742
|
// reading was assumed to be cosmetic — a rectangle printed beside a
|
|
@@ -4289,6 +4771,37 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4289
4771
|
// requires: a part this rule could not read must not be certified by
|
|
4290
4772
|
// it.
|
|
4291
4773
|
const plate = readPlate(abs);
|
|
4774
|
+
// 🚨 **The scan's coordinates are the atlas's, so a file that is not the
|
|
4775
|
+
// declared grid is a non-measurement for every region on it** (issue
|
|
4776
|
+
// #715), and #705's clause above does not cover it. That one fires when
|
|
4777
|
+
// a rectangle has NO texel on the page; a page whose image is a rescale
|
|
4778
|
+
// of the declared size leaves most rectangles partly on it, at
|
|
4779
|
+
// coordinates that address a different part of the picture. Measured on
|
|
4780
|
+
// a two-region pack at a uniform 0.5: the opaque part's failure
|
|
4781
|
+
// DISAPPEARED — the scan found a transparent texel 32 texels away from
|
|
4782
|
+
// it and returned — while the other printed #705's sentence over 0 of
|
|
4783
|
+
// 256 texels. A verdict and a silence, both about texels nobody located.
|
|
4784
|
+
//
|
|
4785
|
+
// A FAIL for #705's reason, word for word: `skip()` is per assertion and
|
|
4786
|
+
// would delete the verdicts on every other page in the same report.
|
|
4787
|
+
if (plate.width !== page.width || plate.height !== page.height) {
|
|
4788
|
+
for (const region of on) {
|
|
4789
|
+
if (baseRegions.has(region.name)) continue;
|
|
4790
|
+
const { width, height } = pageFootprint(region);
|
|
4791
|
+
fail(
|
|
4792
|
+
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
4793
|
+
`part "${region.name}" is not measured: this rule opens the page at the coordinates the atlas ` +
|
|
4794
|
+
`states, and page "${page.name}" declares ${page.width}x${page.height} over a ` +
|
|
4795
|
+
`${plate.width}x${plate.height} image, so the ${width}x${height} rectangle at ${region.x},` +
|
|
4796
|
+
`${region.y} is not where "${region.name}"'s texels are on this file and this rule states ` +
|
|
4797
|
+
'nothing about whether it can draw a transparent pixel. The page grid is ' +
|
|
4798
|
+
"A06_ATLAS_PAGE_SIZE_MATCHES_PNG's to judge, and it names the ratio and how to declare the page " +
|
|
4799
|
+
'honestly. This is renderer policy, and it belongs to --profile spine-html: the default ' +
|
|
4800
|
+
'--profile spine does not run this check.',
|
|
4801
|
+
);
|
|
4802
|
+
}
|
|
4803
|
+
continue;
|
|
4804
|
+
}
|
|
4292
4805
|
for (const region of on) {
|
|
4293
4806
|
if (baseRegions.has(region.name)) continue;
|
|
4294
4807
|
const { width, height } = pageFootprint(region);
|
|
@@ -4341,7 +4854,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4341
4854
|
}
|
|
4342
4855
|
continue;
|
|
4343
4856
|
}
|
|
4344
|
-
const info =
|
|
4857
|
+
const info = header.info;
|
|
4345
4858
|
if (info.hasTransparency) continue;
|
|
4346
4859
|
if (basePages.has(page.name)) continue; // full-stage base plate: opaque is correct
|
|
4347
4860
|
fail(
|