spine-rigc 0.30.0 → 0.32.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 +39 -1
- package/docs/AUTHORING.md +309 -48
- package/docs/INGEST.md +12 -10
- package/docs/SPEC_COVERAGE.md +6 -5
- package/package.json +1 -1
- package/src/atlas.ts +180 -0
- package/src/compile.ts +486 -21
- package/src/deformmeasure.ts +7 -2
- package/src/ingest.ts +254 -29
- package/src/motion.ts +120 -2
- package/src/rig.ts +179 -5
- package/src/timelines.ts +94 -6
- package/src/types.ts +106 -3
- package/src/validate.ts +618 -221
package/src/validate.ts
CHANGED
|
@@ -23,7 +23,9 @@ import {
|
|
|
23
23
|
BoundingBoxAttachment,
|
|
24
24
|
ClippingAttachment,
|
|
25
25
|
type ConstraintTimeline,
|
|
26
|
+
type CurveTimeline,
|
|
26
27
|
DeformTimeline,
|
|
28
|
+
Inherit,
|
|
27
29
|
isBoneTimeline,
|
|
28
30
|
isConstraintTimeline,
|
|
29
31
|
isSlotTimeline,
|
|
@@ -31,6 +33,7 @@ import {
|
|
|
31
33
|
MixFrom,
|
|
32
34
|
PathAttachment,
|
|
33
35
|
PathConstraintData,
|
|
36
|
+
PathConstraintMixTimeline,
|
|
34
37
|
Physics,
|
|
35
38
|
PhysicsConstraintData,
|
|
36
39
|
PhysicsConstraintPose,
|
|
@@ -41,6 +44,7 @@ import {
|
|
|
41
44
|
Skeleton,
|
|
42
45
|
SkeletonJson,
|
|
43
46
|
SliderData,
|
|
47
|
+
SliderMixTimeline,
|
|
44
48
|
TextureAtlas,
|
|
45
49
|
type TextureAtlasRegion,
|
|
46
50
|
type Timeline,
|
|
@@ -50,8 +54,10 @@ import {
|
|
|
50
54
|
// A19 needs the DECODED page, not its header, to measure one region's own
|
|
51
55
|
// rectangle on a shared page.
|
|
52
56
|
import { readPlate } from '../tools/plate.ts';
|
|
53
|
-
import { pageFootprint } from './atlas.ts';
|
|
57
|
+
import { pageFootprint, pageGridSentence } from './atlas.ts';
|
|
54
58
|
import {
|
|
59
|
+
BEZIER_POINTS,
|
|
60
|
+
curveStorage,
|
|
55
61
|
surveyDeformKeys,
|
|
56
62
|
unreachableWhy,
|
|
57
63
|
type DeformDialDispute,
|
|
@@ -66,12 +72,16 @@ import {
|
|
|
66
72
|
type SpineGeneration,
|
|
67
73
|
} from './generation.ts';
|
|
68
74
|
import { colourTypeName, readPngHeader } from './png.ts';
|
|
75
|
+
import { BONE_INHERIT_KNOWN } from './rig.ts';
|
|
69
76
|
import {
|
|
70
77
|
CHANNELS_BY_KIND,
|
|
71
78
|
KEY_TIME_EPSILON,
|
|
72
79
|
PHYSICS_POSE_RULES,
|
|
73
80
|
physicsKeyRefusal,
|
|
81
|
+
physicsOutsideSays,
|
|
74
82
|
physicsRuleFor,
|
|
83
|
+
type PhysicsPoseRule,
|
|
84
|
+
SEQUENCE_MODES,
|
|
75
85
|
SLOT_COLOR_CHANNELS,
|
|
76
86
|
walkTimelines,
|
|
77
87
|
} from './timelines.ts';
|
|
@@ -197,6 +207,7 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
|
|
|
197
207
|
A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
|
|
198
208
|
A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
|
|
199
209
|
A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN: 'validity',
|
|
210
|
+
A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES: 'validity',
|
|
200
211
|
};
|
|
201
212
|
|
|
202
213
|
/**
|
|
@@ -303,6 +314,8 @@ export const SKIP_NO_TWO_COLOR_TINT =
|
|
|
303
314
|
export const SKIP_NO_SEPARABLE_COLOR =
|
|
304
315
|
'no animation keys an "rgb" or "alpha" timeline, so there is no separable slot colour to read back';
|
|
305
316
|
export const SKIP_NO_LINKED_MESH = 'no attachment in this skeleton takes its geometry from another one';
|
|
317
|
+
export const SKIP_NO_SEQUENCE =
|
|
318
|
+
'no attachment carries a "sequence" block and no animation keys a "sequence" timeline, so there is no numbered series to read back';
|
|
306
319
|
/**
|
|
307
320
|
* A09's, which predates this list and joins it rather than being rewritten: it
|
|
308
321
|
* is the same fact about the same subject, and a control that compares against
|
|
@@ -458,6 +471,55 @@ const NAMED_TIMELINE_GROUPS = ['path', 'physics', 'slider'] as const;
|
|
|
458
471
|
const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
|
|
459
472
|
const EDITOR_PHYSICS_COMPONENTS: ReadonlySet<(typeof PHYSICS_COMPONENTS)[number]> = new Set(['x', 'y'] as const);
|
|
460
473
|
|
|
474
|
+
/**
|
|
475
|
+
* The half of a muted-at-rest refusal that says what was searched, and how
|
|
476
|
+
* widely — one text for `A23`, `A36` and `A37`, which ask one question of three
|
|
477
|
+
* constraint kinds (issues #743, #752). A rig with no animation at all says
|
|
478
|
+
* "none of the 0 animations" rather than implying somebody keyed something.
|
|
479
|
+
*/
|
|
480
|
+
function noneKeysItsMixAbove0(animations: number): string {
|
|
481
|
+
return `none of the ${animations} animation${animations === 1 ? '' : 's'} keys its mix above 0`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** The two repairs a muted-at-rest refusal names, since either one is a rig the runtime plays. */
|
|
485
|
+
const REST_OR_KEY_ITS_MIX = 'rest it above 0, or key its mix above 0 in an animation';
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Every value one channel of a curve timeline can pose while it plays: each
|
|
489
|
+
* key's own value, and every sample of each Bezier segment the parser built
|
|
490
|
+
* between two keys (issue #752).
|
|
491
|
+
*
|
|
492
|
+
* ⚠️ The samples are the half a reading of the keys alone misses. The runtime
|
|
493
|
+
* interpolates a Bezier segment between the points `setBezier` stored
|
|
494
|
+
* (`Animation.js:257-298`), not between the two keys, so two keys of 0 joined
|
|
495
|
+
* by a curve whose handles lie above 0 pose values above 0 in between.
|
|
496
|
+
* [measured] on generated fixtures, a `mix` pair of 0 → 0 with its handles at
|
|
497
|
+
* 0.8 poses a path constraint's bone up to 32.69 away from the flat pair, a
|
|
498
|
+
* slider's by 0.54 and a physics constraint's by 3.81. Between two stored
|
|
499
|
+
* points the runtime is linear, so nothing it poses lies outside this list's
|
|
500
|
+
* range — which is what makes "is any of these above 0" the whole question.
|
|
501
|
+
*
|
|
502
|
+
* Channel `c` of frame `f` is `frames[f * entries + 1 + c]`. `curves[f]` is 0
|
|
503
|
+
* for linear, 1 for stepped and `2 + i` for a Bezier whose points start at `i`
|
|
504
|
+
* (`Animation.js:259-261`); `readCurve` stores one segment per channel in
|
|
505
|
+
* order, so channel `c`'s points start `2 * BEZIER_POINTS * c` further on, as
|
|
506
|
+
* `(time, value)` pairs. The storage is read through `curveStorage`, the one
|
|
507
|
+
* reach into that protected array, which `deformmeasure.ts` owns.
|
|
508
|
+
*/
|
|
509
|
+
function curveChannelValues(timeline: CurveTimeline, channel: number): number[] {
|
|
510
|
+
const entries = timeline.getFrameEntries();
|
|
511
|
+
const curves = curveStorage(timeline);
|
|
512
|
+
const values: number[] = [];
|
|
513
|
+
for (let i = 0, frame = 0; i < timeline.frames.length; i += entries, frame++) {
|
|
514
|
+
values.push(timeline.frames[i + 1 + channel]);
|
|
515
|
+
const code = curves[frame];
|
|
516
|
+
if (!(code >= 2)) continue;
|
|
517
|
+
const start = code - 2 + 2 * BEZIER_POINTS * channel;
|
|
518
|
+
for (let point = 0; point < BEZIER_POINTS; point++) values.push(curves[start + 2 * point + 1]);
|
|
519
|
+
}
|
|
520
|
+
return values;
|
|
521
|
+
}
|
|
522
|
+
|
|
461
523
|
/**
|
|
462
524
|
* What A23 says about a SETUP pose outside its bound, per property.
|
|
463
525
|
*
|
|
@@ -481,13 +543,21 @@ const EDITOR_PHYSICS_COMPONENTS: ReadonlySet<(typeof PHYSICS_COMPONENTS)[number]
|
|
|
481
543
|
* other half was looked for and how wide the search was. A rig with no animation
|
|
482
544
|
* at all then says "none of the 0 animations" rather than implying somebody
|
|
483
545
|
* keyed something.
|
|
546
|
+
*
|
|
547
|
+
* `rule` is the row the value was judged by, and only the `strength` sentence
|
|
548
|
+
* reads it: its two arms live on the row (`outside`), beside the key's own
|
|
549
|
+
* sentence, so what the setup pose and a key say about one number is one text.
|
|
484
550
|
*/
|
|
485
|
-
const SETUP_POSE_SAYS: Record<
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
551
|
+
const SETUP_POSE_SAYS: Record<
|
|
552
|
+
string,
|
|
553
|
+
(pose: PhysicsConstraintPose, animations: number, rule: PhysicsPoseRule) => string
|
|
554
|
+
> = {
|
|
555
|
+
mix: (pose, animations) => `has mix ${pose.mix} and ${noneKeysItsMixAbove0(animations)}; it is muted — ${REST_OR_KEY_ITS_MIX}`,
|
|
489
556
|
mass: (pose) => `has massInverse ${pose.massInverse} (mass must be > 0)`,
|
|
490
|
-
|
|
557
|
+
// Two arms, read off the row (issue #748): 0 is a constraint nothing pulls
|
|
558
|
+
// back and below 0 one that is pushed away, and the row's `why` — the key's
|
|
559
|
+
// sentence — quotes the second from the same object.
|
|
560
|
+
strength: (pose, _animations, rule) => `has strength ${pose.strength}; ${physicsOutsideSays(rule, pose.strength)}`,
|
|
491
561
|
damping: (pose) => `has damping ${pose.damping}; outside (0,1) it never settles`,
|
|
492
562
|
};
|
|
493
563
|
|
|
@@ -513,6 +583,53 @@ export const PHYSICS_TIMELINE_NAMES: Record<number, string> = {
|
|
|
513
583
|
[Property.physicsConstraintMix]: 'mix',
|
|
514
584
|
};
|
|
515
585
|
|
|
586
|
+
/**
|
|
587
|
+
* Which physics constraints a physics timeline that names NO constraint writes
|
|
588
|
+
* into — the one reading of the unnamed form, for every rule that has to answer
|
|
589
|
+
* it (`A23`, `A34`, `A42`; issue #726).
|
|
590
|
+
*
|
|
591
|
+
* `SkeletonJson` gives a physics group keyed by the empty name `constraintIndex
|
|
592
|
+
* -1` (`SkeletonJson.js:1048-1054`), and the runtime reads that two ways. A
|
|
593
|
+
* value timeline writes every active physics constraint whose own data declares
|
|
594
|
+
* that property global — `PhysicsConstraintTimeline.apply` asks each subclass's
|
|
595
|
+
* `global` (`Animation.js:2067-2075`). `reset` resets every active one and asks
|
|
596
|
+
* no flag at all (`:2234-2238`). Activity is a skin question and is not asked
|
|
597
|
+
* here: a constraint a skin switches on is still one the timeline can reach.
|
|
598
|
+
*
|
|
599
|
+
* 🔑 `constraints` may be the FILE's objects rather than loaded data, and that
|
|
600
|
+
* is what makes this one reading rather than two: `SkeletonJson` copies each
|
|
601
|
+
* `…Global` field onto the data verbatim (`:313-319`), so the runtime's own
|
|
602
|
+
* `global`, asked of the raw object, reads exactly what it would read of the
|
|
603
|
+
* loaded one — a string `"false"` included, which is truthy to both. A rule that
|
|
604
|
+
* spelled `<property>Global` itself would be a second copy of the runtime's
|
|
605
|
+
* table, free to disagree with it.
|
|
606
|
+
*/
|
|
607
|
+
export function unnamedPhysicsReach<C extends object>(timeline: Timeline, constraints: readonly C[]): C[] {
|
|
608
|
+
if (timeline instanceof PhysicsConstraintResetTimeline) return [...constraints];
|
|
609
|
+
if (!(timeline instanceof PhysicsConstraintTimeline)) return [];
|
|
610
|
+
return constraints.filter((one) => Boolean(timeline.global(one as unknown as PhysicsConstraintData)));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The timeline the runtime builds for one physics timeline NAME, read by the
|
|
615
|
+
* runtime's own parser off a skeleton that holds nothing else — or null for a
|
|
616
|
+
* name its physics branch skips (`SkeletonJson.js:1094`).
|
|
617
|
+
*
|
|
618
|
+
* ⚠️ This is how a raw-JSON rule gets the runtime's class for a name without a
|
|
619
|
+
* table of the eight: a hand-kept map from `"strength"` to
|
|
620
|
+
* `PhysicsConstraintStrengthTimeline` is the copy this avoids, and the parser's
|
|
621
|
+
* `switch` is already that map. The probe skeleton declares no constraint, so
|
|
622
|
+
* the only group it can carry is the unnamed one, and one key is all
|
|
623
|
+
* `readTimeline1` needs to build the object.
|
|
624
|
+
*/
|
|
625
|
+
export function unnamedPhysicsTimeline(name: string): Timeline | null {
|
|
626
|
+
const probe = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(''))).readSkeletonData({
|
|
627
|
+
skeleton: {},
|
|
628
|
+
animations: { probe: { physics: { '': { [name]: [{}] } } } },
|
|
629
|
+
});
|
|
630
|
+
return probe.animations[0]?.timelines[0] ?? null;
|
|
631
|
+
}
|
|
632
|
+
|
|
516
633
|
/**
|
|
517
634
|
* One step of the **float32** grid at `t`, which is the grid a loaded key time
|
|
518
635
|
* actually sits on: `spine-core` reads every timeline's frames into a
|
|
@@ -960,127 +1077,6 @@ export function timelineAddBehaviour(data: ReturnType<SkeletonJson['readSkeleton
|
|
|
960
1077
|
return wrote ? 'overwrites' : 'inert';
|
|
961
1078
|
}
|
|
962
1079
|
|
|
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
|
-
|
|
1084
1080
|
export function validate(input: ValidateInput): ValidateReport {
|
|
1085
1081
|
const failures: Failure[] = [];
|
|
1086
1082
|
const passed: string[] = [];
|
|
@@ -1518,6 +1514,10 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1518
1514
|
kindsOf.set(entry.name, [...(kindsOf.get(entry.name) ?? []), String(entry.type)]);
|
|
1519
1515
|
}
|
|
1520
1516
|
}
|
|
1517
|
+
/** The file's physics constraints, which the unnamed physics group is read against. */
|
|
1518
|
+
const rawPhysics = (Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []).filter(
|
|
1519
|
+
(entry): entry is Json => isObj(entry) && entry.type === 'physics',
|
|
1520
|
+
);
|
|
1521
1521
|
let sawATimeline = false;
|
|
1522
1522
|
/** One target of one group: the name resolves, the type matches, keys exist. */
|
|
1523
1523
|
const checkTarget = (at: string, group: string, name: string, keyArrays: Array<[string, unknown]>): void => {
|
|
@@ -1539,6 +1539,10 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1539
1539
|
);
|
|
1540
1540
|
return;
|
|
1541
1541
|
}
|
|
1542
|
+
checkKeys(at, keyArrays);
|
|
1543
|
+
};
|
|
1544
|
+
/** The key arrays of one group entry that resolved: each one is walked, so each has to hold a key. */
|
|
1545
|
+
const checkKeys = (at: string, keyArrays: Array<[string, unknown]>): void => {
|
|
1542
1546
|
if (keyArrays.length === 0) {
|
|
1543
1547
|
fail(
|
|
1544
1548
|
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
@@ -1580,6 +1584,36 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1580
1584
|
);
|
|
1581
1585
|
continue;
|
|
1582
1586
|
}
|
|
1587
|
+
// The empty name is not a miss: it is the physics group's global form,
|
|
1588
|
+
// which `SkeletonJson` loads as `constraintIndex -1` rather than looking
|
|
1589
|
+
// anything up (`:1048-1054`), and which writes every physics constraint
|
|
1590
|
+
// that declares the timeline's property global (issue #726). Refusing
|
|
1591
|
+
// it as "no constraint called ''" was a sentence that is false about
|
|
1592
|
+
// the file. What CAN be wrong with it is that it reaches nobody: the
|
|
1593
|
+
// parser accepts it, the runtime walks every constraint, and none of
|
|
1594
|
+
// them takes the key.
|
|
1595
|
+
if (group === 'physics' && name === '') {
|
|
1596
|
+
sawATimeline = true;
|
|
1597
|
+
for (const [timelineName] of Object.entries(timelines)) {
|
|
1598
|
+
const timeline = unnamedPhysicsTimeline(timelineName);
|
|
1599
|
+
// A name the parser skips is skipped here too: no timeline exists
|
|
1600
|
+
// to reach anybody, and whether the NAME is right is not a target
|
|
1601
|
+
// question.
|
|
1602
|
+
if (timeline === null || unnamedPhysicsReach(timeline, rawPhysics).length > 0) continue;
|
|
1603
|
+
const resets = timeline instanceof PhysicsConstraintResetTimeline;
|
|
1604
|
+
fail(
|
|
1605
|
+
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
1606
|
+
`${at} timeline "${timelineName}": a physics group that names no constraint writes every physics ` +
|
|
1607
|
+
`constraint ${resets ? 'the skeleton has' : `declaring "${timelineName}Global": true`}, and ` +
|
|
1608
|
+
(rawPhysics.length === 0
|
|
1609
|
+
? 'the skeleton has no physics constraint'
|
|
1610
|
+
: `none of ${rawPhysics.map((one) => `"${String(one.name)}"`).join(', ')} does`) +
|
|
1611
|
+
' — the parser loads it, the runtime walks every constraint, and no constraint takes the key',
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
checkKeys(at, Object.entries(timelines));
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1583
1617
|
checkTarget(at, group, name, Object.entries(timelines));
|
|
1584
1618
|
}
|
|
1585
1619
|
}
|
|
@@ -2981,6 +3015,58 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2981
3015
|
if (survey.spansUnconfirmed) stats.deformSpansUnconfirmed = survey.spansUnconfirmed;
|
|
2982
3016
|
});
|
|
2983
3017
|
|
|
3018
|
+
/**
|
|
3019
|
+
* The constraints some animation keys to a value `live` accepts, on any
|
|
3020
|
+
* channel of a timeline `owns` claims — the one answer to "does anything
|
|
3021
|
+
* switch this on" for `A23`, `A36`, `A37` and `A40` (issues #743, #752).
|
|
3022
|
+
*
|
|
3023
|
+
* ⭐ It replaced `keyedBy`, which read the raw JSON and took a non-empty key
|
|
3024
|
+
* array as the answer, and it did so rather than teaching that one to read
|
|
3025
|
+
* values, because the raw file is the wrong place to read a value: a path
|
|
3026
|
+
* `mix` key that omits `mixRotate` means 1 (`SkeletonJson.js:1011-1013`), a
|
|
3027
|
+
* `mixY` it omits means that key's `mixX`, and a Bezier between two keys
|
|
3028
|
+
* poses values neither key states. A raw reader would restate the parser's
|
|
3029
|
+
* defaults and re-derive its curves — a second opinion on the runtime's own
|
|
3030
|
+
* numbers — so this reads the loaded timelines instead, with
|
|
3031
|
+
* `curveChannelValues` for the curves. [measured] the reading `keyedBy` gave
|
|
3032
|
+
* was wrong in the accepting direction: a path constraint and a slider,
|
|
3033
|
+
* both muted at rest and keyed to 0 only, pose every bone exactly where the
|
|
3034
|
+
* same rig with no timeline does (max |Δ| 0.000000 over 60 steps at 60 fps) and both
|
|
3035
|
+
* passed.
|
|
3036
|
+
*
|
|
3037
|
+
* A timeline naming no constraint is the physics family's global form and
|
|
3038
|
+
* `unnamedPhysicsReach` answers who it reaches; every other constraint
|
|
3039
|
+
* timeline names its one constraint by index.
|
|
3040
|
+
*/
|
|
3041
|
+
const keyedLive = <T extends CurveTimeline & ConstraintTimeline>(
|
|
3042
|
+
owns: (timeline: Timeline) => timeline is T,
|
|
3043
|
+
live: (timeline: T, value: number) => boolean,
|
|
3044
|
+
): Set<object> => {
|
|
3045
|
+
const reached = new Set<object>();
|
|
3046
|
+
for (const animation of data.animations) {
|
|
3047
|
+
for (const timeline of animation.timelines) {
|
|
3048
|
+
if (!owns(timeline)) continue;
|
|
3049
|
+
let keysLive = false;
|
|
3050
|
+
for (let channel = 0; channel < timeline.getFrameEntries() - 1 && !keysLive; channel++) {
|
|
3051
|
+
keysLive = curveChannelValues(timeline, channel).some((value) => live(timeline, value));
|
|
3052
|
+
}
|
|
3053
|
+
if (!keysLive) continue;
|
|
3054
|
+
const reach =
|
|
3055
|
+
timeline.constraintIndex === -1
|
|
3056
|
+
? unnamedPhysicsReach(timeline, data.constraints.filter((one) => one instanceof PhysicsConstraintData))
|
|
3057
|
+
: [data.constraints[timeline.constraintIndex]];
|
|
3058
|
+
for (const one of reach) if (one) reached.add(one);
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
return reached;
|
|
3062
|
+
};
|
|
3063
|
+
|
|
3064
|
+
/**
|
|
3065
|
+
* The one predicate a path or slider mix is judged by, at setup and on every
|
|
3066
|
+
* value a key poses: above 0, where `update()` does anything at all.
|
|
3067
|
+
*/
|
|
3068
|
+
const mixLive = (value: number): boolean => value > 0;
|
|
3069
|
+
|
|
2984
3070
|
// --- A23: a physics constraint that does nothing, quietly ---------------
|
|
2985
3071
|
//
|
|
2986
3072
|
// Every failure mode here is silent. The five component fields default to
|
|
@@ -3036,37 +3122,30 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3036
3122
|
// [measured] at rest it reads NaN on every frame although an animation
|
|
3037
3123
|
// keys `mass` to 1. A key cannot rescue a value that has already broken
|
|
3038
3124
|
// the rig it is resting in.
|
|
3125
|
+
//
|
|
3126
|
+
// The keys are read by `keyedLive`, the one reading A36 and A37 share
|
|
3127
|
+
// (issue #752), through the runtime's own accessor: the pose field is
|
|
3128
|
+
// where the integrator reads the number, and for `mass` that is not the
|
|
3129
|
+
// number the key states. It counts the unnamed global form through
|
|
3130
|
+
// `unnamedPhysicsReach` and a Bezier segment's samples as well as its keys.
|
|
3039
3131
|
const unmuted = new Map<PhysicsConstraintData, Set<string>>();
|
|
3040
3132
|
const raised = new PhysicsConstraintPose();
|
|
3041
|
-
for (const
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
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
|
-
}
|
|
3133
|
+
for (const rule of PHYSICS_POSE_RULES) {
|
|
3134
|
+
if (!rule.inertAtSetup) continue;
|
|
3135
|
+
const reached = keyedLive(
|
|
3136
|
+
(timeline): timeline is PhysicsConstraintTimeline =>
|
|
3137
|
+
timeline instanceof PhysicsConstraintTimeline &&
|
|
3138
|
+
PHYSICS_TIMELINE_NAMES[Number(timeline.getPropertyIds()[0].split('|')[0])] === rule.timeline,
|
|
3139
|
+
(timeline, value) => {
|
|
3140
|
+
timeline.set(raised, value);
|
|
3141
|
+
return rule.poseOk(raised[rule.field]);
|
|
3142
|
+
},
|
|
3143
|
+
);
|
|
3144
|
+
for (const one of data.constraints) {
|
|
3145
|
+
if (!(one instanceof PhysicsConstraintData) || !reached.has(one)) continue;
|
|
3146
|
+
const by = unmuted.get(one) ?? new Set<string>();
|
|
3147
|
+
by.add(rule.timeline);
|
|
3148
|
+
unmuted.set(one, by);
|
|
3070
3149
|
}
|
|
3071
3150
|
}
|
|
3072
3151
|
let mutedUntilKeyed = 0;
|
|
@@ -3092,7 +3171,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3092
3171
|
const drivesAMesh = rule.timeline === 'damping' && meshBoneNames.has(constraint.bone.name);
|
|
3093
3172
|
fail(
|
|
3094
3173
|
'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
|
|
3095
|
-
`${where} ${SETUP_POSE_SAYS[rule.timeline](pose, data.animations.length)}` +
|
|
3174
|
+
`${where} ${SETUP_POSE_SAYS[rule.timeline](pose, data.animations.length, rule)}` +
|
|
3096
3175
|
(drivesAMesh ? ' — and this bone drives a mesh, so the canvas never rests' : ''),
|
|
3097
3176
|
);
|
|
3098
3177
|
}
|
|
@@ -3246,7 +3325,9 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3246
3325
|
});
|
|
3247
3326
|
|
|
3248
3327
|
/**
|
|
3249
|
-
*
|
|
3328
|
+
* Which path constraints and sliders an animation switches ON — `keyedLive`
|
|
3329
|
+
* over their `mix` timelines, judged by `mixLive`, the predicate their setup
|
|
3330
|
+
* pose is judged by below.
|
|
3250
3331
|
*
|
|
3251
3332
|
* ⭐ The reason the two assertions below need this: a constraint whose mixes
|
|
3252
3333
|
* are all 0 at setup is **the idiom**, not a defect — spineboy's aim rig is
|
|
@@ -3254,30 +3335,23 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3254
3335
|
* "muted" is only a finding when nothing turns it on, and that question
|
|
3255
3336
|
* lives in the animations rather than in the constraint.
|
|
3256
3337
|
*
|
|
3257
|
-
*
|
|
3258
|
-
*
|
|
3259
|
-
*
|
|
3260
|
-
*
|
|
3261
|
-
*
|
|
3262
|
-
*
|
|
3263
|
-
*
|
|
3264
|
-
*
|
|
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.
|
|
3338
|
+
* 🔑 "Turns it on" is a VALUE question, and until issue #752 these two asked
|
|
3339
|
+
* a weaker one than `A23` — whether a `mix` key array was non-empty — so a
|
|
3340
|
+
* timeline keying 0 only was a rescue for a constraint it leaves exactly as
|
|
3341
|
+
* muted as no timeline does. Now a path constraint is switched on by a key
|
|
3342
|
+
* posing any of its three mixes above 0, which is the runtime's own
|
|
3343
|
+
* condition: `PathConstraint.update` returns when `mixRotate`, `mixX` and
|
|
3344
|
+
* `mixY` are all 0 (`PathConstraint.js:73-75`), and `Slider.update` when
|
|
3345
|
+
* `mix` is (`Slider.js:53-54`).
|
|
3269
3346
|
*/
|
|
3270
|
-
const
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
}
|
|
3279
|
-
return false;
|
|
3280
|
-
};
|
|
3347
|
+
const pathSwitchedOn = keyedLive(
|
|
3348
|
+
(timeline): timeline is PathConstraintMixTimeline => timeline instanceof PathConstraintMixTimeline,
|
|
3349
|
+
(_timeline, value) => mixLive(value),
|
|
3350
|
+
);
|
|
3351
|
+
const sliderSwitchedOn = keyedLive(
|
|
3352
|
+
(timeline): timeline is SliderMixTimeline => timeline instanceof SliderMixTimeline,
|
|
3353
|
+
(_timeline, value) => mixLive(value),
|
|
3354
|
+
);
|
|
3281
3355
|
|
|
3282
3356
|
// --- A36: a path constraint that follows nothing, quietly ---------------
|
|
3283
3357
|
//
|
|
@@ -3293,9 +3367,9 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3293
3367
|
// slot exists, the constraint resolves, the mixes are 1.
|
|
3294
3368
|
//
|
|
3295
3369
|
// The rest are the same shape as A23's: a constraint that parses and does
|
|
3296
|
-
// nothing. All three mixes at 0 is only a finding when no animation keys
|
|
3297
|
-
//
|
|
3298
|
-
// whether or not anything is keyed.
|
|
3370
|
+
// nothing. All three mixes at 0 is only a finding when no animation keys one
|
|
3371
|
+
// of them above 0 (see `pathSwitchedOn`), and a chain with no bones on it is
|
|
3372
|
+
// one whether or not anything is keyed.
|
|
3299
3373
|
check('A36_PATH_CONSTRAINT_EFFECTIVE', () => {
|
|
3300
3374
|
const constraints = data.constraints.filter((c) => c instanceof PathConstraintData);
|
|
3301
3375
|
if (!constraints.length) return skip('A36_PATH_CONSTRAINT_EFFECTIVE', 'the skeleton declares no path constraint');
|
|
@@ -3322,12 +3396,13 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3322
3396
|
);
|
|
3323
3397
|
}
|
|
3324
3398
|
const pose = constraint.setupPose;
|
|
3325
|
-
const muted = !
|
|
3326
|
-
if (muted && !
|
|
3399
|
+
const muted = ![pose.mixRotate, pose.mixX, pose.mixY].some(mixLive);
|
|
3400
|
+
if (muted && !pathSwitchedOn.has(constraint)) {
|
|
3327
3401
|
fail(
|
|
3328
3402
|
'A36_PATH_CONSTRAINT_EFFECTIVE',
|
|
3329
|
-
`${where} has mixRotate ${pose.mixRotate}, mixX ${pose.mixX} and mixY ${pose.mixY} at setup and
|
|
3330
|
-
|
|
3403
|
+
`${where} has mixRotate ${pose.mixRotate}, mixX ${pose.mixX} and mixY ${pose.mixY} at setup and ` +
|
|
3404
|
+
`${noneKeysItsMixAbove0(data.animations.length)}; update() returns on all-zero mixes, so nothing ever ` +
|
|
3405
|
+
'puts a bone on the path — rest one of the three above 0, or key its mix above 0 in an animation',
|
|
3331
3406
|
);
|
|
3332
3407
|
}
|
|
3333
3408
|
}
|
|
@@ -3384,10 +3459,11 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3384
3459
|
);
|
|
3385
3460
|
}
|
|
3386
3461
|
const mix = slider.setupPose.mix;
|
|
3387
|
-
if (!(mix
|
|
3462
|
+
if (!mixLive(mix) && !sliderSwitchedOn.has(slider)) {
|
|
3388
3463
|
fail(
|
|
3389
3464
|
'A37_SLIDER_CONSTRAINT_EFFECTIVE',
|
|
3390
|
-
`${where} has mix ${mix} at setup and
|
|
3465
|
+
`${where} has mix ${mix} at setup and ${noneKeysItsMixAbove0(data.animations.length)}; update() returns ` +
|
|
3466
|
+
`on mix 0 — ${REST_OR_KEY_ITS_MIX}`,
|
|
3391
3467
|
);
|
|
3392
3468
|
}
|
|
3393
3469
|
}
|
|
@@ -3510,7 +3586,15 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3510
3586
|
);
|
|
3511
3587
|
}
|
|
3512
3588
|
// Clause 1 of the "could this be correct?" list above.
|
|
3513
|
-
|
|
3589
|
+
// "Keyed at all" rather than "keyed live", and on purpose: this clause asks
|
|
3590
|
+
// whether the mix can MOVE from its setup value, so any mix timeline
|
|
3591
|
+
// disqualifies it — the question `keyedBy` answered here, through the one
|
|
3592
|
+
// reading `keyedLive` gives, and unchanged by issue #752.
|
|
3593
|
+
const mixKeyed = keyedLive(
|
|
3594
|
+
(timeline): timeline is SliderMixTimeline => timeline instanceof SliderMixTimeline,
|
|
3595
|
+
() => true,
|
|
3596
|
+
);
|
|
3597
|
+
const authoritative = sliders.filter((s) => s.setupPose.mix >= 1 && !mixKeyed.has(s));
|
|
3514
3598
|
if (authoritative.length < 2) {
|
|
3515
3599
|
return skip(
|
|
3516
3600
|
'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
|
|
@@ -3709,15 +3793,16 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3709
3793
|
* A physics timeline whose animation names no constraint carries
|
|
3710
3794
|
* `constraintIndex -1`, and `PhysicsConstraintTimeline.apply` reads that
|
|
3711
3795
|
* as every ACTIVE physics constraint whose own data declares that property
|
|
3712
|
-
* global.
|
|
3713
|
-
*
|
|
3714
|
-
*
|
|
3715
|
-
* have to guess at it.
|
|
3796
|
+
* global. A motion spec spells it `"physics": "*"` (issue #726) and a
|
|
3797
|
+
* foreign file the empty name; `unnamedPhysicsReach` is the one reading
|
|
3798
|
+
* of it, shared with `A23` and `A34`.
|
|
3716
3799
|
*/
|
|
3717
3800
|
const drivenBy = (timeline: Timeline & ConstraintTimeline): number[] => {
|
|
3718
3801
|
if (timeline.constraintIndex >= 0) return [timeline.constraintIndex];
|
|
3719
|
-
|
|
3720
|
-
|
|
3802
|
+
return unnamedPhysicsReach(
|
|
3803
|
+
timeline,
|
|
3804
|
+
data.constraints.filter((one) => one instanceof PhysicsConstraintData),
|
|
3805
|
+
).map((one) => data.constraints.indexOf(one));
|
|
3721
3806
|
};
|
|
3722
3807
|
let pairs = 0;
|
|
3723
3808
|
let resets = 0;
|
|
@@ -3931,6 +4016,64 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3931
4016
|
// static rig has no duration to compare — and the two must not print
|
|
3932
4017
|
// different verdicts over one skeleton's empty animation list.
|
|
3933
4018
|
if (data.animations.length === 0) return skip('A10_NO_NAN_AFTER_STEPPING', SKIP_NO_ANIMATION);
|
|
4019
|
+
/** Is this a mode `updateWorldTransform`'s switch has a case for? Read off the runtime's own enum. */
|
|
4020
|
+
const isMode = (inherit: unknown): boolean => typeof inherit === 'number' && Inherit[inherit] !== undefined;
|
|
4021
|
+
|
|
4022
|
+
// -- the bone's inheritance mode, posed at every `inherit` key ---------
|
|
4023
|
+
//
|
|
4024
|
+
// 🚨 The one bone timeline whose value is a NAME, and the only one that
|
|
4025
|
+
// can pose a NaN with a finite world: `SkeletonJson` resolves a key's mode
|
|
4026
|
+
// through `Utils.enumValue`, which folds the first letter's case and
|
|
4027
|
+
// nothing else, so `"NOSCALE"` resolves to `undefined` and the timeline's
|
|
4028
|
+
// `Float32Array` frame stores NaN. `InheritTimeline.apply` then sets
|
|
4029
|
+
// `pose.inherit = NaN`, `updateWorldTransform`'s switch matches no case,
|
|
4030
|
+
// and the bone keeps whatever world matrix it had — measured on a forged
|
|
4031
|
+
// two-bone chain: at the key the child's `a,b,c,d` equal the setup
|
|
4032
|
+
// Normal-mode pose to the last digit while the file says `noScale`. The
|
|
4033
|
+
// world position stays finite, so the loop below never saw it (#733).
|
|
4034
|
+
//
|
|
4035
|
+
// ⚠️ Posed AT each key rather than read off the stepping loop, because a
|
|
4036
|
+
// stepped value lives from its key to the next one and a sampling grid
|
|
4037
|
+
// can step over a short span entirely. What is judged is whether the
|
|
4038
|
+
// posed value IS a mode — which is this assertion's name exactly — and
|
|
4039
|
+
// not which mode: for any spelling the lookup resolves, the mode posed
|
|
4040
|
+
// is the mode written by construction of the same lookup, so an equality
|
|
4041
|
+
// here would be the parser agreeing with itself.
|
|
4042
|
+
const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
|
|
4043
|
+
let unresolved = 0;
|
|
4044
|
+
for (const [animName, rawAnim] of Object.entries(rawAnimations)) {
|
|
4045
|
+
if (!isObj(rawAnim) || !isObj(rawAnim.bones)) continue;
|
|
4046
|
+
for (const [boneName, timelines] of Object.entries(rawAnim.bones)) {
|
|
4047
|
+
if (!isObj(timelines) || !Array.isArray(timelines.inherit)) continue;
|
|
4048
|
+
if (!data.findAnimation(animName) || !data.findBone(boneName)) continue;
|
|
4049
|
+
for (const rawKey of timelines.inherit as unknown[]) {
|
|
4050
|
+
if (!isObj(rawKey)) continue;
|
|
4051
|
+
const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
|
|
4052
|
+
const skeleton = new Skeleton(data);
|
|
4053
|
+
const state = new AnimationState(new AnimationStateData(data));
|
|
4054
|
+
state.setAnimation(0, animName, false);
|
|
4055
|
+
skeleton.setupPose();
|
|
4056
|
+
skeleton.update(0);
|
|
4057
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
4058
|
+
state.update(time);
|
|
4059
|
+
state.apply(skeleton);
|
|
4060
|
+
skeleton.update(time);
|
|
4061
|
+
skeleton.updateWorldTransform(Physics.update);
|
|
4062
|
+
const posed = skeleton.findBone(boneName)?.appliedPose.inherit;
|
|
4063
|
+
if (isMode(posed)) continue;
|
|
4064
|
+
unresolved++;
|
|
4065
|
+
fail(
|
|
4066
|
+
'A10_NO_NAN_AFTER_STEPPING',
|
|
4067
|
+
`animation "${animName}" bone "${boneName}" inherit (t=${time}): the bone poses inheritance mode ` +
|
|
4068
|
+
`${String(posed)} — the key spells ${JSON.stringify(rawKey.inherit)}, which the runtime's mode lookup ` +
|
|
4069
|
+
`does not resolve (${BONE_INHERIT_KNOWN}), so no mode applies until the next key and the bone keeps ` +
|
|
4070
|
+
'the world rotation, scale and shear it had',
|
|
4071
|
+
);
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
}
|
|
4075
|
+
if (unresolved > 0) return;
|
|
4076
|
+
|
|
3934
4077
|
for (const anim of data.animations) {
|
|
3935
4078
|
const skeleton = new Skeleton(data);
|
|
3936
4079
|
const state = new AnimationState(new AnimationStateData(data));
|
|
@@ -3950,6 +4093,23 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3950
4093
|
fail('A10_NO_NAN_AFTER_STEPPING', `${anim.name}: bone "${bone.data.name}" world is (${pose.worldX}, ${pose.worldY})`);
|
|
3951
4094
|
return;
|
|
3952
4095
|
}
|
|
4096
|
+
// The setup half of the clause above: a bone's own `inherit` goes
|
|
4097
|
+
// through the same lookup, and a miss there loads `undefined` into
|
|
4098
|
+
// the setup pose, which every frame copies. Only reachable on a
|
|
4099
|
+
// file rigc did not write — `parseRigSpec` refuses the spelling.
|
|
4100
|
+
if (!isMode(pose.inherit)) {
|
|
4101
|
+
const rawBone = Array.isArray(raw?.bones)
|
|
4102
|
+
? (raw.bones as unknown[]).find((b) => isObj(b) && b.name === bone.data.name)
|
|
4103
|
+
: undefined;
|
|
4104
|
+
fail(
|
|
4105
|
+
'A10_NO_NAN_AFTER_STEPPING',
|
|
4106
|
+
`${anim.name}: bone "${bone.data.name}" poses inheritance mode ${String(pose.inherit)} — its setup ` +
|
|
4107
|
+
`spells inherit ${JSON.stringify(isObj(rawBone) ? rawBone.inherit : undefined)}, which the ` +
|
|
4108
|
+
`runtime's mode lookup does not resolve (${BONE_INHERIT_KNOWN}), so its world rotation, scale and shear ` +
|
|
4109
|
+
'are never computed — they stay 0 and everything the bone carries collapses to a point',
|
|
4110
|
+
);
|
|
4111
|
+
return;
|
|
4112
|
+
}
|
|
3953
4113
|
}
|
|
3954
4114
|
for (const slot of skeleton.slots) {
|
|
3955
4115
|
const c = slot.appliedPose.color;
|
|
@@ -4420,6 +4580,259 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4420
4580
|
);
|
|
4421
4581
|
}
|
|
4422
4582
|
});
|
|
4583
|
+
|
|
4584
|
+
// --- A46: a numbered series shows the frame the file states ------------
|
|
4585
|
+
//
|
|
4586
|
+
// 🚨 Every way this goes wrong loads without a word (issue #729, measured on
|
|
4587
|
+
// spine-core 4.3.13): a `sequence` block with no `count` loads a series of
|
|
4588
|
+
// no region; a `setup` past the end is clamped to the last frame; a key's
|
|
4589
|
+
// `mode` outside the seven loads as `hold`; an `index` past the end is
|
|
4590
|
+
// clamped and a fraction truncated; an advancing mode at an effective delay
|
|
4591
|
+
// of 0 never advances; and a `sequence` timeline on an attachment with no
|
|
4592
|
+
// block steps a one-region series and shows that region under every mode.
|
|
4593
|
+
//
|
|
4594
|
+
// ⭐ Two halves, and the second is the one only posing can see. The first
|
|
4595
|
+
// reads each of those off the FILE and names the value. The second POSES
|
|
4596
|
+
// every key at sampled times — mid-frame, so a float32 key time cannot land
|
|
4597
|
+
// a sample on a frame boundary — and compares the region the slot shows
|
|
4598
|
+
// against the one the file's own statement gives: the frame arithmetic of
|
|
4599
|
+
// `SequenceTimeline.applyToSlot` transcribed here, and the frame names of
|
|
4600
|
+
// `Sequence.getPath` transcribed in `attachmentRegionLookups`. Neither is
|
|
4601
|
+
// read off the loaded timeline, so the check is not the runtime agreeing
|
|
4602
|
+
// with itself.
|
|
4603
|
+
//
|
|
4604
|
+
// ⚠️ A sample where the slot shows some other attachment is not compared —
|
|
4605
|
+
// `applyToSlot` returns there, and a series that is hidden is not a series
|
|
4606
|
+
// showing the wrong frame. A timeline with no comparable sample at all is
|
|
4607
|
+
// counted in `stats.sequenceSamplesUnshown` rather than failed.
|
|
4608
|
+
check('A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES', () => {
|
|
4609
|
+
const NAME = 'A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES';
|
|
4610
|
+
type Series = { where: string; lookups: string[] | null; count: number; setup: number };
|
|
4611
|
+
/** The loaded attachment -> what the FILE says its series is. */
|
|
4612
|
+
const series = new Map<object, Series>();
|
|
4613
|
+
/** `skin\0slot\0placeholder` -> the loaded attachment, for the timelines to find. */
|
|
4614
|
+
const byJoin = new Map<string, object>();
|
|
4615
|
+
/** Attachments whose block was already refused above — their timelines say nothing more. */
|
|
4616
|
+
const refused = new Set<object>();
|
|
4617
|
+
const whole = (v: unknown, fallback: number): number | null =>
|
|
4618
|
+
v === undefined ? fallback : typeof v === 'number' && Number.isInteger(v) ? v : null;
|
|
4619
|
+
let blocks = 0;
|
|
4620
|
+
for (const skin of isObj(raw) && Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
|
|
4621
|
+
if (!isObj(skin) || !isObj(skin.attachments)) continue;
|
|
4622
|
+
const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
|
|
4623
|
+
const loadedSkin = data.findSkin(skinName);
|
|
4624
|
+
for (const [slotName, entries] of Object.entries(skin.attachments)) {
|
|
4625
|
+
if (!isObj(entries)) continue;
|
|
4626
|
+
const slotIndex = data.findSlot(slotName)?.index;
|
|
4627
|
+
for (const [placeholder, entry] of Object.entries(entries)) {
|
|
4628
|
+
if (!isObj(entry)) continue;
|
|
4629
|
+
const type = entry.type === undefined ? 'region' : entry.type;
|
|
4630
|
+
if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
|
|
4631
|
+
const loaded = slotIndex === undefined ? null : loadedSkin?.getAttachment(slotIndex, placeholder) ?? null;
|
|
4632
|
+
const where = `skin ${JSON.stringify(skinName)} slot ${JSON.stringify(slotName)} attachment ${JSON.stringify(placeholder)}`;
|
|
4633
|
+
if (loaded !== null) byJoin.set(`${skinName}\u0000${slotName}\u0000${placeholder}`, loaded);
|
|
4634
|
+
if (entry.sequence === undefined || entry.sequence === null) continue;
|
|
4635
|
+
blocks++;
|
|
4636
|
+
const seq = entry.sequence;
|
|
4637
|
+
const name = typeof entry.name === 'string' ? entry.name : placeholder;
|
|
4638
|
+
const path = typeof entry.path === 'string' ? entry.path : name;
|
|
4639
|
+
const count = isObj(seq) ? whole(seq.count, 0) : null;
|
|
4640
|
+
const setup = isObj(seq) ? whole(seq.setup, 0) : null;
|
|
4641
|
+
if ((count === null || setup === null || count < 1) && loaded !== null) refused.add(loaded);
|
|
4642
|
+
if (count === null || setup === null || count < 1) {
|
|
4643
|
+
fail(
|
|
4644
|
+
NAME,
|
|
4645
|
+
`${where}: the sequence states ${JSON.stringify(seq)}` +
|
|
4646
|
+
(isObj(seq) && seq.count === undefined
|
|
4647
|
+
? ' and no "count" — `readSequence` reads 0 (`SkeletonJson.js:644`), so the attachment loads holding no region and draws nothing'
|
|
4648
|
+
: ' — "count" is a whole number of at least 1 and "setup" a whole number, or the series the parser builds is not the one written'),
|
|
4649
|
+
);
|
|
4650
|
+
continue;
|
|
4651
|
+
}
|
|
4652
|
+
if ((setup < 0 || setup >= count) && loaded !== null) refused.add(loaded);
|
|
4653
|
+
if (setup < 0 || setup >= count) {
|
|
4654
|
+
fail(
|
|
4655
|
+
NAME,
|
|
4656
|
+
`${where}: the sequence's setup frame is ${setup} of a ${count}-frame series (frames 0 to ${count - 1}); ` +
|
|
4657
|
+
'`Sequence.resolveIndex` clamps it, so the setup pose shows ' +
|
|
4658
|
+
`${setup < 0 ? 'no frame at all' : `frame ${count - 1}, which the file does not name`}`,
|
|
4659
|
+
);
|
|
4660
|
+
continue;
|
|
4661
|
+
}
|
|
4662
|
+
if (loaded === null) continue; // A00/A08 own an attachment that did not load
|
|
4663
|
+
series.set(loaded, { where, lookups: attachmentRegionLookups(seq, path), count, setup });
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
|
|
4668
|
+
/** The frame `SequenceTimeline.applyToSlot` shows — transcribed, not called. */
|
|
4669
|
+
const frameOf = (mode: string, index: number, elapsed: number, delay: number, count: number): number => {
|
|
4670
|
+
if (mode === 'hold') return index;
|
|
4671
|
+
let i = index + Math.trunc(elapsed / delay + 0.00001);
|
|
4672
|
+
const n = count * 2 - 2;
|
|
4673
|
+
switch (mode) {
|
|
4674
|
+
case 'once':
|
|
4675
|
+
return Math.min(count - 1, i);
|
|
4676
|
+
case 'loop':
|
|
4677
|
+
return i % count;
|
|
4678
|
+
case 'pingpong':
|
|
4679
|
+
i = n === 0 ? 0 : i % n;
|
|
4680
|
+
return i >= count ? n - i : i;
|
|
4681
|
+
case 'onceReverse':
|
|
4682
|
+
return Math.max(count - 1 - i, 0);
|
|
4683
|
+
case 'loopReverse':
|
|
4684
|
+
return count - 1 - (i % count);
|
|
4685
|
+
default: // pingpongReverse
|
|
4686
|
+
i = n === 0 ? 0 : (i + count - 1) % n;
|
|
4687
|
+
return i >= count ? n - i : i;
|
|
4688
|
+
}
|
|
4689
|
+
};
|
|
4690
|
+
|
|
4691
|
+
let timelines = 0;
|
|
4692
|
+
let compared = 0;
|
|
4693
|
+
let unshown = 0;
|
|
4694
|
+
const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
|
|
4695
|
+
for (const [animName, anim] of Object.entries(rawAnimations)) {
|
|
4696
|
+
if (!isObj(anim) || !isObj(anim.attachments)) continue;
|
|
4697
|
+
for (const [skinName, perSkin] of Object.entries(anim.attachments)) {
|
|
4698
|
+
if (!isObj(perSkin)) continue;
|
|
4699
|
+
for (const [slotName, perSlot] of Object.entries(perSkin)) {
|
|
4700
|
+
if (!isObj(perSlot)) continue;
|
|
4701
|
+
for (const [placeholder, perAttachment] of Object.entries(perSlot)) {
|
|
4702
|
+
if (!isObj(perAttachment) || !Array.isArray(perAttachment.sequence)) continue;
|
|
4703
|
+
const keys = perAttachment.sequence as unknown[];
|
|
4704
|
+
if (keys.length === 0) continue; // `readAnimation` skips it; A34 owns an empty timeline
|
|
4705
|
+
timelines++;
|
|
4706
|
+
const where = `animation ${JSON.stringify(animName)} ${skinName}/${slotName}/${placeholder} sequence`;
|
|
4707
|
+
const keyed = byJoin.get(`${skinName}\u0000${slotName}\u0000${placeholder}`);
|
|
4708
|
+
if (keyed === undefined) continue; // the parser throws on a missing target: A00's
|
|
4709
|
+
if (refused.has(keyed)) continue; // its block is already named above
|
|
4710
|
+
const own = series.get(keyed);
|
|
4711
|
+
if (own === undefined) {
|
|
4712
|
+
fail(
|
|
4713
|
+
NAME,
|
|
4714
|
+
`${where}: the timeline steps an attachment that carries no "sequence" block. The parser gives it a ` +
|
|
4715
|
+
'series of ONE region (`readSequence(null)` is `new Sequence(1, false)`), so every mode shows that ' +
|
|
4716
|
+
'region at every time — measured: a "loop" key on a plain region showed it throughout',
|
|
4717
|
+
);
|
|
4718
|
+
continue;
|
|
4719
|
+
}
|
|
4720
|
+
// -- the keys, as the file states them --------------------------
|
|
4721
|
+
type Key = { time: number; mode: string; index: number; delay: number };
|
|
4722
|
+
const read: Key[] = [];
|
|
4723
|
+
let carried = 0;
|
|
4724
|
+
let malformed = false;
|
|
4725
|
+
keys.forEach((rawKey, k) => {
|
|
4726
|
+
const key = isObj(rawKey) ? rawKey : {};
|
|
4727
|
+
const at = `${where} key ${k}`;
|
|
4728
|
+
const time = typeof key.time === 'number' ? key.time : 0;
|
|
4729
|
+
const mode = key.mode === undefined ? 'hold' : key.mode;
|
|
4730
|
+
const index = key.index === undefined ? 0 : key.index;
|
|
4731
|
+
if (key.delay !== undefined) carried = typeof key.delay === 'number' ? key.delay : Number.NaN;
|
|
4732
|
+
if (typeof mode !== 'string' || !(SEQUENCE_MODES as readonly string[]).includes(mode)) {
|
|
4733
|
+
malformed = true;
|
|
4734
|
+
fail(
|
|
4735
|
+
NAME,
|
|
4736
|
+
`${at} (t=${time}): mode ${JSON.stringify(key.mode)} is not one of the ${SEQUENCE_MODES.length} the ` +
|
|
4737
|
+
`format has (${SEQUENCE_MODES.join(', ')}); the parser reads \`SequenceMode[mode]\`, which is ` +
|
|
4738
|
+
'undefined, stores mode bits 0, and the key plays as "hold"',
|
|
4739
|
+
);
|
|
4740
|
+
return;
|
|
4741
|
+
}
|
|
4742
|
+
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index >= own.count) {
|
|
4743
|
+
malformed = true;
|
|
4744
|
+
fail(
|
|
4745
|
+
NAME,
|
|
4746
|
+
`${at} (t=${time}): index ${JSON.stringify(key.index)} is not a frame of the ${own.count}-frame series ` +
|
|
4747
|
+
`(0 to ${own.count - 1}); the runtime stores \`index << 4\`, truncating a fraction, and ` +
|
|
4748
|
+
'`Sequence.resolveIndex` clamps a frame past the end to the last one',
|
|
4749
|
+
);
|
|
4750
|
+
return;
|
|
4751
|
+
}
|
|
4752
|
+
if (mode !== 'hold' && !(carried > 0)) {
|
|
4753
|
+
malformed = true;
|
|
4754
|
+
fail(
|
|
4755
|
+
NAME,
|
|
4756
|
+
`${at} (t=${time}): "${mode}" at a delay of ${String(carried)}${key.delay === undefined ? ' (carried from the key before, 0 on the first)' : ''} — ` +
|
|
4757
|
+
'the frame advances by `(time - keyTime) / delay`, and at 0 that is Infinity, `Infinity | 0` is 0, ' +
|
|
4758
|
+
'and the key shows its first frame throughout: "hold" spelt as another mode',
|
|
4759
|
+
);
|
|
4760
|
+
return;
|
|
4761
|
+
}
|
|
4762
|
+
read.push({ time, mode, index, delay: carried });
|
|
4763
|
+
});
|
|
4764
|
+
if (malformed) continue;
|
|
4765
|
+
|
|
4766
|
+
// -- the pose, sampled ------------------------------------------
|
|
4767
|
+
const animation = data.findAnimation(animName);
|
|
4768
|
+
const slotIndex = data.findSlot(slotName)?.index;
|
|
4769
|
+
if (animation === null || slotIndex === undefined) continue; // A00's
|
|
4770
|
+
/** `key` is the index into `read`, or -1 before the first key (the setup frame). */
|
|
4771
|
+
const samples: Array<{ time: number; key: number; steps: number }> = [];
|
|
4772
|
+
const end = animation.duration;
|
|
4773
|
+
if (read[0].time > 0) samples.push({ time: read[0].time / 2, key: -1, steps: 0 });
|
|
4774
|
+
read.forEach((key, k) => {
|
|
4775
|
+
const until = k + 1 < read.length ? read[k + 1].time : end;
|
|
4776
|
+
if (key.mode === 'hold') {
|
|
4777
|
+
samples.push({ time: key.time, key: k, steps: 0 });
|
|
4778
|
+
return;
|
|
4779
|
+
}
|
|
4780
|
+
// Mid-frame, and enough steps to wrap every mode at least once.
|
|
4781
|
+
for (let step = 0; step < own.count * 2 + 2; step++) {
|
|
4782
|
+
const time = key.time + (step + 0.5) * key.delay;
|
|
4783
|
+
if (time >= until || time > end) break;
|
|
4784
|
+
samples.push({ time, key: k, steps: step + 0.5 });
|
|
4785
|
+
}
|
|
4786
|
+
});
|
|
4787
|
+
let shownHere = 0;
|
|
4788
|
+
for (const sample of samples) {
|
|
4789
|
+
const skeleton = new Skeleton(data);
|
|
4790
|
+
const state = new AnimationState(new AnimationStateData(data));
|
|
4791
|
+
state.setAnimation(0, animName, false);
|
|
4792
|
+
skeleton.setupPose();
|
|
4793
|
+
skeleton.update(0);
|
|
4794
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
4795
|
+
state.update(sample.time);
|
|
4796
|
+
state.apply(skeleton);
|
|
4797
|
+
const pose = skeleton.slots[slotIndex].appliedPose;
|
|
4798
|
+
const shown = pose.attachment;
|
|
4799
|
+
// The slot must show the keyed attachment or one playing its
|
|
4800
|
+
// timelines — the only case `applyToSlot` writes.
|
|
4801
|
+
if (shown === null || (shown !== keyed && shown.timelineAttachment !== keyed)) continue;
|
|
4802
|
+
const drawn = series.get(shown);
|
|
4803
|
+
if (drawn === undefined || drawn.lookups === null) continue;
|
|
4804
|
+
shownHere++;
|
|
4805
|
+
compared++;
|
|
4806
|
+
// The frame count the runtime folds by is the SHOWN attachment's:
|
|
4807
|
+
// a link with a series of its own steps it by its source's keys.
|
|
4808
|
+
const key = sample.key < 0 ? null : read[sample.key];
|
|
4809
|
+
const want = key === null ? drawn.setup : frameOf(key.mode, key.index, sample.time - key.time, key.delay, drawn.count);
|
|
4810
|
+
const sequence = (shown as RegionAttachment | MeshAttachment).sequence;
|
|
4811
|
+
const region = (sequence.regions[sequence.resolveIndex(pose)] as TextureAtlasRegion | null | undefined)?.name ?? null;
|
|
4812
|
+
if (region !== drawn.lookups[want]) {
|
|
4813
|
+
fail(
|
|
4814
|
+
NAME,
|
|
4815
|
+
`${where} (t=${Number(sample.time.toFixed(4))}): the slot shows region ${JSON.stringify(region)}, and ` +
|
|
4816
|
+
`the file states frame ${want} of ${drawn.count} — ${JSON.stringify(drawn.lookups[want])} — ` +
|
|
4817
|
+
(key === null
|
|
4818
|
+
? 'the setup frame, before the first key'
|
|
4819
|
+
: `key ${sample.key} plays "${key.mode}" from frame ${key.index} every ${key.delay}s, ` +
|
|
4820
|
+
`${sample.steps} delay(s) in`),
|
|
4821
|
+
);
|
|
4822
|
+
break;
|
|
4823
|
+
}
|
|
4824
|
+
}
|
|
4825
|
+
if (shownHere === 0) unshown++;
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
4828
|
+
}
|
|
4829
|
+
}
|
|
4830
|
+
if (blocks === 0 && timelines === 0) return skip(NAME, SKIP_NO_SEQUENCE);
|
|
4831
|
+
stats.sequenceBlocks = blocks;
|
|
4832
|
+
stats.sequenceTimelines = timelines;
|
|
4833
|
+
stats.sequenceSamples = compared;
|
|
4834
|
+
if (unshown > 0) stats.sequenceSamplesUnshown = unshown;
|
|
4835
|
+
});
|
|
4423
4836
|
}
|
|
4424
4837
|
|
|
4425
4838
|
// --- A06 / A17 / A19: the atlas against the PNGs on disk ------------------
|
|
@@ -4486,7 +4899,9 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4486
4899
|
);
|
|
4487
4900
|
}
|
|
4488
4901
|
const info = header.info;
|
|
4489
|
-
|
|
4902
|
+
const onPage = atlas.regions.filter((region) => region.page.name === page.name);
|
|
4903
|
+
const gridSaid = info === null ? null : pageGridSentence(page, info, onPage);
|
|
4904
|
+
if (gridSaid !== null) {
|
|
4490
4905
|
// 🔒 **Still validity, and now for a measured reason rather than an
|
|
4491
4906
|
// inherited one** (issue #715). The card that opened this expected the
|
|
4492
4907
|
// clause to move behind the profile switch once the runtime's mapping
|
|
@@ -4494,29 +4909,11 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4494
4909
|
// draws. What settles it the other way is that the sentence below can
|
|
4495
4910
|
// name a repair the FORMAT already provides (`scale:`), and that two of
|
|
4496
4911
|
// the three readers a wrong grid breaks are rigc's own and run under
|
|
4497
|
-
// both profiles.
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
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
|
-
}
|
|
4912
|
+
// both profiles. The sentence, and the one derivation of the ratio under
|
|
4913
|
+
// it, is `pageGridSentence` in [`src/atlas.ts`](atlas.ts): the compiler's
|
|
4914
|
+
// region lift states the same one when it is asked for texels on such a
|
|
4915
|
+
// page (issue #750), so the two cannot come to describe one page two ways.
|
|
4916
|
+
fail('A06_ATLAS_PAGE_SIZE_MATCHES_PNG', gridSaid);
|
|
4520
4917
|
}
|
|
4521
4918
|
// 📐 PROFILE, from here down. `pma: false` and no rotation are rigc's atlas
|
|
4522
4919
|
// CONVENTION, not the atlas format's rules — a packed page with
|