spine-rigc 1.9.0 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.ts +48 -17
- package/docs/AUTHORING.md +2 -2
- package/package.json +1 -1
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/bone_timelines.ts +56 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +34 -16
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/index.ts +83 -2
- package/src/assertions/model/linked_meshes.ts +2 -2
- package/src/assertions/model/mesh_attachments.ts +1 -1
- package/src/assertions/model/region_joins.ts +20 -12
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +18 -8
- package/src/assertions/model/skin_members.ts +5 -3
- package/src/assertions/model/skin_meshes.ts +1 -1
- package/src/assertions/model/slider_composition.ts +191 -0
- package/src/assertions/model/slot_colour.ts +5 -4
- package/src/assertions/model/stepped_poses.ts +89 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +9 -8
- package/src/bonedist.ts +18 -6
- package/src/compile.ts +114 -14
- package/src/core/additive.ts +468 -0
- package/src/core/constraints.ts +12 -2
- package/src/core/constraints_physics.ts +31 -8
- package/src/core/raw.ts +77 -16
- package/src/core/walk.ts +202 -0
- package/src/deformmeasure.ts +35 -2270
- package/src/deformstructure.ts +60 -18
- package/src/deformsurvey.ts +2295 -0
- package/src/nonfinite.ts +54 -0
- package/src/render.ts +200 -59
- package/src/render_core.ts +5 -0
- package/src/validate.ts +791 -1558
package/src/validate.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { dirname, resolve } from 'node:path';
|
|
17
17
|
import {
|
|
18
|
+
type Animation,
|
|
18
19
|
AnimationState,
|
|
19
20
|
AnimationStateData,
|
|
20
21
|
AtlasAttachmentLoader,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
PhysicsConstraintTimeline,
|
|
41
42
|
Property,
|
|
42
43
|
RegionAttachment,
|
|
44
|
+
SequenceTimeline as RuntimeSequenceTimeline,
|
|
43
45
|
Skeleton,
|
|
44
46
|
SkeletonJson,
|
|
45
47
|
SliderData,
|
|
@@ -58,36 +60,25 @@ import {
|
|
|
58
60
|
// them, so it is already on `package.json`'s `files` allowlist — see CLAUDE.md.
|
|
59
61
|
// A19 needs the DECODED page, not its header, to measure one region's own
|
|
60
62
|
// rectangle on a shared page.
|
|
61
|
-
import {
|
|
62
|
-
BEZIER_POINTS,
|
|
63
|
-
curveStorage,
|
|
64
|
-
surveyDeformKeys,
|
|
65
|
-
unreachableWhy,
|
|
66
|
-
type DeformDialDispute,
|
|
67
|
-
type DeformDialTie,
|
|
68
|
-
type DeformReach,
|
|
69
|
-
type DialSpan,
|
|
70
|
-
} from './deformmeasure.ts';
|
|
63
|
+
import { BEZIER_POINTS, curveStorage, surveyDeformKeys } from './deformmeasure.ts';
|
|
71
64
|
import {
|
|
72
65
|
LEGACY_BONE_INHERIT_KEY,
|
|
73
66
|
spineGeneration,
|
|
74
67
|
TOPLEVEL_CONSTRAINT_ARRAYS,
|
|
75
68
|
type SpineGeneration,
|
|
76
69
|
} from './generation.ts';
|
|
77
|
-
import {
|
|
70
|
+
import { posedNumbersOf } from './render.ts';
|
|
78
71
|
import { BONE_INHERIT_KNOWN } from './rig.ts';
|
|
79
72
|
import {
|
|
80
73
|
CHANNELS_BY_KIND,
|
|
81
|
-
float32Step,
|
|
82
74
|
physicsRuleFor,
|
|
83
|
-
SEQUENCE_MODES,
|
|
84
75
|
SLOT_COLOR_CHANNELS,
|
|
85
76
|
walkTimelines,
|
|
86
77
|
} from './timelines.ts';
|
|
87
78
|
import type { RigInfo } from './types.ts';
|
|
88
79
|
import { ASSERTION_KIND, kindRunsUnder, type AssertionProfile } from './assertions/kinds.ts';
|
|
89
80
|
import { verdictHarness, type Failure } from './assertions/harness.ts';
|
|
90
|
-
import {
|
|
81
|
+
import { isObj, type Json } from './assertions/values.ts';
|
|
91
82
|
import type { SkinEntryFacts } from './assertions/facts/skin_entries.ts';
|
|
92
83
|
import type { AtlasPageFacts } from './assertions/facts/atlas_pages.ts';
|
|
93
84
|
import type { SlotColourFacts, SlotTimelines } from './assertions/facts/slot_colour.ts';
|
|
@@ -129,18 +120,39 @@ import { a42DrivenConstraintsUpdateAfterTheirDriver } from './assertions/bodies/
|
|
|
129
120
|
import { a44LinkedMeshStatesNoGeometryOfItsOwn } from './assertions/bodies/a44.ts';
|
|
130
121
|
import { a47IkConstraintNotMutedThroughout } from './assertions/bodies/a47.ts';
|
|
131
122
|
import { a48TransformConstraintNotMutedThroughout } from './assertions/bodies/a48.ts';
|
|
123
|
+
import type { RosterBone, RosterSlot, SkeletonRosterFacts } from './assertions/facts/skeleton_roster.ts';
|
|
124
|
+
import type { BoneTimelineFacts, BoneTimelines } from './assertions/facts/bone_timelines.ts';
|
|
125
|
+
import type { EventKeyEntry, EventKeyFacts } from './assertions/facts/event_keys.ts';
|
|
126
|
+
import { a12NoDarkColor } from './assertions/bodies/a12.ts';
|
|
127
|
+
import { a24AxisSpaceStroke } from './assertions/bodies/a24.ts';
|
|
128
|
+
import { a25DetachedBoneParentage } from './assertions/bodies/a25.ts';
|
|
129
|
+
import { a26SlotDrawOrder } from './assertions/bodies/a26.ts';
|
|
130
|
+
import { a29StrokeWithinContactDepth } from './assertions/bodies/a29.ts';
|
|
131
|
+
import { a30StrokeWithinCapContainment } from './assertions/bodies/a30.ts';
|
|
132
|
+
import { a32EventKeysResolve, eventKeyAt } from './assertions/bodies/a32.ts';
|
|
132
133
|
import {
|
|
133
134
|
SKIP_NO_ANIMATION,
|
|
134
135
|
SKIP_NO_ATLAS,
|
|
135
136
|
SKIP_NO_ATLAS_PAGE,
|
|
136
|
-
SKIP_NO_DECLARED_DURATION,
|
|
137
137
|
SKIP_NO_MESH_ATTACHMENT,
|
|
138
138
|
SKIP_NO_POSE,
|
|
139
|
-
SKIP_NO_SEQUENCE,
|
|
140
139
|
SKIP_NO_SKELETON,
|
|
141
140
|
SKIP_NO_TIMELINE,
|
|
142
|
-
SKIP_NO_TWO_COLOR_TINT,
|
|
143
141
|
} from './assertions/reasons.ts';
|
|
142
|
+
import type { DeformSurveyFacts } from './assertions/facts/deform_survey.ts';
|
|
143
|
+
import type { AnimationDurationFacts } from './assertions/facts/animation_durations.ts';
|
|
144
|
+
import type { TwoColourFacts } from './assertions/facts/two_colour.ts';
|
|
145
|
+
import { entryAddress, type EntryAddress, type SequenceFacts, type SequenceSkinEntry, type SequenceTimeline } from './assertions/facts/sequences.ts';
|
|
146
|
+
import { a39DeformKeepsTriangleWinding } from './assertions/bodies/a39.ts';
|
|
147
|
+
import { a09AnimationDurationMatchesSpec } from './assertions/bodies/a09.ts';
|
|
148
|
+
import { a43TwoColorTintLoadsAndPosesAsWritten } from './assertions/bodies/a43.ts';
|
|
149
|
+
import { a46SequenceAttachmentsShowTheFrameTheFileStates } from './assertions/bodies/a46.ts';
|
|
150
|
+
import type { SliderCompositionFacts, SliderFact, SliderTimelineFact } from './assertions/facts/slider_composition.ts';
|
|
151
|
+
import type { ConstraintTargetFacts, TargetAnimation, TargetKeyArray } from './assertions/facts/constraint_targets.ts';
|
|
152
|
+
import { a34ConstraintTimelineTargets } from './assertions/bodies/a34.ts';
|
|
153
|
+
import { a40SlidersComposeOnASharedTarget } from './assertions/bodies/a40.ts';
|
|
154
|
+
import { a10NoNanAfterStepping } from './assertions/bodies/a10.ts';
|
|
155
|
+
import type { SteppedFrame, SteppedPoseFacts } from './assertions/facts/stepped_poses.ts';
|
|
144
156
|
|
|
145
157
|
export type { Failure } from './assertions/harness.ts';
|
|
146
158
|
|
|
@@ -284,62 +296,9 @@ export interface ValidateReport {
|
|
|
284
296
|
stats: Record<string, number | string>;
|
|
285
297
|
}
|
|
286
298
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
*
|
|
291
|
-
* Empty on the track, which is what every animation no slider applies gets — so
|
|
292
|
-
* a message about a rig with no sliders in it reads exactly as it always has.
|
|
293
|
-
*/
|
|
294
|
-
function frameClause(reach: DeformReach): string {
|
|
295
|
-
return reach.kind === 'slider' ? ` (applied by slider "${reach.slider}", not played on a track)` : '';
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* A dial's reach as A39's stats line spells it, or `none` when it can select no
|
|
300
|
-
* part of its animation at all.
|
|
301
|
-
*
|
|
302
|
-
* Six decimals because a key time has six: a reach whose end is printed coarser
|
|
303
|
-
* than the times it is compared against cannot be read against them.
|
|
304
|
-
*/
|
|
305
|
-
function dialSpanText(span: DialSpan | null): string {
|
|
306
|
-
return span === null ? 'none' : `${span.lo.toFixed(6)}..${span.hi.toFixed(6)}s`;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* One disputed dial on A39's stats line — both answers, both reaches, and the
|
|
311
|
-
* frames the artifact's answer could not have posed (issue #427).
|
|
312
|
-
*
|
|
313
|
-
* ⭐ **`outside:` is always there, `none` included.** The comparison it reports is
|
|
314
|
-
* what decides whether a disagreement changed anything the survey measured, and a
|
|
315
|
-
* comparison that came out equal must not look like one nobody made. It is the
|
|
316
|
-
* difference between "both answers pose the same frames, so the disagreement is a
|
|
317
|
-
* fact about rigc and not about this rig" and "these key times were surveyed
|
|
318
|
-
* through a field the skeleton does not name and no settable value of the one it
|
|
319
|
-
* does reaches them".
|
|
320
|
-
*
|
|
321
|
-
* ⚠️ No spaces anywhere in it: the stats line is `k=v` pairs joined by spaces, and
|
|
322
|
-
* a value with a space in it turns one reading into two.
|
|
323
|
-
*/
|
|
324
|
-
function dialDisputeText(dispute: DeformDialDispute): string {
|
|
325
|
-
const stated = dispute.statedResponse === null ? 'unmeasured' : dispute.statedResponse.toExponential(3);
|
|
326
|
-
return (
|
|
327
|
-
`${dispute.slider}|artifact:${dispute.bone}.${dispute.stated}@${stated}` +
|
|
328
|
-
`|reaches:${dialSpanText(dispute.statedReach)}` +
|
|
329
|
-
`|probe:${dispute.bone}.${dispute.drive}@${dispute.driveResponse.toExponential(3)}` +
|
|
330
|
-
`|reaches:${dialSpanText(dispute.driveReach)}` +
|
|
331
|
-
`|outside:${dispute.outside.length === 0 ? 'none' : dispute.outside.map((t) => `${t.toFixed(6)}s`).join('+')}`
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** One tied dial on the same line, in the shape that cannot be read as a dispute. */
|
|
336
|
-
function dialTieText(tie: DeformDialTie): string {
|
|
337
|
-
const rivals = tie.rivals.map((r) => `${tie.bone}.${r.field}@${r.response.toExponential(3)}`).join('+');
|
|
338
|
-
return `${tie.slider}|artifact:${tie.bone}.${tie.drive}@${tie.driveResponse.toExponential(3)}|tied:${rivals}`;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const FRAME = 1 / 60;
|
|
342
|
-
const STEP_FRAMES = 120;
|
|
299
|
+
// The four sentences A39 prints a frame, a dial's reach, a dispute and a tie
|
|
300
|
+
// with, and A09's one-frame slack, are `./assertions/bodies/a39.ts`'s and
|
|
301
|
+
// `./assertions/bodies/a09.ts`'s since issue #1025 (cut 4c-3).
|
|
343
302
|
|
|
344
303
|
/**
|
|
345
304
|
* The constraint groups that spell `group.<constraint>.<timeline>` — a
|
|
@@ -696,18 +655,18 @@ export type TimelineAddBehaviour = 'accumulates' | 'overwrites' | 'inert';
|
|
|
696
655
|
*/
|
|
697
656
|
const PROBE_DISPLACEMENT = 0.375;
|
|
698
657
|
|
|
699
|
-
/** Every own value of a pose object a timeline could have written, as text. */
|
|
700
|
-
function
|
|
701
|
-
const parts: string
|
|
658
|
+
/** Every own value of a pose object a timeline could have written, each as its key and its text. */
|
|
659
|
+
function poseParts(pose: object): Array<[string, string]> {
|
|
660
|
+
const parts: Array<[string, string]> = [];
|
|
702
661
|
for (const key of Object.keys(pose).sort()) {
|
|
703
662
|
const value = (pose as Record<string, unknown>)[key];
|
|
704
663
|
if (value === null || value === undefined || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
|
|
705
|
-
parts.push(
|
|
664
|
+
parts.push([key, String(value)]);
|
|
706
665
|
continue;
|
|
707
666
|
}
|
|
708
667
|
if (Array.isArray(value)) {
|
|
709
668
|
const entries: unknown[] = value;
|
|
710
|
-
if (entries.every((one) => typeof one === 'number')) parts.push(
|
|
669
|
+
if (entries.every((one) => typeof one === 'number')) parts.push([key, `[${entries.join(',')}]`]);
|
|
711
670
|
continue;
|
|
712
671
|
}
|
|
713
672
|
if (typeof value !== 'object') continue;
|
|
@@ -717,12 +676,17 @@ function poseReading(pose: object): string {
|
|
|
717
676
|
// structure — a bone back-reference, a slot's data — is either an identity
|
|
718
677
|
// (read by name below) or something no timeline writes.
|
|
719
678
|
if (keys.length > 0 && keys.every((one) => typeof record[one] === 'number')) {
|
|
720
|
-
parts.push(
|
|
679
|
+
parts.push([key, `{${keys.map((one) => `${one}:${String(record[one])}`).join(',')}}`]);
|
|
721
680
|
} else if (typeof record.name === 'string') {
|
|
722
|
-
parts.push(
|
|
681
|
+
parts.push([key, `@${record.name}`]);
|
|
723
682
|
}
|
|
724
683
|
}
|
|
725
|
-
return parts
|
|
684
|
+
return parts;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Every own value of a pose object a timeline could have written, as text. */
|
|
688
|
+
function poseReading(pose: object): string {
|
|
689
|
+
return poseParts(pose).map(([key, text]) => `${key}=${text}`).join(',');
|
|
726
690
|
}
|
|
727
691
|
|
|
728
692
|
/** The whole of what a timeline could have changed, in one comparable string. */
|
|
@@ -848,6 +812,73 @@ export function timelineAddBehaviour(data: ReturnType<SkeletonJson['readSkeleton
|
|
|
848
812
|
return wrote ? 'overwrites' : 'inert';
|
|
849
813
|
}
|
|
850
814
|
|
|
815
|
+
/** One skin under one start state of `timelineAddBehaviour`'s probe: what the cell reads, and every pose field the applications moved. */
|
|
816
|
+
export interface TimelineAddCell {
|
|
817
|
+
/** The skin worn, `(none)` for no skin set. */
|
|
818
|
+
view: string;
|
|
819
|
+
state: 'setup' | 'displaced';
|
|
820
|
+
/** `A` a second application moved the pose at some time, `W` only the first did, `-` neither ever did. */
|
|
821
|
+
letter: 'A' | 'W' | '-';
|
|
822
|
+
/** Each field the first or the second application moved, at each time: `bone:<name>.<key>`, `slot:<name>.<key>` or `constraint:<name>.<key>`, with its three readings. */
|
|
823
|
+
changed: Array<{ field: string; t: number; before: string; once: string; twice: string }>;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* `timelineAddBehaviour`'s probe, every cell kept: the same skins, start states,
|
|
828
|
+
* times and arguments, read per field rather than as one string — what the
|
|
829
|
+
* core suite's `CO27` holds the core's additive probe (`src/core/additive.ts`)
|
|
830
|
+
* to, cell by cell and value by value (issue #1025, cut 4c-5). The class the
|
|
831
|
+
* cells read is held to `timelineAddBehaviour`'s own answer on every timeline
|
|
832
|
+
* there, so the two cannot drift.
|
|
833
|
+
*/
|
|
834
|
+
export function timelineAddCells(data: ReturnType<SkeletonJson['readSkeletonData']>, timeline: Timeline): TimelineAddCell[] {
|
|
835
|
+
const fields = (skeleton: Skeleton): Map<string, string> => {
|
|
836
|
+
const out = new Map<string, string>();
|
|
837
|
+
const take = (kind: string, name: string, pose: object): void => {
|
|
838
|
+
for (const [key, text] of poseParts(pose)) out.set(`${kind}:${name}.${key}`, text);
|
|
839
|
+
};
|
|
840
|
+
for (const bone of skeleton.bones) take('bone', bone.data.name, bone.appliedPose);
|
|
841
|
+
for (const slot of skeleton.slots) take('slot', slot.data.name, slot.appliedPose);
|
|
842
|
+
for (const constraint of skeleton.constraints) take('constraint', constraint.data.name, constraint.appliedPose as object);
|
|
843
|
+
out.set('drawOrder', skeleton.drawOrder.appliedPose.map((slot) => slot.data.name).join('>'));
|
|
844
|
+
return out;
|
|
845
|
+
};
|
|
846
|
+
const cells: TimelineAddCell[] = [];
|
|
847
|
+
for (const skin of [null, ...data.skins]) {
|
|
848
|
+
const skeleton = new Skeleton(data);
|
|
849
|
+
if (skin !== null) skeleton.setSkin(skin);
|
|
850
|
+
probeStartStates(skeleton).forEach((displace, at) => {
|
|
851
|
+
const cell: TimelineAddCell = { view: skin?.name ?? '(none)', state: at === 0 ? 'setup' : 'displaced', letter: '-', changed: [] };
|
|
852
|
+
for (const time of probeTimes(timeline)) {
|
|
853
|
+
skeleton.setupPose();
|
|
854
|
+
for (const bone of skeleton.bones) bone.resetConstrained();
|
|
855
|
+
for (const slot of skeleton.slots) slot.resetConstrained();
|
|
856
|
+
for (const constraint of skeleton.constraints) constraint.resetConstrained();
|
|
857
|
+
skeleton.drawOrder.resetConstrained();
|
|
858
|
+
displace();
|
|
859
|
+
const before = fields(skeleton);
|
|
860
|
+
timeline.apply(skeleton, time, time, null, 1, MixFrom.current, true, false, true);
|
|
861
|
+
const once = fields(skeleton);
|
|
862
|
+
timeline.apply(skeleton, time, time, null, 1, MixFrom.current, true, false, true);
|
|
863
|
+
const twice = fields(skeleton);
|
|
864
|
+
let wrote = false;
|
|
865
|
+
let again = false;
|
|
866
|
+
for (const [field, b] of before) {
|
|
867
|
+
const o = once.get(field) ?? '';
|
|
868
|
+
const w = twice.get(field) ?? '';
|
|
869
|
+
if (o !== b) wrote = true;
|
|
870
|
+
if (w !== o) again = true;
|
|
871
|
+
if (o !== b || w !== o) cell.changed.push({ field, t: time, before: b, once: o, twice: w });
|
|
872
|
+
}
|
|
873
|
+
if (again) cell.letter = 'A';
|
|
874
|
+
else if (wrote && cell.letter === '-') cell.letter = 'W';
|
|
875
|
+
}
|
|
876
|
+
cells.push(cell);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
return cells;
|
|
880
|
+
}
|
|
881
|
+
|
|
851
882
|
/**
|
|
852
883
|
* Where two texts first differ, as A18 names it: the 1-based line and each
|
|
853
884
|
* side's line, trimmed and cut to 120 characters.
|
|
@@ -898,6 +929,9 @@ export function runtimeFacts(skeletonText: string, atlasText: string): {
|
|
|
898
929
|
regionJoins: RegionJoinFacts;
|
|
899
930
|
atlasRegions: AtlasRegionFacts;
|
|
900
931
|
stage: StageFacts;
|
|
932
|
+
skeletonRoster: SkeletonRosterFacts;
|
|
933
|
+
boneTimelines: BoneTimelineFacts;
|
|
934
|
+
eventKeys: EventKeyFacts;
|
|
901
935
|
} | null {
|
|
902
936
|
let atlas: TextureAtlas;
|
|
903
937
|
let data: ReturnType<SkeletonJson['readSkeletonData']>;
|
|
@@ -918,9 +952,140 @@ export function runtimeFacts(skeletonText: string, atlasText: string): {
|
|
|
918
952
|
regionJoins: spineRegionJoins(atlasText, raw),
|
|
919
953
|
atlasRegions: { atlas },
|
|
920
954
|
stage: spineStage(data),
|
|
955
|
+
skeletonRoster: rawSkeletonRoster(raw),
|
|
956
|
+
boneTimelines: rawBoneTimelines(raw),
|
|
957
|
+
eventKeys: rawEventKeys(raw),
|
|
921
958
|
};
|
|
922
959
|
}
|
|
923
960
|
|
|
961
|
+
/**
|
|
962
|
+
* The runtime's supply of `SkeletonRosterFacts` (issue #1025, cut 4c-4): the
|
|
963
|
+
* skeleton JSON's `bones` and `slots`, read as A12, A25 and A26 always read
|
|
964
|
+
* them — a slot is any object in the array, named `String(name)`, dark where
|
|
965
|
+
* the key is present; a bone is an object with a string name, its parent the
|
|
966
|
+
* string the file states or `null`. Off the raw JSON because those three run
|
|
967
|
+
* whatever the round trip did. The model side's supply is
|
|
968
|
+
* `./assertions/model/skeleton_roster.ts`.
|
|
969
|
+
*/
|
|
970
|
+
export function rawSkeletonRoster(raw: Json | null): SkeletonRosterFacts {
|
|
971
|
+
const bones: RosterBone[] = [];
|
|
972
|
+
for (const bone of Array.isArray(raw?.bones) ? (raw.bones as unknown[]) : []) {
|
|
973
|
+
if (isObj(bone) && typeof bone.name === 'string') bones.push({ name: bone.name, parent: typeof bone.parent === 'string' ? bone.parent : null });
|
|
974
|
+
}
|
|
975
|
+
const slots: RosterSlot[] = (Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : []).filter(isObj).map((s) => ({ name: String(s.name), dark: 'dark' in s }));
|
|
976
|
+
return { bones, slots };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* The slot timelines of the skeleton JSON in the file's order — every
|
|
981
|
+
* animation that is an object, every slot entry that is an object — the walk
|
|
982
|
+
* `walkTimelines` made for A12 (issue #1025, cut 4c-4), and the same walk
|
|
983
|
+
* A45's supply makes (`spineSlotColourFacts`), here without the loaded
|
|
984
|
+
* skeleton because A12 runs whatever the round trip did. The model side
|
|
985
|
+
* supplies the same list (`fileSlotTimelines`).
|
|
986
|
+
*/
|
|
987
|
+
export function rawSlotTimelines(raw: Json | null): SlotTimelines[] {
|
|
988
|
+
const out: SlotTimelines[] = [];
|
|
989
|
+
const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
|
|
990
|
+
for (const [animation, anim] of Object.entries(rawAnimations)) {
|
|
991
|
+
if (!isObj(anim) || !isObj(anim.slots)) continue;
|
|
992
|
+
for (const [slot, timelines] of Object.entries(anim.slots)) {
|
|
993
|
+
if (!isObj(timelines)) continue;
|
|
994
|
+
out.push({ animation, slot, timelines });
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return out;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* The runtime's supply of `BoneTimelineFacts` (issue #1025, cut 4c-4): every
|
|
1002
|
+
* (animation, bone) pair of the skeleton JSON, in the file's order — every
|
|
1003
|
+
* animation that is an object with a `bones` object, every bone entry,
|
|
1004
|
+
* whatever it holds — the walk A24, A29 and A30 made over the raw JSON. The
|
|
1005
|
+
* model side's supply is `./assertions/model/bone_timelines.ts`.
|
|
1006
|
+
*/
|
|
1007
|
+
export function rawBoneTimelines(raw: Json | null): BoneTimelineFacts {
|
|
1008
|
+
const boneTimelines: BoneTimelines[] = [];
|
|
1009
|
+
const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
|
|
1010
|
+
for (const [animation, anim] of Object.entries(anims)) {
|
|
1011
|
+
if (!isObj(anim) || !isObj(anim.bones)) continue;
|
|
1012
|
+
for (const [bone, timelines] of Object.entries(anim.bones as Json)) boneTimelines.push({ animation, bone, timelines });
|
|
1013
|
+
}
|
|
1014
|
+
return { boneTimelines };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* The runtime's supply of `EventKeyFacts` (issue #1025, cut 4c-4) — and the
|
|
1019
|
+
* four clauses of A32 that stay with the round trip.
|
|
1020
|
+
*
|
|
1021
|
+
* Every event key of the skeleton JSON, in the file's order, with what the
|
|
1022
|
+
* body's one moved clause reads (the fields the key states, whether its event
|
|
1023
|
+
* declares an audio path). The four clauses below run here, over the raw JSON,
|
|
1024
|
+
* exactly as they ran in A32's body, and hand their findings in as `kept`, in
|
|
1025
|
+
* the order they printed, with `stopped` where the clause ended the key's
|
|
1026
|
+
* reading (the body prints them at the same place). They stay because each is
|
|
1027
|
+
* a state the model document's reader refuses by name (`readEventKeys` in
|
|
1028
|
+
* `src/core/events.ts`): a key with no string name, an event the skeleton
|
|
1029
|
+
* does not declare, a time that is not a finite number, a time before the key
|
|
1030
|
+
* before it — measured on forged documents, each refused at the key's
|
|
1031
|
+
* address, so no readable document reaches them and the model side has no
|
|
1032
|
+
* input to print them from.
|
|
1033
|
+
*/
|
|
1034
|
+
export function rawEventKeys(raw: Json | null): EventKeyFacts {
|
|
1035
|
+
if (!raw) return { parsed: false, animations: false, timelines: 0, keys: [] };
|
|
1036
|
+
if (!isObj(raw.animations)) return { parsed: true, animations: false, timelines: 0, keys: [] };
|
|
1037
|
+
const declared = isObj(raw.events) ? (raw.events as Json) : {};
|
|
1038
|
+
const known = Object.keys(declared);
|
|
1039
|
+
const keys: EventKeyEntry[] = [];
|
|
1040
|
+
let timelines = 0;
|
|
1041
|
+
for (const [animName, anim] of Object.entries(raw.animations as Json)) {
|
|
1042
|
+
if (!isObj(anim) || !Array.isArray(anim.events)) continue;
|
|
1043
|
+
timelines++;
|
|
1044
|
+
let previous = -Infinity;
|
|
1045
|
+
(anim.events as unknown[]).forEach((key, k) => {
|
|
1046
|
+
const at = eventKeyAt(animName, k);
|
|
1047
|
+
const kept: string[] = [];
|
|
1048
|
+
const stop = (name: string): void => void keys.push({ animation: animName, index: k, kept, stopped: true, name, sets: { volume: false, balance: false }, audio: false });
|
|
1049
|
+
if (!isObj(key) || typeof key.name !== 'string') {
|
|
1050
|
+
kept.push(`${at}: an event key needs a string "name"`);
|
|
1051
|
+
return stop('');
|
|
1052
|
+
}
|
|
1053
|
+
const definition = declared[key.name];
|
|
1054
|
+
if (definition === undefined) {
|
|
1055
|
+
kept.push(
|
|
1056
|
+
`${at}: fires "${key.name}", which the skeleton's events block does not declare` +
|
|
1057
|
+
(known.length ? ` (declared: ${known.join(', ')})` : ' (that block is empty or absent)'),
|
|
1058
|
+
);
|
|
1059
|
+
return stop(key.name);
|
|
1060
|
+
}
|
|
1061
|
+
// `time` defaults to 0 when absent (`:1247`), which is what the editor
|
|
1062
|
+
// writes for a firing on frame 0.
|
|
1063
|
+
const time = key.time === undefined ? 0 : key.time;
|
|
1064
|
+
if (typeof time !== 'number' || !Number.isFinite(time)) {
|
|
1065
|
+
kept.push(`${at}: time is ${JSON.stringify(key.time)}, not a finite number`);
|
|
1066
|
+
return stop(key.name);
|
|
1067
|
+
}
|
|
1068
|
+
if (time < previous) {
|
|
1069
|
+
kept.push(
|
|
1070
|
+
`${at}: "${key.name}" is at t=${time}, after a key at t=${previous} — the parser fills frames in ` +
|
|
1071
|
+
'array order and never sorts them, so the earlier firing is unreachable',
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
previous = Math.max(previous, time);
|
|
1075
|
+
keys.push({
|
|
1076
|
+
animation: animName,
|
|
1077
|
+
index: k,
|
|
1078
|
+
kept,
|
|
1079
|
+
stopped: false,
|
|
1080
|
+
name: key.name,
|
|
1081
|
+
sets: { volume: key.volume !== undefined, balance: key.balance !== undefined },
|
|
1082
|
+
audio: isObj(definition) && typeof definition.audio === 'string',
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
return { parsed: true, animations: true, timelines, keys };
|
|
1087
|
+
}
|
|
1088
|
+
|
|
924
1089
|
/**
|
|
925
1090
|
* The runtime's supply of A45's facts (issue #1025): the slot timelines read
|
|
926
1091
|
* off the skeleton JSON in the file's order — every animation the file keys,
|
|
@@ -959,6 +1124,156 @@ function spineSlotColourFacts(raw: Json | null, data: ReturnType<SkeletonJson['r
|
|
|
959
1124
|
};
|
|
960
1125
|
}
|
|
961
1126
|
|
|
1127
|
+
/**
|
|
1128
|
+
* The runtime's supply of A39's fact (issue #1025, cut 4c-3): the survey of
|
|
1129
|
+
* the loaded skeleton, read and posed by spine-core (`surveyDeformKeys`) — the
|
|
1130
|
+
* call A39 always made. The model side's supply is
|
|
1131
|
+
* `./assertions/model/deform_survey.ts`.
|
|
1132
|
+
*/
|
|
1133
|
+
export function spineDeformSurvey(data: ReturnType<SkeletonJson['readSkeletonData']>): DeformSurveyFacts {
|
|
1134
|
+
return { survey: (exempt) => surveyDeformKeys(data, exempt) };
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* The runtime's supply of A09's facts (issue #1025, cut 4c-3): the loaded
|
|
1139
|
+
* animations in the loaded order — the file's — each with its duration and
|
|
1140
|
+
* every loaded timeline's `getDuration()`, as A09 always read them. The model
|
|
1141
|
+
* side's supply is `./assertions/model/animation_durations.ts`.
|
|
1142
|
+
*/
|
|
1143
|
+
export function spineAnimationDurations(data: ReturnType<SkeletonJson['readSkeletonData']>): AnimationDurationFacts {
|
|
1144
|
+
return { animations: data.animations.map((anim) => ({ name: anim.name, duration: anim.duration, timelineDurations: anim.timelines.map((t) => t.getDuration()) })) };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* The runtime's supply of A43's facts (issue #1025, cut 4c-3): the stated dark
|
|
1149
|
+
* colours read off the skeleton JSON in the file's slot order, each slot named
|
|
1150
|
+
* as A43's walk always named it; the setup dark colour the loaded slot holds;
|
|
1151
|
+
* A45's walk of the file's slot timelines; whether an animation loaded; and a
|
|
1152
|
+
* slot's two colours posed on a fresh, non-looping track by spine-core — setup
|
|
1153
|
+
* pose, `update(0)`, `updateWorldTransform(Physics.reset)`, the track stepped
|
|
1154
|
+
* to the time and applied, then `update` and `updateWorldTransform
|
|
1155
|
+
* (Physics.update)` by the same time, A43's recipe unchanged. The model side's
|
|
1156
|
+
* supply is `./assertions/model/two_colour.ts`.
|
|
1157
|
+
*/
|
|
1158
|
+
export function spineTwoColourFacts(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): TwoColourFacts {
|
|
1159
|
+
const slotDarks: Array<{ slot: string; dark: string }> = [];
|
|
1160
|
+
for (const slot of Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : []) {
|
|
1161
|
+
if (isObj(slot) && typeof slot.dark === 'string') slotDarks.push({ slot: String(slot.name), dark: slot.dark });
|
|
1162
|
+
}
|
|
1163
|
+
return {
|
|
1164
|
+
slotDarks,
|
|
1165
|
+
loadedDark: (name) => data.findSlot(name)?.setupPose.darkColor ?? null,
|
|
1166
|
+
slotTimelines: spineSlotColourFacts(raw, data).slotTimelines,
|
|
1167
|
+
hasAnimation: (name) => Boolean(data.findAnimation(name)),
|
|
1168
|
+
posedTint: (animName, slotName, time) => {
|
|
1169
|
+
const skeleton = new Skeleton(data);
|
|
1170
|
+
const state = new AnimationState(new AnimationStateData(data));
|
|
1171
|
+
state.setAnimation(0, animName, false);
|
|
1172
|
+
skeleton.setupPose();
|
|
1173
|
+
skeleton.update(0);
|
|
1174
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
1175
|
+
state.update(time);
|
|
1176
|
+
state.apply(skeleton);
|
|
1177
|
+
skeleton.update(time);
|
|
1178
|
+
skeleton.updateWorldTransform(Physics.update);
|
|
1179
|
+
const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
|
|
1180
|
+
if (posed === undefined) return undefined;
|
|
1181
|
+
return { light: posed.color, dark: posed.darkColor ?? null };
|
|
1182
|
+
},
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* The runtime's supply of A46's facts (issue #1025, cut 4c-3): the skin
|
|
1188
|
+
* entries and sequence timelines read off the skeleton JSON as A46's walks
|
|
1189
|
+
* always read them — the skins, a skin's slot keys and a slot's placeholders in
|
|
1190
|
+
* the file's order, an entry with no `type` a region, an entry of another kind
|
|
1191
|
+
* left out, a timeline whose `sequence` is not a list left out — each entry
|
|
1192
|
+
* marked loaded where the slot exists and the loaded skin of that name holds an
|
|
1193
|
+
* attachment at it; and what a slot shows on a fresh, non-looping track posed
|
|
1194
|
+
* by spine-core — setup pose, `update(0)`, `updateWorldTransform
|
|
1195
|
+
* (Physics.reset)`, the track stepped to the time and applied, A46's recipe
|
|
1196
|
+
* unchanged. The loaded attachment is answered by its address: every loaded
|
|
1197
|
+
* skin's `getAttachments()` gives each attachment object the skin, slot and
|
|
1198
|
+
* placeholder it is filed under, and the object a linked mesh plays its
|
|
1199
|
+
* timelines as (`timelineAttachment`) is answered the same way. The model
|
|
1200
|
+
* side's supply is `./assertions/model/sequences.ts`.
|
|
1201
|
+
*/
|
|
1202
|
+
export function spineSequenceFacts(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): SequenceFacts {
|
|
1203
|
+
const entries: SequenceSkinEntry[] = [];
|
|
1204
|
+
for (const skin of isObj(raw) && Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
|
|
1205
|
+
if (!isObj(skin) || !isObj(skin.attachments)) continue;
|
|
1206
|
+
const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
|
|
1207
|
+
const loadedSkin = data.findSkin(skinName);
|
|
1208
|
+
for (const [slotName, perSlot] of Object.entries(skin.attachments)) {
|
|
1209
|
+
if (!isObj(perSlot)) continue;
|
|
1210
|
+
const slotIndex = data.findSlot(slotName)?.index;
|
|
1211
|
+
for (const [placeholder, entry] of Object.entries(perSlot)) {
|
|
1212
|
+
if (!isObj(entry)) continue;
|
|
1213
|
+
const type = entry.type === undefined ? 'region' : entry.type;
|
|
1214
|
+
if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
|
|
1215
|
+
const loaded = slotIndex === undefined ? null : loadedSkin?.getAttachment(slotIndex, placeholder) ?? null;
|
|
1216
|
+
entries.push({ skin: skinName, slot: slotName, placeholder, name: entry.name, path: entry.path, sequence: entry.sequence, loaded: loaded !== null });
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
const timelines: SequenceTimeline[] = [];
|
|
1221
|
+
const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
|
|
1222
|
+
for (const [animation, anim] of Object.entries(rawAnimations)) {
|
|
1223
|
+
if (!isObj(anim) || !isObj(anim.attachments)) continue;
|
|
1224
|
+
for (const [skin, perSkin] of Object.entries(anim.attachments)) {
|
|
1225
|
+
if (!isObj(perSkin)) continue;
|
|
1226
|
+
for (const [slot, perSlot] of Object.entries(perSkin)) {
|
|
1227
|
+
if (!isObj(perSlot)) continue;
|
|
1228
|
+
for (const [placeholder, perAttachment] of Object.entries(perSlot)) {
|
|
1229
|
+
if (!isObj(perAttachment) || !Array.isArray(perAttachment.sequence)) continue;
|
|
1230
|
+
timelines.push({ animation, skin, slot, placeholder, keys: perAttachment.sequence as unknown[] });
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
/** Every loaded attachment object -> the address it is filed under, first filing kept. */
|
|
1236
|
+
let addressOf: Map<object, EntryAddress> | null = null;
|
|
1237
|
+
const address = (attachment: object): EntryAddress => {
|
|
1238
|
+
if (addressOf === null) {
|
|
1239
|
+
addressOf = new Map();
|
|
1240
|
+
for (const skin of data.skins) {
|
|
1241
|
+
for (const entry of skin.getAttachments()) {
|
|
1242
|
+
if (!addressOf.has(entry.attachment)) addressOf.set(entry.attachment, entryAddress(skin.name, data.slots[entry.slotIndex].name, entry.placeholder));
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
// An attachment no skin files has no address: it can match no timeline's.
|
|
1247
|
+
return addressOf.get(attachment) ?? '';
|
|
1248
|
+
};
|
|
1249
|
+
return {
|
|
1250
|
+
entries,
|
|
1251
|
+
timelines,
|
|
1252
|
+
hasSlot: (slot) => data.findSlot(slot) !== null,
|
|
1253
|
+
duration: (animation) => data.findAnimation(animation)?.duration ?? null,
|
|
1254
|
+
posedFrame: (animName, slotName, time) => {
|
|
1255
|
+
const slotIndex = data.findSlot(slotName)?.index;
|
|
1256
|
+
if (slotIndex === undefined) return null;
|
|
1257
|
+
const skeleton = new Skeleton(data);
|
|
1258
|
+
const state = new AnimationState(new AnimationStateData(data));
|
|
1259
|
+
state.setAnimation(0, animName, false);
|
|
1260
|
+
skeleton.setupPose();
|
|
1261
|
+
skeleton.update(0);
|
|
1262
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
1263
|
+
state.update(time);
|
|
1264
|
+
state.apply(skeleton);
|
|
1265
|
+
const pose = skeleton.slots[slotIndex].appliedPose;
|
|
1266
|
+
const shown = pose.attachment;
|
|
1267
|
+
if (shown === null) return null;
|
|
1268
|
+
// The object it plays timelines as — `applyToSlot`'s second test — where the attachment carries one.
|
|
1269
|
+
const playsAs = shown.timelineAttachment ?? shown;
|
|
1270
|
+
const sequence = shown instanceof RegionAttachment || shown instanceof MeshAttachment ? shown.sequence : null;
|
|
1271
|
+
const region = sequence === null ? null : ((sequence.regions[sequence.resolveIndex(pose)] as TextureAtlasRegion | null | undefined)?.name ?? null);
|
|
1272
|
+
return { shown: address(shown), playsAs: address(playsAs), region };
|
|
1273
|
+
},
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
|
|
962
1277
|
/**
|
|
963
1278
|
* The runtime's supply of `SkinMeshFacts` (issue #1025, cut 4c-1): every
|
|
964
1279
|
* loaded `MeshAttachment` of every skin, linked meshes included, in the loaded
|
|
@@ -1398,6 +1713,129 @@ export function spineConstraintFacts(data: ReturnType<SkeletonJson['readSkeleton
|
|
|
1398
1713
|
return { animations: data.animations.length, constraints, timelines, pathSlots };
|
|
1399
1714
|
}
|
|
1400
1715
|
|
|
1716
|
+
/**
|
|
1717
|
+
* The runtime's supply of `ConstraintTargetFacts` (issue #1025, cut 4c-5):
|
|
1718
|
+
* A34's walk of the raw JSON, as A34 always walked it — the `constraints`
|
|
1719
|
+
* array's objects, then each animation (`Object.entries`, the file's order)
|
|
1720
|
+
* that is an object, its `ik` and `transform` groups (each entry ONE key
|
|
1721
|
+
* array) and its `path`, `physics` and `slider` groups (each entry an object
|
|
1722
|
+
* of named key arrays, or a bare value), each where the group is an object.
|
|
1723
|
+
* `reach` builds the timeline the parser builds for a physics timeline NAME
|
|
1724
|
+
* (`unnamedPhysicsTimeline`) and asks it of the file's physics constraint
|
|
1725
|
+
* objects (`unnamedPhysicsReach`). The model side's supply is
|
|
1726
|
+
* `./assertions/model/constraint_targets.ts`.
|
|
1727
|
+
*/
|
|
1728
|
+
export function rawConstraintTargets(raw: Json): ConstraintTargetFacts {
|
|
1729
|
+
const objects = (Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []).filter((entry): entry is Json => isObj(entry));
|
|
1730
|
+
const constraints = objects.map((entry) => ({ name: typeof entry.name === 'string' ? entry.name : null, type: String(entry.type), spelled: String(entry.name) }));
|
|
1731
|
+
const rawPhysics = objects.filter((entry) => entry.type === 'physics');
|
|
1732
|
+
const keyArray = (timeline: string, keys: unknown): TargetKeyArray => ({ timeline, keys: Array.isArray(keys) ? { count: keys.length } : { spelled: `${JSON.stringify(keys)}` } });
|
|
1733
|
+
let animations: TargetAnimation[] | null = null;
|
|
1734
|
+
if (isObj(raw.animations)) {
|
|
1735
|
+
animations = [];
|
|
1736
|
+
for (const [name, anim] of Object.entries(raw.animations)) {
|
|
1737
|
+
if (!isObj(anim)) continue;
|
|
1738
|
+
const groups: TargetAnimation['groups'][number][] = [];
|
|
1739
|
+
for (const group of ['ik', 'transform'] as const) {
|
|
1740
|
+
if (!isObj(anim[group])) continue;
|
|
1741
|
+
groups.push({ group, entries: Object.entries(anim[group] as Json).map(([target, keys]) => ({ name: target, bare: null, keyArrays: [keyArray('', keys)] })) });
|
|
1742
|
+
}
|
|
1743
|
+
for (const group of NAMED_TIMELINE_GROUPS) {
|
|
1744
|
+
if (!isObj(anim[group])) continue;
|
|
1745
|
+
groups.push({
|
|
1746
|
+
group,
|
|
1747
|
+
entries: Object.entries(anim[group] as Json).map(([target, timelines]) =>
|
|
1748
|
+
isObj(timelines) ? { name: target, bare: null, keyArrays: Object.entries(timelines).map(([timeline, keys]) => keyArray(timeline, keys)) } : { name: target, bare: JSON.stringify(timelines), keyArrays: [] },
|
|
1749
|
+
),
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
animations.push({ name, groups });
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
return {
|
|
1756
|
+
constraints,
|
|
1757
|
+
groupsByAnimation: animations,
|
|
1758
|
+
reach: (name) => {
|
|
1759
|
+
const timeline = unnamedPhysicsTimeline(name);
|
|
1760
|
+
if (timeline === null) return null;
|
|
1761
|
+
return { resets: timeline instanceof PhysicsConstraintResetTimeline, reached: unnamedPhysicsReach(timeline, rawPhysics).map((one) => String(one.name)) };
|
|
1762
|
+
},
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* The runtime's supply of `SliderCompositionFacts` (issue #1025, cut 4c-5):
|
|
1768
|
+
* the loaded sliders in the `constraints` array's order, and each animation's
|
|
1769
|
+
* loaded timelines in the order the runtime built them, each with its class
|
|
1770
|
+
* name and its `propertyIds` — spelled as the facts spell an id (the property's
|
|
1771
|
+
* name off `Property`, the index after it as loaded, and a deform or sequence
|
|
1772
|
+
* timeline's attachment OBJECT replaced by the address a skin files it under,
|
|
1773
|
+
* first filing kept, as `spineSequenceFacts` maps it) — and the sentence's
|
|
1774
|
+
* words for each, `describe` as A40 always wrote it. `behaviour` is today's
|
|
1775
|
+
* probe, `timelineAddBehaviour`. The model side's supply is
|
|
1776
|
+
* `./assertions/model/slider_composition.ts`.
|
|
1777
|
+
*/
|
|
1778
|
+
export function spineSliderComposition(data: ReturnType<SkeletonJson['readSkeletonData']>): SliderCompositionFacts {
|
|
1779
|
+
let addressOf: Map<object, string> | null = null;
|
|
1780
|
+
const address = (attachment: object): string => {
|
|
1781
|
+
if (addressOf === null) {
|
|
1782
|
+
addressOf = new Map();
|
|
1783
|
+
for (const skin of data.skins) {
|
|
1784
|
+
for (const entry of skin.getAttachments()) {
|
|
1785
|
+
if (!addressOf.has(entry.attachment)) addressOf.set(entry.attachment, entryAddress(skin.name, data.slots[entry.slotIndex].name, entry.placeholder));
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
return addressOf.get(attachment) ?? '';
|
|
1790
|
+
};
|
|
1791
|
+
/** What a property id points at, in the words the rig spec uses — A40's `describe`. */
|
|
1792
|
+
const describe = (timeline: Timeline, id: string): string => {
|
|
1793
|
+
const property = Property[Number(id.split('|')[0])] ?? id;
|
|
1794
|
+
if (isBoneTimeline(timeline)) return `bone "${data.bones[timeline.boneIndex]?.name ?? timeline.boneIndex}" ${property}`;
|
|
1795
|
+
if (timeline instanceof DeformTimeline) {
|
|
1796
|
+
return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" deform of "${timeline.attachment.name}"`;
|
|
1797
|
+
}
|
|
1798
|
+
if (isSlotTimeline(timeline)) return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" ${property}`;
|
|
1799
|
+
if (isConstraintTimeline(timeline) && timeline.constraintIndex >= 0) {
|
|
1800
|
+
return `constraint "${data.constraints[timeline.constraintIndex]?.name ?? timeline.constraintIndex}" ${property}`;
|
|
1801
|
+
}
|
|
1802
|
+
return `the skeleton's ${property}`;
|
|
1803
|
+
};
|
|
1804
|
+
/** An id as the facts spell it: the property's name in place of its number, an attachment's address in place of its serial. */
|
|
1805
|
+
const idOf = (timeline: Timeline, id: string): string => {
|
|
1806
|
+
const [head, ...rest] = id.split('|');
|
|
1807
|
+
const named = [Property[Number(head)] ?? head, ...rest];
|
|
1808
|
+
if (timeline instanceof DeformTimeline || timeline instanceof RuntimeSequenceTimeline) named[2] = address(timeline.attachment);
|
|
1809
|
+
return named.join('|');
|
|
1810
|
+
};
|
|
1811
|
+
const timelinesOf = (animation: Animation): SliderTimelineFact[] =>
|
|
1812
|
+
animation.timelines.map((timeline) => ({
|
|
1813
|
+
runtimeClass: timeline.constructor.name,
|
|
1814
|
+
properties: timeline.propertyIds.map((id) => ({ id: idOf(timeline, id), property: Property[Number(id.split('|')[0])] ?? id, names: describe(timeline, id) })),
|
|
1815
|
+
}));
|
|
1816
|
+
const sliders: SliderFact[] = [];
|
|
1817
|
+
data.constraints.forEach((c, index) => {
|
|
1818
|
+
if (!(c instanceof SliderData)) return;
|
|
1819
|
+
sliders.push({
|
|
1820
|
+
name: c.name,
|
|
1821
|
+
index,
|
|
1822
|
+
mix: c.setupPose.mix,
|
|
1823
|
+
additive: c.additive,
|
|
1824
|
+
skinRequired: c.skinRequired,
|
|
1825
|
+
skins: data.skins.filter((skin) => skin.constraints.includes(c)).map((skin) => skin.name),
|
|
1826
|
+
animation: c.animation === null ? null : { name: c.animation.name, timelines: timelinesOf(c.animation) },
|
|
1827
|
+
});
|
|
1828
|
+
});
|
|
1829
|
+
return {
|
|
1830
|
+
sliders,
|
|
1831
|
+
behaviour: (animationName, at) => {
|
|
1832
|
+
const timeline = data.findAnimation(animationName)?.timelines[at];
|
|
1833
|
+
if (timeline === undefined) throw new Error(`internal: animation "${animationName}" has no timeline ${at}`);
|
|
1834
|
+
return timelineAddBehaviour(data, timeline);
|
|
1835
|
+
},
|
|
1836
|
+
};
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1401
1839
|
/**
|
|
1402
1840
|
* The facts cut 4c-2's bodies read, as `validate()` supplies them from a pair
|
|
1403
1841
|
* spine-core loads — or `null` when the load throws (A00's failure). For the
|
|
@@ -1416,6 +1854,135 @@ export function runtimeRigFacts(skeletonText: string, atlasText: string): { mesh
|
|
|
1416
1854
|
return { meshes: spineMeshFacts(data, raw), polygons: spinePolygonFacts(data, raw), links: spineLinkFacts(data, raw), constraints: spineConstraintFacts(data) };
|
|
1417
1855
|
}
|
|
1418
1856
|
|
|
1857
|
+
/**
|
|
1858
|
+
* Cut 4c-3's facts (issue #1025) as `validate()` supplies them from a pair
|
|
1859
|
+
* spine-core loads — A39's survey, A09's durations, A43's tint and A46's series
|
|
1860
|
+
* — or `null` when the load throws. For the selftest's `VF12` and
|
|
1861
|
+
* `tools/verdict_gate.ts`, which hand them to the bodies and ask the model
|
|
1862
|
+
* side's suppliers the same questions, value by value.
|
|
1863
|
+
*/
|
|
1864
|
+
/**
|
|
1865
|
+
* The runtime's supply of A10's facts (issue #1025, cut 4c-5a): every pose A10
|
|
1866
|
+
* reads, posed by spine-core exactly as A10 always posed it.
|
|
1867
|
+
*
|
|
1868
|
+
* - The setup pose: `setupPose()`, `update(0)`,
|
|
1869
|
+
* `updateWorldTransform(Physics.reset)`, no animation set.
|
|
1870
|
+
* - Each animation's walk: `setAnimation(0, name, true)` — a LOOPING track —
|
|
1871
|
+
* over a fresh skeleton posed as above, then per step `AnimationState.update
|
|
1872
|
+
* (step)`, `apply`, `Skeleton.update(step)`, `updateWorldTransform
|
|
1873
|
+
* (Physics.update)`, one pose read after each.
|
|
1874
|
+
* - A bone's mode at a key: a fresh, non-looping track, the setup pose reset,
|
|
1875
|
+
* the track stepped to the key's time and applied, then `update` and
|
|
1876
|
+
* `updateWorldTransform(Physics.update)` by the same time.
|
|
1877
|
+
*
|
|
1878
|
+
* A pose is read as `posedNumbersOf` reads it for `render` (every bone's world
|
|
1879
|
+
* transform, every shown region's and mesh's world vertices), with each bone's
|
|
1880
|
+
* `appliedPose.inherit` — `null` where it is a value `updateWorldTransform`'s
|
|
1881
|
+
* switch has a case for, read off the runtime's own enum — and every slot's
|
|
1882
|
+
* light and dark colour. The model side's supply is
|
|
1883
|
+
* `./assertions/model/stepped_poses.ts`.
|
|
1884
|
+
*/
|
|
1885
|
+
export function spineSteppedPoses(raw: Json | null, data: ReturnType<SkeletonJson['readSkeletonData']>): SteppedPoseFacts {
|
|
1886
|
+
/** Is this a mode `updateWorldTransform`'s switch has a case for? Read off the runtime's own enum. */
|
|
1887
|
+
const isMode = (inherit: unknown): boolean => typeof inherit === 'number' && Inherit[inherit] !== undefined;
|
|
1888
|
+
const frameOf = (skeleton: Skeleton): SteppedFrame => {
|
|
1889
|
+
const { bones, drawn } = posedNumbersOf(skeleton);
|
|
1890
|
+
return {
|
|
1891
|
+
bones: bones.map((b, i) => {
|
|
1892
|
+
const inherit = skeleton.bones[i].appliedPose.inherit;
|
|
1893
|
+
return { ...b, inherit: isMode(inherit) ? null : String(inherit) };
|
|
1894
|
+
}),
|
|
1895
|
+
drawn,
|
|
1896
|
+
slots: skeleton.slots.map((slot) => {
|
|
1897
|
+
const c = slot.appliedPose.color;
|
|
1898
|
+
const d = slot.appliedPose.darkColor;
|
|
1899
|
+
return { name: slot.data.name, colour: [c.r, c.g, c.b, c.a] as const, dark: d === null ? null : ([d.r, d.g, d.b] as const) };
|
|
1900
|
+
}),
|
|
1901
|
+
};
|
|
1902
|
+
};
|
|
1903
|
+
return {
|
|
1904
|
+
boneCount: data.bones.length,
|
|
1905
|
+
steppedAnimations: data.animations.map((anim) => ({ name: anim.name, duration: anim.duration })),
|
|
1906
|
+
hasAnimation: (name) => Boolean(data.findAnimation(name)),
|
|
1907
|
+
hasBone: (name) => Boolean(data.findBone(name)),
|
|
1908
|
+
posedInherit: (animName, boneName, time) => {
|
|
1909
|
+
const skeleton = new Skeleton(data);
|
|
1910
|
+
const state = new AnimationState(new AnimationStateData(data));
|
|
1911
|
+
state.setAnimation(0, animName, false);
|
|
1912
|
+
skeleton.setupPose();
|
|
1913
|
+
skeleton.update(0);
|
|
1914
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
1915
|
+
state.update(time);
|
|
1916
|
+
state.apply(skeleton);
|
|
1917
|
+
skeleton.update(time);
|
|
1918
|
+
skeleton.updateWorldTransform(Physics.update);
|
|
1919
|
+
const bone = skeleton.findBone(boneName);
|
|
1920
|
+
if (bone === null) return undefined;
|
|
1921
|
+
const posed = bone.appliedPose.inherit;
|
|
1922
|
+
return isMode(posed) ? null : String(posed);
|
|
1923
|
+
},
|
|
1924
|
+
statedInherit: (name) => {
|
|
1925
|
+
const rawBone = Array.isArray(raw?.bones) ? (raw.bones as unknown[]).find((b) => isObj(b) && b.name === name) : undefined;
|
|
1926
|
+
return `${JSON.stringify(isObj(rawBone) ? rawBone.inherit : undefined)}`;
|
|
1927
|
+
},
|
|
1928
|
+
setup: () => {
|
|
1929
|
+
const atRest = new Skeleton(data);
|
|
1930
|
+
atRest.setupPose();
|
|
1931
|
+
atRest.update(0);
|
|
1932
|
+
atRest.updateWorldTransform(Physics.reset);
|
|
1933
|
+
return frameOf(atRest);
|
|
1934
|
+
},
|
|
1935
|
+
walk: (animName, step, frames) => {
|
|
1936
|
+
const skeleton = new Skeleton(data);
|
|
1937
|
+
const state = new AnimationState(new AnimationStateData(data));
|
|
1938
|
+
state.setAnimation(0, animName, true);
|
|
1939
|
+
skeleton.setupPose();
|
|
1940
|
+
skeleton.update(0);
|
|
1941
|
+
skeleton.updateWorldTransform(Physics.reset);
|
|
1942
|
+
const out: SteppedFrame[] = [];
|
|
1943
|
+
for (let i = 0; i < frames; i++) {
|
|
1944
|
+
state.update(step);
|
|
1945
|
+
state.apply(skeleton);
|
|
1946
|
+
skeleton.update(step);
|
|
1947
|
+
skeleton.updateWorldTransform(Physics.update);
|
|
1948
|
+
out.push(frameOf(skeleton));
|
|
1949
|
+
}
|
|
1950
|
+
return out;
|
|
1951
|
+
},
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
export function runtimePosedFacts(skeletonText: string, atlasText: string): { deformSurvey: DeformSurveyFacts; animationDurations: AnimationDurationFacts; twoColour: TwoColourFacts; sequences: SequenceFacts; steppedPoses: SteppedPoseFacts; boneTimelines: BoneTimelineFacts } | null {
|
|
1956
|
+
let data: ReturnType<SkeletonJson['readSkeletonData']>;
|
|
1957
|
+
let raw: Json;
|
|
1958
|
+
try {
|
|
1959
|
+
raw = JSON.parse(skeletonText) as Json;
|
|
1960
|
+
data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText))).readSkeletonData(JSON.parse(skeletonText));
|
|
1961
|
+
} catch {
|
|
1962
|
+
return null;
|
|
1963
|
+
}
|
|
1964
|
+
return { deformSurvey: spineDeformSurvey(data), animationDurations: spineAnimationDurations(data), twoColour: spineTwoColourFacts(raw, data), sequences: spineSequenceFacts(raw, data), steppedPoses: spineSteppedPoses(raw, data), boneTimelines: rawBoneTimelines(raw) };
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* Cut 4c-5's facts (issue #1025) as `validate()` supplies them from a pair
|
|
1969
|
+
* spine-core loads — A40's sliders and A34's constraint groups, with the
|
|
1970
|
+
* constraint facts A40 reads beside them — or `null` when the load throws.
|
|
1971
|
+
* For the selftest's `VF14` and `tools/verdict_gate.ts`, which hand them to
|
|
1972
|
+
* the bodies and ask the model side's suppliers the same questions.
|
|
1973
|
+
*/
|
|
1974
|
+
export function runtimeCut4c5Facts(skeletonText: string, atlasText: string): { sliderComposition: SliderCompositionFacts; constraintTargets: ConstraintTargetFacts; constraints: ConstraintFacts } | null {
|
|
1975
|
+
let data: ReturnType<SkeletonJson['readSkeletonData']>;
|
|
1976
|
+
let raw: Json;
|
|
1977
|
+
try {
|
|
1978
|
+
raw = JSON.parse(skeletonText) as Json;
|
|
1979
|
+
data = new SkeletonJson(new AtlasAttachmentLoader(new TextureAtlas(atlasText))).readSkeletonData(JSON.parse(skeletonText));
|
|
1980
|
+
} catch {
|
|
1981
|
+
return null;
|
|
1982
|
+
}
|
|
1983
|
+
return { sliderComposition: spineSliderComposition(data), constraintTargets: rawConstraintTargets(raw), constraints: spineConstraintFacts(data) };
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1419
1986
|
/** A fact supply computed on first use and kept: a supplier that throws throws inside the `check` that asked, as the body it feeds always did. */
|
|
1420
1987
|
function once<T>(supply: () => T): () => T {
|
|
1421
1988
|
let held: { value: T } | null = null;
|
|
@@ -1686,60 +2253,14 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1686
2253
|
// It runs on the raw JSON rather than on the loaded data because the loaded
|
|
1687
2254
|
// `Event` no longer remembers which fields the file wrote: an override that was
|
|
1688
2255
|
// dropped and an override that matched the default are the same object.
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
sawATimeline = true;
|
|
1698
|
-
let previous = -Infinity;
|
|
1699
|
-
(anim.events as unknown[]).forEach((key, k) => {
|
|
1700
|
-
const at = `animation "${animName}" event key ${k}`;
|
|
1701
|
-
if (!isObj(key) || typeof key.name !== 'string') {
|
|
1702
|
-
fail('A32_EVENT_KEYS_RESOLVE', `${at}: an event key needs a string "name"`);
|
|
1703
|
-
return;
|
|
1704
|
-
}
|
|
1705
|
-
const definition = declared[key.name];
|
|
1706
|
-
if (definition === undefined) {
|
|
1707
|
-
fail(
|
|
1708
|
-
'A32_EVENT_KEYS_RESOLVE',
|
|
1709
|
-
`${at}: fires "${key.name}", which the skeleton's events block does not declare` +
|
|
1710
|
-
(known.length ? ` (declared: ${known.join(', ')})` : ' (that block is empty or absent)'),
|
|
1711
|
-
);
|
|
1712
|
-
return;
|
|
1713
|
-
}
|
|
1714
|
-
// `time` defaults to 0 when absent (`:1247`), which is what the editor
|
|
1715
|
-
// writes for a firing on frame 0.
|
|
1716
|
-
const time = key.time === undefined ? 0 : key.time;
|
|
1717
|
-
if (typeof time !== 'number' || !Number.isFinite(time)) {
|
|
1718
|
-
fail('A32_EVENT_KEYS_RESOLVE', `${at}: time is ${JSON.stringify(key.time)}, not a finite number`);
|
|
1719
|
-
return;
|
|
1720
|
-
}
|
|
1721
|
-
if (time < previous) {
|
|
1722
|
-
fail(
|
|
1723
|
-
'A32_EVENT_KEYS_RESOLVE',
|
|
1724
|
-
`${at}: "${key.name}" is at t=${time}, after a key at t=${previous} — the parser fills frames in ` +
|
|
1725
|
-
'array order and never sorts them, so the earlier firing is unreachable',
|
|
1726
|
-
);
|
|
1727
|
-
}
|
|
1728
|
-
previous = Math.max(previous, time);
|
|
1729
|
-
const hasAudio = isObj(definition) && typeof definition.audio === 'string';
|
|
1730
|
-
for (const field of ['volume', 'balance'] as const) {
|
|
1731
|
-
if (key[field] !== undefined && !hasAudio) {
|
|
1732
|
-
fail(
|
|
1733
|
-
'A32_EVENT_KEYS_RESOLVE',
|
|
1734
|
-
`${at}: "${key.name}" sets ${field}, but the event declares no audio path — the parser reads ` +
|
|
1735
|
-
`${field} only for an event that has one, so it is dropped in silence`,
|
|
1736
|
-
);
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
});
|
|
1740
|
-
}
|
|
1741
|
-
if (!sawATimeline) return skip('A32_EVENT_KEYS_RESOLVE', 'no animation carries an event timeline');
|
|
1742
|
-
});
|
|
2256
|
+
//
|
|
2257
|
+
// ✂️ Split per clause since issue #1025 (cut 4c-4): the third mode is the
|
|
2258
|
+
// rig's — a readable model document states a key's `volume` and the event's
|
|
2259
|
+
// `audio` — and its clause is the body's (`./assertions/bodies/a32.ts`); the
|
|
2260
|
+
// first two, with "no string name" and "a time that is not a finite number",
|
|
2261
|
+
// are states the document's reader refuses by name, so they stay here, in
|
|
2262
|
+
// `rawEventKeys`, and the body prints what they found at their place.
|
|
2263
|
+
check('A32_EVENT_KEYS_RESOLVE', () => a32EventKeysResolve(verdicts, rawEventKeys(raw)));
|
|
1743
2264
|
|
|
1744
2265
|
// --- A34: a constraint timeline aims at a constraint of that type ---------
|
|
1745
2266
|
//
|
|
@@ -1772,129 +2293,15 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1772
2293
|
// constant now, and the message that names the timelines a group takes reads
|
|
1773
2294
|
// them off `CHANNELS_BY_KIND` — it used to be a ternary over two groups,
|
|
1774
2295
|
// which is a sentence that cannot be extended without being rewritten.
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
const kindsOf = new Map<string, string[]>();
|
|
1785
|
-
for (const entry of Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []) {
|
|
1786
|
-
if (isObj(entry) && typeof entry.name === 'string') {
|
|
1787
|
-
kindsOf.set(entry.name, [...(kindsOf.get(entry.name) ?? []), String(entry.type)]);
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
/** The file's physics constraints, which the unnamed physics group is read against. */
|
|
1791
|
-
const rawPhysics = (Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []).filter(
|
|
1792
|
-
(entry): entry is Json => isObj(entry) && entry.type === 'physics',
|
|
1793
|
-
);
|
|
1794
|
-
let sawATimeline = false;
|
|
1795
|
-
/** One target of one group: the name resolves, the type matches, keys exist. */
|
|
1796
|
-
const checkTarget = (at: string, group: string, name: string, keyArrays: Array<[string, unknown]>): void => {
|
|
1797
|
-
sawATimeline = true;
|
|
1798
|
-
const declared = kindsOf.get(name) ?? [];
|
|
1799
|
-
if (declared.length === 0) {
|
|
1800
|
-
const known = [...kindsOf.entries()].filter(([, kinds]) => kinds.includes(group)).map(([n]) => n);
|
|
1801
|
-
fail(
|
|
1802
|
-
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
1803
|
-
`${at}: the skeleton's constraints array has no "${name}"` +
|
|
1804
|
-
(known.length ? ` (${group} constraints: ${known.join(', ')})` : `, and no ${group} constraint at all`),
|
|
1805
|
-
);
|
|
1806
|
-
return;
|
|
1807
|
-
}
|
|
1808
|
-
if (!declared.includes(group)) {
|
|
1809
|
-
fail(
|
|
1810
|
-
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
1811
|
-
`${at}: "${name}" is declared as a "${declared.join('"/"')}" constraint, so the ${group} lookup misses it and the loader throws`,
|
|
1812
|
-
);
|
|
1813
|
-
return;
|
|
1814
|
-
}
|
|
1815
|
-
checkKeys(at, keyArrays);
|
|
1816
|
-
};
|
|
1817
|
-
/** The key arrays of one group entry that resolved: each one is walked, so each has to hold a key. */
|
|
1818
|
-
const checkKeys = (at: string, keyArrays: Array<[string, unknown]>): void => {
|
|
1819
|
-
if (keyArrays.length === 0) {
|
|
1820
|
-
fail(
|
|
1821
|
-
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
1822
|
-
`${at}: the constraint is named and carries no timeline at all; the group is walked and nothing happens`,
|
|
1823
|
-
);
|
|
1824
|
-
return;
|
|
1825
|
-
}
|
|
1826
|
-
for (const [timeline, keys] of keyArrays) {
|
|
1827
|
-
if (Array.isArray(keys) && keys.length > 0) continue;
|
|
1828
|
-
fail(
|
|
1829
|
-
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
1830
|
-
`${at}${timeline ? ` timeline "${timeline}"` : ''}: the key array is ` +
|
|
1831
|
-
`${Array.isArray(keys) ? 'empty' : JSON.stringify(keys)}; the parser reads key 0, finds nothing and ` +
|
|
1832
|
-
'skips the whole timeline without a word',
|
|
1833
|
-
);
|
|
1834
|
-
}
|
|
1835
|
-
};
|
|
1836
|
-
for (const [animName, anim] of Object.entries(raw.animations as Json)) {
|
|
1837
|
-
if (!isObj(anim)) continue;
|
|
1838
|
-
// group.<constraint> = keys[]
|
|
1839
|
-
for (const group of ['ik', 'transform'] as const) {
|
|
1840
|
-
if (!isObj(anim[group])) continue;
|
|
1841
|
-
for (const [name, keys] of Object.entries(anim[group] as Json)) {
|
|
1842
|
-
checkTarget(`animation "${animName}" ${group} timeline "${name}"`, group, name, [['', keys]]);
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1845
|
-
// group.<constraint>.<timeline> = keys[]
|
|
1846
|
-
for (const group of NAMED_TIMELINE_GROUPS) {
|
|
1847
|
-
if (!isObj(anim[group])) continue;
|
|
1848
|
-
for (const [name, timelines] of Object.entries(anim[group] as Json)) {
|
|
1849
|
-
const at = `animation "${animName}" ${group} constraint "${name}"`;
|
|
1850
|
-
if (!isObj(timelines)) {
|
|
1851
|
-
sawATimeline = true;
|
|
1852
|
-
fail(
|
|
1853
|
-
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
1854
|
-
`${at}: this group maps a constraint to NAMED timelines ` +
|
|
1855
|
-
`(${Object.keys(CHANNELS_BY_KIND[group]).join('/')}), and this one holds ${JSON.stringify(timelines)} — ` +
|
|
1856
|
-
'a bare key array here is the ik/transform shape and is walked as an object',
|
|
1857
|
-
);
|
|
1858
|
-
continue;
|
|
1859
|
-
}
|
|
1860
|
-
// The empty name is not a miss: it is the physics group's global form,
|
|
1861
|
-
// which `SkeletonJson` loads as `constraintIndex -1` rather than looking
|
|
1862
|
-
// anything up (`:1048-1054`), and which writes every physics constraint
|
|
1863
|
-
// that declares the timeline's property global (issue #726). Refusing
|
|
1864
|
-
// it as "no constraint called ''" was a sentence that is false about
|
|
1865
|
-
// the file. What CAN be wrong with it is that it reaches nobody: the
|
|
1866
|
-
// parser accepts it, the runtime walks every constraint, and none of
|
|
1867
|
-
// them takes the key.
|
|
1868
|
-
if (group === 'physics' && name === '') {
|
|
1869
|
-
sawATimeline = true;
|
|
1870
|
-
for (const [timelineName] of Object.entries(timelines)) {
|
|
1871
|
-
const timeline = unnamedPhysicsTimeline(timelineName);
|
|
1872
|
-
// A name the parser skips is skipped here too: no timeline exists
|
|
1873
|
-
// to reach anybody, and whether the NAME is right is not a target
|
|
1874
|
-
// question.
|
|
1875
|
-
if (timeline === null || unnamedPhysicsReach(timeline, rawPhysics).length > 0) continue;
|
|
1876
|
-
const resets = timeline instanceof PhysicsConstraintResetTimeline;
|
|
1877
|
-
fail(
|
|
1878
|
-
'A34_CONSTRAINT_TIMELINE_TARGETS',
|
|
1879
|
-
`${at} timeline "${timelineName}": a physics group that names no constraint writes every physics ` +
|
|
1880
|
-
`constraint ${resets ? 'the skeleton has' : `declaring "${timelineName}Global": true`}, and ` +
|
|
1881
|
-
(rawPhysics.length === 0
|
|
1882
|
-
? 'the skeleton has no physics constraint'
|
|
1883
|
-
: `none of ${rawPhysics.map((one) => `"${String(one.name)}"`).join(', ')} does`) +
|
|
1884
|
-
' — the parser loads it, the runtime walks every constraint, and no constraint takes the key',
|
|
1885
|
-
);
|
|
1886
|
-
}
|
|
1887
|
-
checkKeys(at, Object.entries(timelines));
|
|
1888
|
-
continue;
|
|
1889
|
-
}
|
|
1890
|
-
checkTarget(at, group, name, Object.entries(timelines));
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
}
|
|
1894
|
-
if (!sawATimeline) {
|
|
1895
|
-
return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'no animation carries a constraint timeline');
|
|
1896
|
-
}
|
|
1897
|
-
});
|
|
2296
|
+
//
|
|
2297
|
+
// ✂️ Moved whole since issue #1025 (cut 4c-5): the body is
|
|
2298
|
+
// `./assertions/bodies/a34.ts`, over this file's constraint groups as the
|
|
2299
|
+
// raw JSON states them (`rawConstraintTargets`), and whom a physics timeline
|
|
2300
|
+
// naming no constraint reaches is still the parser's timeline asked of the
|
|
2301
|
+
// file's constraint objects, through the facts.
|
|
2302
|
+
check('A34_CONSTRAINT_TIMELINE_TARGETS', () =>
|
|
2303
|
+
raw ? a34ConstraintTimelineTargets(verdicts, rawConstraintTargets(raw)) : skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'the skeleton JSON did not parse (A00 owns that failure)'),
|
|
2304
|
+
);
|
|
1898
2305
|
|
|
1899
2306
|
// --- A35: a deform key's run lands inside the attachment it edits ---------
|
|
1900
2307
|
//
|
|
@@ -2141,19 +2548,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2141
2548
|
|
|
2142
2549
|
// --- A12: no dark / two-colour tint --------------------------------------
|
|
2143
2550
|
// Parsed, then silently ignored by spine-html.
|
|
2144
|
-
check('A12_NO_DARK_COLOR', () =>
|
|
2145
|
-
const slots = Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : [];
|
|
2146
|
-
for (const slot of slots) {
|
|
2147
|
-
if (isObj(slot) && 'dark' in slot) {
|
|
2148
|
-
fail('A12_NO_DARK_COLOR', `slot "${String(slot.name)}" declares a dark colour; the renderer ignores it`);
|
|
2149
|
-
}
|
|
2150
|
-
}
|
|
2151
|
-
walkTimelines(raw, (path, kind, name) => {
|
|
2152
|
-
if (kind === 'slot' && (name === 'rgba2' || name === 'rgb2')) {
|
|
2153
|
-
fail('A12_NO_DARK_COLOR', `${path}: two-colour timeline "${name}" is silently ignored by the renderer`);
|
|
2154
|
-
}
|
|
2155
|
-
});
|
|
2156
|
-
});
|
|
2551
|
+
check('A12_NO_DARK_COLOR', () => a12NoDarkColor(verdicts, rawSkeletonRoster(raw), rawSlotTimelines(raw)));
|
|
2157
2552
|
|
|
2158
2553
|
// --- A05: curve arrays are 4 numbers per value channel --------------------
|
|
2159
2554
|
check('A05_CURVE_ARRAY_LENGTH', () => {
|
|
@@ -2407,238 +2802,12 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2407
2802
|
// reversals out of it and the report prints the rest. What stays here is the
|
|
2408
2803
|
// SEVERITY and the exemption: the survey has no opinion about either, which
|
|
2409
2804
|
// is what lets a report run it with no exemption at all.
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
}
|
|
2417
|
-
const survey = surveyDeformKeys(data, new Set(input.rig.deformMayFold));
|
|
2418
|
-
// 🚨 Which dial was turned, when rigc's two halves did not simply agree
|
|
2419
|
-
// about that — and BEFORE any of the returns below, because every one of
|
|
2420
|
-
// them is a run that posed frames through this dial (issue #427).
|
|
2421
|
-
//
|
|
2422
|
-
// The verdict used to live in `DeformReach.label`, which `explain` prints
|
|
2423
|
-
// and nothing else does, so a `build`-only run — the normal loop, and the
|
|
2424
|
-
// one an agent that cannot see the rig actually runs — never learned that
|
|
2425
|
-
// the artifact and the probe named different fields.
|
|
2426
|
-
//
|
|
2427
|
-
// ⛔ Not a refusal, and the measurement rather than taste is why (#427).
|
|
2428
|
-
// The frames this survey posed were each checked against `SliderPose.time`
|
|
2429
|
-
// by the runtime itself, so a disagreement cannot make it pose one that
|
|
2430
|
-
// does not happen; and the field it drives is the largest response the
|
|
2431
|
-
// probe found, so it cannot make it miss one that does. What a
|
|
2432
|
-
// disagreement CAN do is leave the rig naming a property no settable value
|
|
2433
|
-
// of turns far enough — which is what `outside` measures and what an
|
|
2434
|
-
// author can act on. A refusal would refuse a rig spine-core poses
|
|
2435
|
-
// correctly at every key, with no edit that would make it green.
|
|
2436
|
-
//
|
|
2437
|
-
// 🔒 A tie is not a disagreement and gets a line that cannot be read as
|
|
2438
|
-
// one: there the probe named no field, the artifact broke the tie, and the
|
|
2439
|
-
// parent-45° geometry that reaches it is legitimate.
|
|
2440
|
-
if (survey.dialTies.length) {
|
|
2441
|
-
stats.deformDialsTied = survey.dialTies.length;
|
|
2442
|
-
stats.deformDialTied = survey.dialTies.map(dialTieText).join(',');
|
|
2443
|
-
}
|
|
2444
|
-
if (survey.dialDisputes.length) {
|
|
2445
|
-
stats.deformDialsDisagreed = survey.dialDisputes.length;
|
|
2446
|
-
stats.deformDialDisagreed = survey.dialDisputes.map(dialDisputeText).join(',');
|
|
2447
|
-
}
|
|
2448
|
-
/** A key this rule is refusing, by the triple that identifies it. */
|
|
2449
|
-
const refusedKey = new Set<string>();
|
|
2450
|
-
for (const key of survey.keys) {
|
|
2451
|
-
// ⭐ The one thing this rule cannot say about a key that draws nothing.
|
|
2452
|
-
// Its own message below states the harm as "draws its texture
|
|
2453
|
-
// backwards", and that sentence is false when no pixel of the mesh
|
|
2454
|
-
// lands at this time — the slot has faded to alpha 0, or shows another
|
|
2455
|
-
// attachment. So the key is measured, reported and not gated (issue
|
|
2456
|
-
// #401). Per key and per time: the SAME slot folding at full alpha in
|
|
2457
|
-
// another animation, or at another key, is refused as before, which is
|
|
2458
|
-
// what makes this a measurement rather than a second `deformMayFold`.
|
|
2459
|
-
if (key.draw.blank !== null) continue;
|
|
2460
|
-
// And the one thing it cannot say about a key at a time no dial selects
|
|
2461
|
-
// (issue #407): the frame posed is not this key's, so its geometry
|
|
2462
|
-
// belongs to some other time and a winding read off it would be a
|
|
2463
|
-
// measurement of the wrong thing. Named below, on the stats line.
|
|
2464
|
-
if (key.dial?.unreachable === true) continue;
|
|
2465
|
-
if (key.reversed.length === 0) continue;
|
|
2466
|
-
refusedKey.add(`${key.animation} ${key.slot} ${key.attachment} ${key.key}`);
|
|
2467
|
-
const shown = key.reversed
|
|
2468
|
-
.slice(0, 4)
|
|
2469
|
-
.map((r) => `${r.triangle} [${r.ids.join(',')}] ${r.before.toFixed(3)} -> ${r.after.toFixed(3)}px²`);
|
|
2470
|
-
const more = key.reversed.length > shown.length ? `, and ${key.reversed.length - shown.length} more` : '';
|
|
2471
|
-
fail(
|
|
2472
|
-
'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
|
|
2473
|
-
// ⚠️ The frame is in the message whenever it is not the track, because
|
|
2474
|
-
// the same key can be refused in one frame and passed over in another
|
|
2475
|
-
// — two sliders applying one animation are two frames — and a message
|
|
2476
|
-
// that named only the key would be ambiguous about which (issue #407).
|
|
2477
|
-
`animation "${key.animation}"${frameClause(key.reach)} deform ${key.slot}/${key.attachment} ` +
|
|
2478
|
-
`key ${key.key} (t=${key.time}s): ` +
|
|
2479
|
-
`${key.reversed.length} of ${key.triangles} triangle(s) reverse winding — triangle ${shown.join('; triangle ')}` +
|
|
2480
|
-
`${more}. The mesh has turned inside out there and draws its texture backwards` +
|
|
2481
|
-
// The alpha is in the message whenever it is not full, because the
|
|
2482
|
-
// one thing that would make this key exempt is alpha exactly 0 and
|
|
2483
|
-
// an author who has already faded the part half out needs to be
|
|
2484
|
-
// told that half is not none (issue #401).
|
|
2485
|
-
(key.draw.alpha === 1
|
|
2486
|
-
? ''
|
|
2487
|
-
: ` at alpha ${key.draw.alpha.toFixed(4)} — visible at that strength, and only alpha exactly 0 draws ` +
|
|
2488
|
-
'no pixels at all') +
|
|
2489
|
-
'. Fix the key\'s ' +
|
|
2490
|
-
'offsets in the motion spec\'s deform timeline (a projection past its fold angle is the usual ' +
|
|
2491
|
-
'cause — docs/FACE.md §4.2 has the closed form), or, if this slot folds on purpose, declare it ' +
|
|
2492
|
-
`in the rig spec as invariants.deformMayFold: [{ "slot": "${key.slot}", "why": … }]`,
|
|
2493
|
-
);
|
|
2494
|
-
}
|
|
2495
|
-
// --- and the folds no key lands on (issue #403) ------------------------
|
|
2496
|
-
let spanFolds = 0;
|
|
2497
|
-
for (const span of survey.spans) {
|
|
2498
|
-
if (span.fold === null) continue;
|
|
2499
|
-
// Suppressed when a bounding key already says it: one defect, one
|
|
2500
|
-
// message. The span check is here for what the keys cannot see.
|
|
2501
|
-
const bounded = `${span.animation} ${span.slot} ${span.attachment} `;
|
|
2502
|
-
if (refusedKey.has(bounded + span.fromKey) || refusedKey.has(bounded + span.toKey)) continue;
|
|
2503
|
-
spanFolds++;
|
|
2504
|
-
const at = span.fold;
|
|
2505
|
-
const shown = at.measure.reversed
|
|
2506
|
-
.slice(0, 4)
|
|
2507
|
-
.map((r) => `${r.triangle} [${r.ids.join(',')}] ${r.before.toFixed(3)} -> ${r.after.toFixed(3)}px²`);
|
|
2508
|
-
const more =
|
|
2509
|
-
at.measure.reversed.length > shown.length ? `, and ${at.measure.reversed.length - shown.length} more` : '';
|
|
2510
|
-
// A stepped segment interpolates NOTHING — it holds the earlier key's
|
|
2511
|
-
// geometry across the whole span — so saying "the runtime interpolates"
|
|
2512
|
-
// there would be telling the author to look for a defect in the wrong
|
|
2513
|
-
// place. What changed across a stepped span is what the slot DRAWS.
|
|
2514
|
-
const held = span.curve === 'stepped';
|
|
2515
|
-
fail(
|
|
2516
|
-
'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
|
|
2517
|
-
`animation "${span.animation}"${frameClause(span.reach)} deform ${span.slot}/${span.attachment} ` +
|
|
2518
|
-
`BETWEEN key ${span.fromKey} ` +
|
|
2519
|
-
`(t=${span.fromTime}s) and key ${span.toKey} (t=${span.toTime}s), at t=${at.time.toFixed(6)}s` +
|
|
2520
|
-
(held ? ' (a stepped segment)' : ` — ${(at.percent * 100).toFixed(1)}% of the way from one to the other`) +
|
|
2521
|
-
`: ${at.measure.reversed.length} of ${at.measure.triangles} triangle(s) reverse winding — ` +
|
|
2522
|
-
`triangle ${shown.join('; triangle ')}${more}. NO KEY LANDS THERE: ` +
|
|
2523
|
-
(held
|
|
2524
|
-
? `a stepped segment interpolates nothing, it HOLDS key ${span.fromKey}'s geometry across the whole ` +
|
|
2525
|
-
'span — so the fold is that key\'s and what changes here is what the slot draws'
|
|
2526
|
-
: 'the runtime interpolates between the two keys, and the mesh is inside out for part of the way') +
|
|
2527
|
-
', drawing its texture backwards' +
|
|
2528
|
-
(at.measure.draw.alpha === 1
|
|
2529
|
-
? ''
|
|
2530
|
-
: ` at alpha ${at.measure.draw.alpha.toFixed(4)} — visible at that strength, and only alpha exactly 0 ` +
|
|
2531
|
-
'draws no pixels at all') +
|
|
2532
|
-
'. ' +
|
|
2533
|
-
(held
|
|
2534
|
-
? `Fix key ${span.fromKey}'s offsets, or keep the slot drawing nothing for as long as it holds them`
|
|
2535
|
-
: 'Add a key inside the span so the geometry the runtime passes through is geometry you wrote, or ' +
|
|
2536
|
-
"move the two keys' offsets closer together (a projection past its fold angle is the usual cause " +
|
|
2537
|
-
'— docs/FACE.md §4.2 has the closed form)') +
|
|
2538
|
-
(at.measure.draw.alpha === 1
|
|
2539
|
-
? ''
|
|
2540
|
-
: '; if the part is being faded out over this turn, land the alpha-0 key BEFORE the folding key ' +
|
|
2541
|
-
'rather than on it, so every frame that folds is a frame that draws nothing (docs/FACE.md §9.2)') +
|
|
2542
|
-
`, or, if this slot folds on purpose, declare it in the rig spec as invariants.deformMayFold: ` +
|
|
2543
|
-
`[{ "slot": "${span.slot}", "why": … }]`,
|
|
2544
|
-
);
|
|
2545
|
-
}
|
|
2546
|
-
if (survey.timelines === 0) {
|
|
2547
|
-
return skip('A39_DEFORM_KEEPS_TRIANGLE_WINDING', 'no animation carries a deform timeline');
|
|
2548
|
-
}
|
|
2549
|
-
if (survey.keys.length === 0) {
|
|
2550
|
-
const why = [
|
|
2551
|
-
survey.exempted.length ? `the rig declares ${survey.exempted.join(', ')} as deformMayFold` : '',
|
|
2552
|
-
survey.notAMesh.length ? `${survey.notAMesh.join(', ')} deform an attachment with no triangles` : '',
|
|
2553
|
-
].filter(Boolean);
|
|
2554
|
-
return skip(
|
|
2555
|
-
'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
|
|
2556
|
-
`no deform timeline here has a winding to keep: ${why.join('; ') || 'every mesh keyed has no triangles'}`,
|
|
2557
|
-
);
|
|
2558
|
-
}
|
|
2559
|
-
// ⚠️ Silence is not a pass. A key passed over because nothing of it is
|
|
2560
|
-
// drawn has to be visible on a green run too — this is the only surface
|
|
2561
|
-
// `validate` has, and `explain`'s DEFORM block prints the whole sentence
|
|
2562
|
-
// beside the key's own figures.
|
|
2563
|
-
// ⚠️ Unreachable first and `blank` second, in the survey's own order, so
|
|
2564
|
-
// the two counts partition the ungated keys instead of double-counting a
|
|
2565
|
-
// key that is both.
|
|
2566
|
-
const unreachable = survey.keys.filter((k) => k.dial?.unreachable === true);
|
|
2567
|
-
const blank = survey.keys.filter((k) => k.dial?.unreachable !== true && k.draw.blank !== null);
|
|
2568
|
-
const ungated = blank.length + unreachable.length;
|
|
2569
|
-
const name = (k: (typeof survey.keys)[number]): string =>
|
|
2570
|
-
`${k.animation}/${k.slot}/${k.attachment}#${k.key}:${k.draw.showsThisMesh ? 'alpha0' : 'notShown'}`;
|
|
2571
|
-
// ⚠️ `&& spanFolds === 0` because a rig whose every key draws nothing can
|
|
2572
|
-
// still fold at a time between two of them that DOES draw — that is issue
|
|
2573
|
-
// #403's own case, and a SKIP printed over a refusal would be this rule
|
|
2574
|
-
// reporting "nothing to measure" about the thing it just measured.
|
|
2575
|
-
if (ungated === survey.keys.length && spanFolds === 0) {
|
|
2576
|
-
const first = blank[0] ?? unreachable[0];
|
|
2577
|
-
return skip(
|
|
2578
|
-
'A39_DEFORM_KEEPS_TRIANGLE_WINDING',
|
|
2579
|
-
`no deform key here is measurable in the frame its animation is reached in — ` +
|
|
2580
|
-
`${first.animation} ${first.slot}/${first.attachment} key ${first.key}: ` +
|
|
2581
|
-
`${first.draw.blank ?? unreachableWhy(first)}` +
|
|
2582
|
-
(survey.keys.length > 1 ? `, and ${survey.keys.length - 1} more key(s) like it` : '') +
|
|
2583
|
-
(unreachable.length
|
|
2584
|
-
? `. ${unreachable.length} of them at a time no dial selects, which is a rig defect this rule does ` +
|
|
2585
|
-
'not refuse and does not pass over in silence either'
|
|
2586
|
-
: '') +
|
|
2587
|
-
(survey.spans.length
|
|
2588
|
-
? `. The ${survey.spans.length} span(s) between them were scanned too and none folds where anything ` +
|
|
2589
|
-
'is drawn'
|
|
2590
|
-
: ''),
|
|
2591
|
-
);
|
|
2592
|
-
}
|
|
2593
|
-
stats.deformKeysMeasured = survey.keys.length - ungated;
|
|
2594
|
-
stats.deformTrianglesMeasured = survey.trianglesMeasured;
|
|
2595
|
-
stats.deformTrianglesCollapsed = survey.collapsed;
|
|
2596
|
-
// Which frame each animation was posed in (issue #407) — printed only when
|
|
2597
|
-
// a slider chose one, because on every other rig it says "a track" about
|
|
2598
|
-
// every animation and a stats line that never varies is not a reading.
|
|
2599
|
-
const frames = [...new Map(survey.keys.map((k) => [`${k.animation}/${k.reach.slider ?? 'track'}`, k])).values()];
|
|
2600
|
-
if (frames.some((k) => k.reach.kind === 'slider')) {
|
|
2601
|
-
stats.deformFrames = frames
|
|
2602
|
-
.map((k) => `${k.animation}:${k.reach.kind === 'slider' ? `slider/${k.reach.slider}` : 'track'}`)
|
|
2603
|
-
.join(',');
|
|
2604
|
-
}
|
|
2605
|
-
if (blank.length) {
|
|
2606
|
-
stats.deformKeysNotDrawn = blank.length;
|
|
2607
|
-
stats.deformNotDrawn = blank.map(name).join(',');
|
|
2608
|
-
if (survey.notDrawnReversed) stats.deformNotDrawnReversed = survey.notDrawnReversed;
|
|
2609
|
-
}
|
|
2610
|
-
// 🚨 A key at a time no dial can select is NOT a pass and NOT a refusal —
|
|
2611
|
-
// it is a rig whose slider cannot reach its own animation's key, named
|
|
2612
|
-
// here so a green run cannot be read as having measured it (issue #407).
|
|
2613
|
-
if (unreachable.length) {
|
|
2614
|
-
stats.deformKeysUnreachable = unreachable.length;
|
|
2615
|
-
stats.deformUnreachable = unreachable
|
|
2616
|
-
.map((k) => `${k.animation}/${k.slot}/${k.attachment}#${k.key}@${k.dial?.applied.toFixed(6) ?? '?'}`)
|
|
2617
|
-
.join(',');
|
|
2618
|
-
if (survey.notReachableReversed) stats.deformUnreachableReversed = survey.notReachableReversed;
|
|
2619
|
-
}
|
|
2620
|
-
if (survey.exempted.length) stats.deformFoldExempt = survey.exempted.join(',');
|
|
2621
|
-
// ⚠️ The between-keys scan on the stats line, on a GREEN run too (issue
|
|
2622
|
-
// #403). `deformSpansScanned` is the positive control an agent can read —
|
|
2623
|
-
// a scan that ran and found nothing has to be distinguishable from a scan
|
|
2624
|
-
// that never ran — and `deformSpanProbes` is what it cost: 0 on a rig the
|
|
2625
|
-
// closed form flags nothing in, one posed measurement per flagged window
|
|
2626
|
-
// otherwise.
|
|
2627
|
-
stats.deformSpansScanned = survey.spans.length;
|
|
2628
|
-
// ⚠️ And the ones it could NOT scan, for the same reason the line above
|
|
2629
|
-
// exists: a span bounded by a key at a time no dial selects would be
|
|
2630
|
-
// solved over two poses of some other time, so it is skipped — and a skip
|
|
2631
|
-
// nobody can see is the silence this whole surface is against (#407).
|
|
2632
|
-
if (survey.spansNotScanned) stats.deformSpansNotScanned = survey.spansNotScanned;
|
|
2633
|
-
if (survey.spanProbes) stats.deformSpanProbes = survey.spanProbes;
|
|
2634
|
-
if (survey.spansNotDrawn) stats.deformSpansNotDrawn = survey.spansNotDrawn;
|
|
2635
|
-
// A prediction nothing reproduced. Never a refusal — that would be the
|
|
2636
|
-
// false red issues #44 and #262 already cost this file — and never a
|
|
2637
|
-
// silence either: the one case that reaches it is a weighted mesh whose
|
|
2638
|
-
// bones move across the span, where the closed form's fixed-pose
|
|
2639
|
-
// assumption is the thing that did not hold.
|
|
2640
|
-
if (survey.spansUnconfirmed) stats.deformSpansUnconfirmed = survey.spansUnconfirmed;
|
|
2641
|
-
});
|
|
2805
|
+
//
|
|
2806
|
+
// Since issue #1025 (cut 4c-3) "here" is `./assertions/bodies/a39.ts`, which
|
|
2807
|
+
// takes the survey as a fact: `spineDeformSurvey` hands it spine-core's, and
|
|
2808
|
+
// the model side the core's over the model document — the same body, and the
|
|
2809
|
+
// survey the two hand it held to one by `tools/survey_hashes.ts`.
|
|
2810
|
+
check('A39_DEFORM_KEEPS_TRIANGLE_WINDING', () => a39DeformKeepsTriangleWinding(verdicts, spineDeformSurvey(data), input.rig));
|
|
2642
2811
|
|
|
2643
2812
|
// --- A23, A41, A36, A37, A47, A48: a constraint that does nothing, quietly -
|
|
2644
2813
|
//
|
|
@@ -2785,127 +2954,13 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2785
2954
|
// measurement instead of two exceptions. A list of unobservable spellings
|
|
2786
2955
|
// written into this file would have been the hand-kept table that produced
|
|
2787
2956
|
// the defect above.
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
}
|
|
2796
|
-
// Clause 1 of the "could this be correct?" list above.
|
|
2797
|
-
// "Keyed at all" rather than "keyed live", and on purpose: this clause asks
|
|
2798
|
-
// whether the mix can MOVE from its setup value, so any mix timeline
|
|
2799
|
-
// disqualifies it — the question `keyedBy` answered here, through the one
|
|
2800
|
-
// reading `switchedOn` gives (`./assertions/constraint_words.ts`, over
|
|
2801
|
-
// the same facts the moved constraint bodies read — issue #1025), and
|
|
2802
|
-
// unchanged by issue #752.
|
|
2803
|
-
const mixKeyed = switchedOn(constraintFacts(), (timeline) => timeline.kind === 'slider' && timeline.word === 'mix', 1, () => true);
|
|
2804
|
-
const authoritative = sliders.filter((s) => s.setupPose.mix >= 1 && !mixKeyed.has(data.constraints.indexOf(s)));
|
|
2805
|
-
if (authoritative.length < 2) {
|
|
2806
|
-
return skip(
|
|
2807
|
-
'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
|
|
2808
|
-
`${authoritative.length} of the ${sliders.length} slider constraints apply at full authority; below mix 1 an ` +
|
|
2809
|
-
'apply is a lerp from the current pose rather than an overwrite, so what the others do to a shared property is a weighting',
|
|
2810
|
-
);
|
|
2811
|
-
}
|
|
2812
|
-
/** Which skins switch a slider on, or null when it is active under every skin. */
|
|
2813
|
-
const skinsOf = new Map<SliderData, Set<string> | null>();
|
|
2814
|
-
for (const slider of authoritative) {
|
|
2815
|
-
if (!slider.skinRequired) {
|
|
2816
|
-
skinsOf.set(slider, null);
|
|
2817
|
-
continue;
|
|
2818
|
-
}
|
|
2819
|
-
const names = new Set<string>();
|
|
2820
|
-
for (const skin of data.skins) {
|
|
2821
|
-
if (skin.constraints.includes(slider)) names.add(skin.name);
|
|
2822
|
-
}
|
|
2823
|
-
skinsOf.set(slider, names);
|
|
2824
|
-
}
|
|
2825
|
-
/** Clause 2: can these two ever run in the same frame? */
|
|
2826
|
-
const canOverlap = (a: SliderData, b: SliderData): boolean => {
|
|
2827
|
-
const skinsA = skinsOf.get(a) ?? null;
|
|
2828
|
-
const skinsB = skinsOf.get(b) ?? null;
|
|
2829
|
-
if (!skinsA || !skinsB) return true;
|
|
2830
|
-
for (const name of skinsA) {
|
|
2831
|
-
if (skinsB.has(name)) return true;
|
|
2832
|
-
}
|
|
2833
|
-
return false;
|
|
2834
|
-
};
|
|
2835
|
-
/** What a property id points at, in the words the rig spec uses. */
|
|
2836
|
-
const describe = (timeline: Timeline, id: string): string => {
|
|
2837
|
-
const property = Property[Number(id.split('|')[0])] ?? id;
|
|
2838
|
-
if (isBoneTimeline(timeline)) return `bone "${data.bones[timeline.boneIndex]?.name ?? timeline.boneIndex}" ${property}`;
|
|
2839
|
-
if (timeline instanceof DeformTimeline) {
|
|
2840
|
-
return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" deform of "${timeline.attachment.name}"`;
|
|
2841
|
-
}
|
|
2842
|
-
if (isSlotTimeline(timeline)) return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" ${property}`;
|
|
2843
|
-
if (isConstraintTimeline(timeline) && timeline.constraintIndex >= 0) {
|
|
2844
|
-
return `constraint "${data.constraints[timeline.constraintIndex]?.name ?? timeline.constraintIndex}" ${property}`;
|
|
2845
|
-
}
|
|
2846
|
-
return `the skeleton's ${property}`;
|
|
2847
|
-
};
|
|
2848
|
-
/** property id -> the sliders whose animation keys it, in constraints-array order. */
|
|
2849
|
-
const byProperty = new Map<string, Array<{ slider: SliderData; timeline: Timeline }>>();
|
|
2850
|
-
for (const slider of authoritative) {
|
|
2851
|
-
const seen = new Set<string>();
|
|
2852
|
-
for (const timeline of slider.animation?.timelines ?? []) {
|
|
2853
|
-
for (const id of timeline.propertyIds) {
|
|
2854
|
-
if (seen.has(id)) continue;
|
|
2855
|
-
seen.add(id);
|
|
2856
|
-
byProperty.set(id, [...(byProperty.get(id) ?? []), { slider, timeline }]);
|
|
2857
|
-
}
|
|
2858
|
-
}
|
|
2859
|
-
}
|
|
2860
|
-
/** Posed once per timeline, because the answer is the class's and the rigs that reach here share timelines. */
|
|
2861
|
-
const behaviour = new Map<Timeline, TimelineAddBehaviour>();
|
|
2862
|
-
const addBehaviourOf = (timeline: Timeline): TimelineAddBehaviour => {
|
|
2863
|
-
const known = behaviour.get(timeline);
|
|
2864
|
-
if (known !== undefined) return known;
|
|
2865
|
-
const measured = timelineAddBehaviour(data, timeline);
|
|
2866
|
-
behaviour.set(timeline, measured);
|
|
2867
|
-
return measured;
|
|
2868
|
-
};
|
|
2869
|
-
let shared = 0;
|
|
2870
|
-
for (const [id, users] of byProperty) {
|
|
2871
|
-
if (users.length < 2) continue;
|
|
2872
|
-
shared++;
|
|
2873
|
-
const at = (slider: SliderData): string =>
|
|
2874
|
-
`"${slider.name}" (constraints[${data.constraints.indexOf(slider)}], additive: ${String(slider.additive)})`;
|
|
2875
|
-
const chain = users.map((u) => at(u.slider)).join(', ');
|
|
2876
|
-
for (let j = 1; j < users.length; j++) {
|
|
2877
|
-
const later = users[j];
|
|
2878
|
-
const composes = addBehaviourOf(later.timeline);
|
|
2879
|
-
if (composes === 'accumulates' && later.slider.additive) continue;
|
|
2880
|
-
// Nothing a second slider could take away: applied with the arguments
|
|
2881
|
-
// a slider passes — `firedEvents` null among them — this timeline
|
|
2882
|
-
// moves no pose at all.
|
|
2883
|
-
if (composes === 'inert') continue;
|
|
2884
|
-
const erased = users.slice(0, j).filter((e) => canOverlap(e.slider, later.slider));
|
|
2885
|
-
if (!erased.length) continue;
|
|
2886
|
-
const erasedNames = `${erased.map((e) => `"${e.slider.name}"`).join(', ')} contribute${erased.length === 1 ? 's' : ''}`;
|
|
2887
|
-
const why =
|
|
2888
|
-
composes === 'accumulates'
|
|
2889
|
-
? `slider "${later.slider.name}" applies animation "${later.slider.animation?.name}" with additive false, and at ` +
|
|
2890
|
-
'mix 1 a non-additive apply writes the value outright (`getRelativeValue` returns `setup + value`, ' +
|
|
2891
|
-
'`getAbsoluteValue` returns `value`) rather than adding to the pose it found. Set `"additive": true` on ' +
|
|
2892
|
-
`slider "${later.slider.name}" in the rig spec — rigc will not choose that flag for you — or key this ` +
|
|
2893
|
-
`property from one slider only. [measured] \`${later.timeline.constructor.name}.apply\` posed twice with ` +
|
|
2894
|
-
'`add` set accumulates, so that flag is the repair here'
|
|
2895
|
-
: `the ${Property[Number(id.split('|')[0])] ?? id} timeline they share writes its value outright whatever the ` +
|
|
2896
|
-
`flags say — [measured] \`${later.timeline.constructor.name}.apply\` posed twice with \`add\` set left the ` +
|
|
2897
|
-
'same value there rather than adding to it — so `"additive": true` would NOT compose these. Key this ' +
|
|
2898
|
-
'property from one slider only, or move both edits into the one animation a single slider applies';
|
|
2899
|
-
fail(
|
|
2900
|
-
'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
|
|
2901
|
-
`${describe(later.timeline, id)} is keyed by the animations of ${users.length} sliders — ${chain} — and every ` +
|
|
2902
|
-
`one of them applies at mix 1. Today ${at(later.slider)} wins that property and ${erasedNames} ` +
|
|
2903
|
-
`nothing to it: ${why}.`,
|
|
2904
|
-
);
|
|
2905
|
-
}
|
|
2906
|
-
}
|
|
2907
|
-
stats.sliderSharedTargets = shared;
|
|
2908
|
-
});
|
|
2957
|
+
//
|
|
2958
|
+
// ✂️ Moved whole since issue #1025 (cut 4c-5): the body is
|
|
2959
|
+
// `./assertions/bodies/a40.ts`, over the sliders and their animations'
|
|
2960
|
+
// timelines (`spineSliderComposition`) and the constraint facts, and what a
|
|
2961
|
+
// timeline does with `add` is still this probe, `timelineAddBehaviour`,
|
|
2962
|
+
// asked by the body through the facts.
|
|
2963
|
+
check('A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET', () => a40SlidersComposeOnASharedTarget(verdicts, spineSliderComposition(data), constraintFacts()));
|
|
2909
2964
|
|
|
2910
2965
|
// --- A42: a dial that drives a constraint the array already ran ---------
|
|
2911
2966
|
//
|
|
@@ -2989,271 +3044,104 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
2989
3044
|
check('A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED', () => a38SkinMembersAreSkinRequired(verdicts, data));
|
|
2990
3045
|
|
|
2991
3046
|
// --- A09: compiled duration == declared duration (rule 4) --------------
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
if (!input.declaredDurations) {
|
|
2997
|
-
return skip('A09_ANIMATION_DURATION_MATCHES_SPEC', 'no motion spec supplied, so no declared duration to compare against');
|
|
2998
|
-
}
|
|
2999
|
-
// Same trap one level down. A **static rig** — a skeleton that exists to
|
|
3000
|
-
// be posed and carries no animation at all, which is what
|
|
3001
|
-
// `1-weight-and-mass`'s second export is — declares nothing and loads
|
|
3002
|
-
// nothing, so both loops below iterate zero times and the assertion
|
|
3003
|
-
// reported PASS. That is the vacuous green this report is built to refuse:
|
|
3004
|
-
// there is no duration here, and saying so is the honest answer.
|
|
3005
|
-
if (Object.keys(input.declaredDurations).length === 0 && data.animations.length === 0) {
|
|
3006
|
-
return skip('A09_ANIMATION_DURATION_MATCHES_SPEC', SKIP_NO_DECLARED_DURATION);
|
|
3007
|
-
}
|
|
3008
|
-
for (const [name, declared] of Object.entries(input.declaredDurations)) {
|
|
3009
|
-
const anim = data.findAnimation(name);
|
|
3010
|
-
if (!anim) {
|
|
3011
|
-
fail('A09_ANIMATION_DURATION_MATCHES_SPEC', `spec declares animation "${name}" but the skeleton has none`);
|
|
3012
|
-
continue;
|
|
3013
|
-
}
|
|
3014
|
-
// Two arms, and the asymmetry is the point.
|
|
3015
|
-
//
|
|
3016
|
-
// UNDERSHOOT is R7's question — is the declared duration wrong? An
|
|
3017
|
-
// animation may hold its final pose, so a last key a little before the
|
|
3018
|
-
// end is ordinary and a frame of that is slack.
|
|
3019
|
-
//
|
|
3020
|
-
// OVERSHOOT is a different question with a different tolerance.
|
|
3021
|
-
// `anim.duration` IS the largest key time (`SkeletonJson.ts:1261` takes
|
|
3022
|
-
// the max over every timeline's own duration), so a loaded duration past
|
|
3023
|
-
// the declared one means a KEY is past it — and nothing that plays the
|
|
3024
|
-
// animation for the duration it declares will ever reach that key. Rung
|
|
3025
|
-
// 6 lost a one-frame attachment reveal to a key 3.4e-5 s past the end,
|
|
3026
|
-
// 1/500 of FRAME, which this comparison read as agreement (issue #54).
|
|
3027
|
-
// `compile.ts` refuses that per timeline now; this is the same rule held
|
|
3028
|
-
// against a skeleton the compiler never saw.
|
|
3029
|
-
// The slack is one float32 step at the declared duration, and it is the
|
|
3030
|
-
// same function the compiler's Rule 4 refuses on (`float32Step`, in
|
|
3031
|
-
// `timelines.ts`, which says why it is a step of the float and no longer
|
|
3032
|
-
// a fixed 1e-6 plus one).
|
|
3033
|
-
const slack = float32Step(declared);
|
|
3034
|
-
const past = anim.duration - declared;
|
|
3035
|
-
if (past > slack) {
|
|
3036
|
-
const late = anim.timelines.filter((t) => t.getDuration() - declared > slack).length;
|
|
3037
|
-
fail(
|
|
3038
|
-
'A09_ANIMATION_DURATION_MATCHES_SPEC',
|
|
3039
|
-
`animation "${name}" has ${late} timeline(s) keyed past the declared duration ${declared}s — ` +
|
|
3040
|
-
`the last key is at ${anim.duration}s, ${past.toFixed(6)}s late, so nothing ever samples it`,
|
|
3041
|
-
);
|
|
3042
|
-
} else if (declared - anim.duration > FRAME) {
|
|
3043
|
-
fail(
|
|
3044
|
-
'A09_ANIMATION_DURATION_MATCHES_SPEC',
|
|
3045
|
-
`animation "${name}" loaded duration ${anim.duration}s, spec declares ${declared}s`,
|
|
3046
|
-
);
|
|
3047
|
-
}
|
|
3048
|
-
}
|
|
3049
|
-
for (const anim of data.animations) {
|
|
3050
|
-
if (!(anim.name in input.declaredDurations)) {
|
|
3051
|
-
fail('A09_ANIMATION_DURATION_MATCHES_SPEC', `skeleton has animation "${anim.name}" with no spec entry`);
|
|
3052
|
-
}
|
|
3053
|
-
}
|
|
3054
|
-
});
|
|
3047
|
+
//
|
|
3048
|
+
// The body is `./assertions/bodies/a09.ts` since issue #1025 (cut 4c-3);
|
|
3049
|
+
// what it reads off the loaded skeleton is `spineAnimationDurations`.
|
|
3050
|
+
check('A09_ANIMATION_DURATION_MATCHES_SPEC', () => a09AnimationDurationMatchesSpec(verdicts, spineAnimationDurations(data), input.declaredDurations));
|
|
3055
3051
|
|
|
3056
3052
|
// --- A10: step every animation and look for NaN ------------------------
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
// gate, and `render` then refused the same file by the bone. So the gate
|
|
3150
|
-
// said the skeleton was fine and the renderer said it was not, which is two
|
|
3151
|
-
// definitions of one word. The scan reads the six terms of every bone, then
|
|
3152
|
-
// the vertices of every region and mesh shown — because a bone can be
|
|
3153
|
-
// finite over a vertex that is not (a `scaleX` chain whose product stays
|
|
3154
|
-
// under the largest double while every corner of the child's region
|
|
3155
|
-
// passes it) — and names the bone or the vertex, the term and the frame.
|
|
3156
|
-
//
|
|
3157
|
-
// ⚠️ The setup pose is its own surface and is read as such: every frame
|
|
3158
|
-
// below is posed AFTER `state.apply`, so a bone the animation keys from
|
|
3159
|
-
// t=0 never shows its setup value to the loop, and a runtime that shows
|
|
3160
|
-
// the rig at rest does show it.
|
|
3161
|
-
//
|
|
3162
|
-
// 🔸 Posed once, before any animation is set, rather than once per
|
|
3163
|
-
// animation as it was until #902: `setAnimation` does not touch the
|
|
3164
|
-
// skeleton, so every animation's copy of this pose was the same pose, and
|
|
3165
|
-
// the first of them was the only one ever read.
|
|
3166
|
-
if (data.animations.length === 0) stats.nanStepping = 'skipped';
|
|
3167
|
-
const atRest = new Skeleton(data);
|
|
3168
|
-
atRest.setupPose();
|
|
3169
|
-
atRest.update(0);
|
|
3170
|
-
atRest.updateWorldTransform(Physics.reset);
|
|
3171
|
-
const atSetup = nonFiniteOfPosed('the setup pose', atRest);
|
|
3172
|
-
if (atSetup !== null) {
|
|
3173
|
-
fail('A10_NO_NAN_AFTER_STEPPING', atSetup);
|
|
3174
|
-
return;
|
|
3175
|
-
}
|
|
3176
|
-
|
|
3177
|
-
/**
|
|
3178
|
-
* What a posed frame shows that the world-transform scan does not read: a
|
|
3179
|
-
* bone posing no inheritance mode, and a slot colour that is not finite.
|
|
3180
|
-
* `where` leads the sentence — the animation's name at a stepped frame,
|
|
3181
|
-
* as it always has, or `the setup pose` on a skeleton with no animation.
|
|
3182
|
-
*/
|
|
3183
|
-
const poseDefect = (where: string, skeleton: Skeleton): string | null => {
|
|
3184
|
-
for (const bone of skeleton.bones) {
|
|
3185
|
-
const pose = bone.appliedPose;
|
|
3186
|
-
// The setup half of the inherit-key clause above: a bone's own
|
|
3187
|
-
// `inherit` goes through the same lookup, and a miss there loads
|
|
3188
|
-
// `undefined` into the setup pose, which every frame copies. Only
|
|
3189
|
-
// reachable on a file rigc did not write — `parseRigSpec` refuses the
|
|
3190
|
-
// spelling.
|
|
3191
|
-
if (!isMode(pose.inherit)) {
|
|
3192
|
-
const rawBone = Array.isArray(raw?.bones)
|
|
3193
|
-
? (raw.bones as unknown[]).find((b) => isObj(b) && b.name === bone.data.name)
|
|
3194
|
-
: undefined;
|
|
3195
|
-
return (
|
|
3196
|
-
`${where}: bone "${bone.data.name}" poses inheritance mode ${String(pose.inherit)} — its setup ` +
|
|
3197
|
-
`spells inherit ${JSON.stringify(isObj(rawBone) ? rawBone.inherit : undefined)}, which the ` +
|
|
3198
|
-
`runtime's mode lookup does not resolve (${BONE_INHERIT_KNOWN}), so its world rotation, scale and shear ` +
|
|
3199
|
-
'are never computed — they stay 0 and everything the bone carries collapses to a point'
|
|
3200
|
-
);
|
|
3201
|
-
}
|
|
3202
|
-
}
|
|
3203
|
-
for (const slot of skeleton.slots) {
|
|
3204
|
-
const c = slot.appliedPose.color;
|
|
3205
|
-
if (![c.r, c.g, c.b, c.a].every(Number.isFinite)) return `${where}: slot "${slot.data.name}" colour is non-finite`;
|
|
3206
|
-
// The other colour a slot poses, and it was outside this loop until
|
|
3207
|
-
// issue #690 for the reason every gap here has: nothing emitted one.
|
|
3208
|
-
// `null` is the ordinary case — a slot with no `dark` allocates no
|
|
3209
|
-
// dark colour at all — and is not a reading to make, so it is
|
|
3210
|
-
// skipped rather than treated as zero.
|
|
3211
|
-
const d = slot.appliedPose.darkColor;
|
|
3212
|
-
if (d !== null && ![d.r, d.g, d.b].every(Number.isFinite)) {
|
|
3213
|
-
return `${where}: slot "${slot.data.name}" dark colour is non-finite`;
|
|
3214
|
-
}
|
|
3215
|
-
}
|
|
3216
|
-
return null;
|
|
3217
|
-
};
|
|
3218
|
-
|
|
3219
|
-
// The stepping half, and on a static rig there is nothing to step: the
|
|
3220
|
-
// setup pose is then the only frame the skeleton has, and the two readings
|
|
3221
|
-
// a stepped frame gets are made on it instead, so a colour or an
|
|
3222
|
-
// inheritance mode the rig shows at rest is not left to `render`.
|
|
3223
|
-
if (data.animations.length === 0) {
|
|
3224
|
-
const atRestDefect = poseDefect('the setup pose', atRest);
|
|
3225
|
-
if (atRestDefect !== null) fail('A10_NO_NAN_AFTER_STEPPING', atRestDefect);
|
|
3226
|
-
return;
|
|
3227
|
-
}
|
|
3228
|
-
for (const anim of data.animations) {
|
|
3229
|
-
const skeleton = new Skeleton(data);
|
|
3230
|
-
const state = new AnimationState(new AnimationStateData(data));
|
|
3231
|
-
state.setAnimation(0, anim.name, true);
|
|
3232
|
-
skeleton.setupPose();
|
|
3233
|
-
skeleton.update(0);
|
|
3234
|
-
skeleton.updateWorldTransform(Physics.reset);
|
|
3235
|
-
const step = Math.max(anim.duration, 1) / STEP_FRAMES;
|
|
3236
|
-
for (let i = 0; i < STEP_FRAMES; i++) {
|
|
3237
|
-
state.update(step);
|
|
3238
|
-
state.apply(skeleton);
|
|
3239
|
-
skeleton.update(step);
|
|
3240
|
-
skeleton.updateWorldTransform(Physics.update);
|
|
3241
|
-
const found = nonFiniteOfPosed(
|
|
3242
|
-
`animation ${JSON.stringify(anim.name)} frame ${i + 1} of ${STEP_FRAMES} (t=${((i + 1) * step).toFixed(4)}s)`,
|
|
3243
|
-
skeleton,
|
|
3244
|
-
);
|
|
3245
|
-
if (found !== null) {
|
|
3246
|
-
fail('A10_NO_NAN_AFTER_STEPPING', found);
|
|
3247
|
-
return;
|
|
3248
|
-
}
|
|
3249
|
-
const defect = poseDefect(anim.name, skeleton);
|
|
3250
|
-
if (defect !== null) {
|
|
3251
|
-
fail('A10_NO_NAN_AFTER_STEPPING', defect);
|
|
3252
|
-
return;
|
|
3253
|
-
}
|
|
3254
|
-
}
|
|
3255
|
-
}
|
|
3256
|
-
});
|
|
3053
|
+
// The body is `./assertions/bodies/a10.ts` since issue #1025 (cut 4c-5a); what it
|
|
3054
|
+
// reads off the loaded skeleton is `spineSteppedPoses` (and the raw JSON's bone
|
|
3055
|
+
// timelines, `rawBoneTimelines`). The argument for each clause, as it stood
|
|
3056
|
+
// inside the body:
|
|
3057
|
+
//
|
|
3058
|
+
// Two clauses, and since issue #902 each is read on its own subject. The
|
|
3059
|
+
// SETUP POSE is posed from the bones, so it has something to read on any
|
|
3060
|
+
// skeleton that carries one; the STEPPED FRAMES are posed once per
|
|
3061
|
+
// animation, so a skeleton with none has nothing to step. This used to
|
|
3062
|
+
// skip the whole rule on the second fact alone, which was A15's pattern
|
|
3063
|
+
// (#580) for a rule whose only clause was the stepping — and #882 gave it
|
|
3064
|
+
// the setup clause without moving the skip. Measured on the static probe
|
|
3065
|
+
// with a leaf bone at `rotation: 1e309` in its emitted file: `validate`
|
|
3066
|
+
// printed this rule as SKIP and the run green under both profiles, and
|
|
3067
|
+
// `render` refused the same file by the bone. A static rig's setup pose
|
|
3068
|
+
// is the whole of what it shows, so that was the one pose nothing read.
|
|
3069
|
+
//
|
|
3070
|
+
// ⇒ The multi-clause rule of #580 now governs, the shape A09, A13, A33
|
|
3071
|
+
// and A38 carry: the rule SKIPs only when neither clause has anything to
|
|
3072
|
+
// measure, the clause that ran decides PASS or FAIL, and the clause that
|
|
3073
|
+
// had nothing is named on the stats line (`nanStepping=skipped`, beside
|
|
3074
|
+
// `animations=0`) the way A47 names a constraint it did not measure. A
|
|
3075
|
+
// PASS row carries no detail, which is why the stats line is where.
|
|
3076
|
+
//
|
|
3077
|
+
// ⚠️ A09 does not follow, and that is its own name read at its word: a
|
|
3078
|
+
// declared duration is a fact about an animation and nothing else, so a
|
|
3079
|
+
// static rig still gives it nothing at all.
|
|
3080
|
+
//
|
|
3081
|
+
// -- the bone's inheritance mode, posed at every `inherit` key ---------
|
|
3082
|
+
//
|
|
3083
|
+
// 🚨 The one bone timeline whose value is a NAME, and the only one that
|
|
3084
|
+
// can pose a NaN with a finite world: `SkeletonJson` resolves a key's mode
|
|
3085
|
+
// through `Utils.enumValue`, which folds the first letter's case and
|
|
3086
|
+
// nothing else, so `"NOSCALE"` resolves to `undefined` and the timeline's
|
|
3087
|
+
// `Float32Array` frame stores NaN. `InheritTimeline.apply` then sets
|
|
3088
|
+
// `pose.inherit = NaN`, `updateWorldTransform`'s switch matches no case,
|
|
3089
|
+
// and the bone keeps whatever world matrix it had — measured on a forged
|
|
3090
|
+
// two-bone chain: at the key the child's `a,b,c,d` equal the setup
|
|
3091
|
+
// Normal-mode pose to the last digit while the file says `noScale`. The
|
|
3092
|
+
// world position stays finite, so the loop below never saw it (#733).
|
|
3093
|
+
//
|
|
3094
|
+
// ⚠️ Posed AT each key rather than read off the stepping loop, because a
|
|
3095
|
+
// stepped value lives from its key to the next one and a sampling grid
|
|
3096
|
+
// can step over a short span entirely. What is judged is whether the
|
|
3097
|
+
// posed value IS a mode — which is this assertion's name exactly — and
|
|
3098
|
+
// not which mode: for any spelling the lookup resolves, the mode posed
|
|
3099
|
+
// is the mode written by construction of the same lookup, so an equality
|
|
3100
|
+
// here would be the parser agreeing with itself.
|
|
3101
|
+
//
|
|
3102
|
+
// -- the whole world transform, at the setup pose and every stepped frame
|
|
3103
|
+
//
|
|
3104
|
+
// 🚨 Read through `firstNonFinite` (`./nonfinite.ts`) over `posedNumbersOf`,
|
|
3105
|
+
// the scan `render` refuses on, and not a second opinion of it (issue #882). This loop read `worldX` and
|
|
3106
|
+
// `worldY` alone until then, and a bone at `rotation: 1e309` — in its setup
|
|
3107
|
+
// pose or as a `rotate` key — poses a finite position over a NaN `a`, `b`,
|
|
3108
|
+
// `c` and `d` whenever it has no child offset from it: measured on every
|
|
3109
|
+
// leaf bone of the three generated probes, 20 of 20 green through the whole
|
|
3110
|
+
// gate, and `render` then refused the same file by the bone. So the gate
|
|
3111
|
+
// said the skeleton was fine and the renderer said it was not, which is two
|
|
3112
|
+
// definitions of one word. The scan reads the six terms of every bone, then
|
|
3113
|
+
// the vertices of every region and mesh shown — because a bone can be
|
|
3114
|
+
// finite over a vertex that is not (a `scaleX` chain whose product stays
|
|
3115
|
+
// under the largest double while every corner of the child's region
|
|
3116
|
+
// passes it) — and names the bone or the vertex, the term and the frame.
|
|
3117
|
+
//
|
|
3118
|
+
// ⚠️ The setup pose is its own surface and is read as such: every frame
|
|
3119
|
+
// below is posed AFTER `state.apply`, so a bone the animation keys from
|
|
3120
|
+
// t=0 never shows its setup value to the loop, and a runtime that shows
|
|
3121
|
+
// the rig at rest does show it.
|
|
3122
|
+
//
|
|
3123
|
+
// 🔸 Posed once, before any animation is set, rather than once per
|
|
3124
|
+
// animation as it was until #902: `setAnimation` does not touch the
|
|
3125
|
+
// skeleton, so every animation's copy of this pose was the same pose, and
|
|
3126
|
+
// the first of them was the only one ever read.
|
|
3127
|
+
//
|
|
3128
|
+
// The setup half of the inherit-key clause above: a bone's own
|
|
3129
|
+
// `inherit` goes through the same lookup, and a miss there loads
|
|
3130
|
+
// `undefined` into the setup pose, which every frame copies. Only
|
|
3131
|
+
// reachable on a file rigc did not write — `parseRigSpec` refuses the
|
|
3132
|
+
// spelling.
|
|
3133
|
+
//
|
|
3134
|
+
// The other colour a slot poses, and it was outside this loop until
|
|
3135
|
+
// issue #690 for the reason every gap here has: nothing emitted one.
|
|
3136
|
+
// `null` is the ordinary case — a slot with no `dark` allocates no
|
|
3137
|
+
// dark colour at all — and is not a reading to make, so it is
|
|
3138
|
+
// skipped rather than treated as zero.
|
|
3139
|
+
//
|
|
3140
|
+
// The stepping half, and on a static rig there is nothing to step: the
|
|
3141
|
+
// setup pose is then the only frame the skeleton has, and the two readings
|
|
3142
|
+
// a stepped frame gets are made on it instead, so a colour or an
|
|
3143
|
+
// inheritance mode the rig shows at rest is not left to `render`.
|
|
3144
|
+
check('A10_NO_NAN_AFTER_STEPPING', () => a10NoNanAfterStepping(verdicts, rawBoneTimelines(raw), spineSteppedPoses(raw, data)));
|
|
3257
3145
|
|
|
3258
3146
|
// --- A43: the two-colour tint, read back off the runtime ----------------
|
|
3259
3147
|
//
|
|
@@ -3289,188 +3177,11 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3289
3177
|
// ⚠️ The hex is parsed HERE rather than through `Color.fromString`, and that
|
|
3290
3178
|
// is the point of the clause: a check that read the required value out of the
|
|
3291
3179
|
// same parser it is checking would agree with it whatever it did.
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
|
|
3298
|
-
};
|
|
3299
|
-
/** `rrggbbaa`, or `rrggbb` the runtime opens at alpha 1. Null for anything else. */
|
|
3300
|
-
const readLightHex = (hex: string): [number, number, number, number] | null => {
|
|
3301
|
-
const body = hex.startsWith('#') ? hex.slice(1) : hex;
|
|
3302
|
-
if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
|
|
3303
|
-
const rgb = [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255);
|
|
3304
|
-
return [rgb[0], rgb[1], rgb[2], body.length === 8 ? Number.parseInt(body.slice(6, 8), 16) / 255 : 1];
|
|
3305
|
-
};
|
|
3306
|
-
/**
|
|
3307
|
-
* Half a quantisation step. A channel is one byte in the file and a
|
|
3308
|
-
* `Float32Array` entry in a timeline, so the widest honest gap between the
|
|
3309
|
-
* number written and the number posed is well under `1/510`.
|
|
3310
|
-
*/
|
|
3311
|
-
const STEP = 1 / 510;
|
|
3312
|
-
const off = (found: number, want: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > STEP;
|
|
3313
|
-
const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
|
|
3314
|
-
|
|
3315
|
-
// -- the subjects, read off the FILE ---------------------------------
|
|
3316
|
-
const declared = new Map<string, string>();
|
|
3317
|
-
for (const slot of Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : []) {
|
|
3318
|
-
if (isObj(slot) && typeof slot.dark === 'string') declared.set(String(slot.name), slot.dark);
|
|
3319
|
-
}
|
|
3320
|
-
// `rgb2` joined in issue #730, and it is this rule's subject rather than a
|
|
3321
|
-
// new one's because it is the same tint with the light alpha left out:
|
|
3322
|
-
// `RGB2Timeline` writes the light rgb and the dark colour, and on a slot
|
|
3323
|
-
// with no dark colour it throws in `apply1` exactly as `RGBA2Timeline`
|
|
3324
|
-
// does (measured on a forged file: `TypeError: null is not an object
|
|
3325
|
-
// (evaluating 'dark.r = …')`). The one thing it does differently is the
|
|
3326
|
-
// alpha it does NOT pose, which is `A45`'s question, not this one's.
|
|
3327
|
-
const keyed: Array<{ anim: string; slot: string; timeline: 'rgba2' | 'rgb2'; keys: unknown[] }> = [];
|
|
3328
|
-
const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
|
|
3329
|
-
for (const [animName, anim] of Object.entries(rawAnimations)) {
|
|
3330
|
-
if (!isObj(anim) || !isObj(anim.slots)) continue;
|
|
3331
|
-
for (const [slotName, timelines] of Object.entries(anim.slots)) {
|
|
3332
|
-
if (!isObj(timelines)) continue;
|
|
3333
|
-
for (const timeline of ['rgba2', 'rgb2'] as const) {
|
|
3334
|
-
const keys = timelines[timeline];
|
|
3335
|
-
if (Array.isArray(keys)) keyed.push({ anim: animName, slot: slotName, timeline, keys });
|
|
3336
|
-
}
|
|
3337
|
-
}
|
|
3338
|
-
}
|
|
3339
|
-
if (declared.size === 0 && keyed.length === 0) {
|
|
3340
|
-
return skip('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', SKIP_NO_TWO_COLOR_TINT);
|
|
3341
|
-
}
|
|
3342
|
-
stats.darkSlots = declared.size;
|
|
3343
|
-
stats.rgba2Timelines = keyed.filter((t) => t.timeline === 'rgba2').length;
|
|
3344
|
-
stats.rgb2Timelines = keyed.filter((t) => t.timeline === 'rgb2').length;
|
|
3345
|
-
|
|
3346
|
-
// -- clause 1: the setup pose ----------------------------------------
|
|
3347
|
-
for (const [name, hex] of declared) {
|
|
3348
|
-
const want = readDarkHex(hex);
|
|
3349
|
-
const loaded = data.findSlot(name)?.setupPose.darkColor ?? null;
|
|
3350
|
-
// ⚠️ "The parser kept nothing" is asked FIRST, and the order is the
|
|
3351
|
-
// finding rather than a style: an empty string is both unreadable as a
|
|
3352
|
-
// colour and dropped outright, and only the second sentence is about
|
|
3353
|
-
// what the loaded skeleton holds. Asked the other way round, `""` was
|
|
3354
|
-
// reported as "not six hex digits" — true, and about the file, on the
|
|
3355
|
-
// one input where the file is not what went wrong.
|
|
3356
|
-
if (loaded === null) {
|
|
3357
|
-
fail(
|
|
3358
|
-
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3359
|
-
`slot "${name}" states dark ${JSON.stringify(hex)} and the loaded skeleton holds no dark colour for ` +
|
|
3360
|
-
'it at all — the slot reader takes `dark` through a truthiness test, so a falsy value is dropped ' +
|
|
3361
|
-
'in silence and the slot is tinted with one colour',
|
|
3362
|
-
);
|
|
3363
|
-
continue;
|
|
3364
|
-
}
|
|
3365
|
-
if (want === null) {
|
|
3366
|
-
fail(
|
|
3367
|
-
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3368
|
-
`slot "${name}" states dark ${JSON.stringify(hex)}, which is not six hex digits — the parser reads ` +
|
|
3369
|
-
'fixed two-character slices and stores whatever `parseInt` returns, so the loaded colour is ' +
|
|
3370
|
-
`(${show([loaded.r, loaded.g, loaded.b])}) rather than a failure`,
|
|
3371
|
-
);
|
|
3372
|
-
continue;
|
|
3373
|
-
}
|
|
3374
|
-
const found: [number, number, number] = [loaded.r, loaded.g, loaded.b];
|
|
3375
|
-
if (found.some((n, i) => off(n, want[i]))) {
|
|
3376
|
-
fail(
|
|
3377
|
-
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3378
|
-
`slot "${name}" states dark ${JSON.stringify(hex)} and the runtime loaded (${show(found)}), wanted ` +
|
|
3379
|
-
`(${show(want)})`,
|
|
3380
|
-
);
|
|
3381
|
-
}
|
|
3382
|
-
}
|
|
3383
|
-
|
|
3384
|
-
// -- clauses 2 and 3: every rgba2 and rgb2 timeline --------------------
|
|
3385
|
-
//
|
|
3386
|
-
// ⚠️ Clause 2 poisons its whole ANIMATION, not only its own timeline: the
|
|
3387
|
-
// throw happens inside `state.apply`, which applies every timeline of the
|
|
3388
|
-
// animation at once, so posing a second — correct — `rgba2` timeline in the
|
|
3389
|
-
// same animation would take this assertion down with a `threw:` line
|
|
3390
|
-
// instead of the two named failures it has already worked out.
|
|
3391
|
-
const cannotPose = new Set(keyed.filter((t) => !declared.has(t.slot)).map((t) => t.anim));
|
|
3392
|
-
for (const { anim: animName, slot: slotName, timeline, keys } of keyed) {
|
|
3393
|
-
const where = `animation "${animName}" slot "${slotName}" ${timeline}`;
|
|
3394
|
-
if (!declared.has(slotName)) {
|
|
3395
|
-
fail(
|
|
3396
|
-
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3397
|
-
`${where}: slot "${slotName}" declares no setup "dark", so the runtime allocates no dark colour for ` +
|
|
3398
|
-
`it and applying this animation throws instead of tinting — give the slot a \`dark\`, or key ` +
|
|
3399
|
-
`"${timeline === 'rgba2' ? 'rgba' : 'rgb'}"`,
|
|
3400
|
-
);
|
|
3401
|
-
continue;
|
|
3402
|
-
}
|
|
3403
|
-
if (cannotPose.has(animName)) continue;
|
|
3404
|
-
const animation = data.findAnimation(animName);
|
|
3405
|
-
if (!animation) {
|
|
3406
|
-
fail('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', `${where}: the loaded skeleton has no animation "${animName}"`);
|
|
3407
|
-
continue;
|
|
3408
|
-
}
|
|
3409
|
-
for (const rawKey of keys) {
|
|
3410
|
-
if (!isObj(rawKey)) continue;
|
|
3411
|
-
const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
|
|
3412
|
-
const wantLight = typeof rawKey.light === 'string' ? readLightHex(rawKey.light) : null;
|
|
3413
|
-
const wantDark = typeof rawKey.dark === 'string' ? readDarkHex(rawKey.dark) : null;
|
|
3414
|
-
// ⚠️ Posed BEFORE the key's own spelling is judged, so that a key whose
|
|
3415
|
-
// hex cannot be read is still reported with the colour the runtime
|
|
3416
|
-
// actually holds. `Color.setFromString` slices fixed offsets and stores
|
|
3417
|
-
// whatever `parseInt` gives back, so the value found is the product
|
|
3418
|
-
// here — "not six hex digits" alone would be the value REQUIRED twice
|
|
3419
|
-
// over and the found value nowhere.
|
|
3420
|
-
const skeleton = new Skeleton(data);
|
|
3421
|
-
const state = new AnimationState(new AnimationStateData(data));
|
|
3422
|
-
state.setAnimation(0, animName, false);
|
|
3423
|
-
skeleton.setupPose();
|
|
3424
|
-
skeleton.update(0);
|
|
3425
|
-
skeleton.updateWorldTransform(Physics.reset);
|
|
3426
|
-
// At the key as the runtime stores it — see `atStoredKey` (#771).
|
|
3427
|
-
state.update(atStoredKey(time));
|
|
3428
|
-
state.apply(skeleton);
|
|
3429
|
-
skeleton.update(atStoredKey(time));
|
|
3430
|
-
skeleton.updateWorldTransform(Physics.update);
|
|
3431
|
-
const posed = skeleton.slots.find((s) => s.data.name === slotName)?.appliedPose;
|
|
3432
|
-
const light = posed?.color;
|
|
3433
|
-
const dark = posed?.darkColor ?? null;
|
|
3434
|
-
if (!light || dark === null) {
|
|
3435
|
-
fail(
|
|
3436
|
-
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3437
|
-
`${where} (t=${time}): the posed skeleton has no ${light ? 'dark colour' : 'slot'} to read`,
|
|
3438
|
-
);
|
|
3439
|
-
continue;
|
|
3440
|
-
}
|
|
3441
|
-
const foundLight: [number, number, number, number] = [light.r, light.g, light.b, light.a];
|
|
3442
|
-
const foundDark: [number, number, number] = [dark.r, dark.g, dark.b];
|
|
3443
|
-
if (wantLight === null || wantDark === null) {
|
|
3444
|
-
fail(
|
|
3445
|
-
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3446
|
-
`${where} (t=${time}): the key states light ${JSON.stringify(rawKey.light)} and dark ` +
|
|
3447
|
-
`${JSON.stringify(rawKey.dark)} — an ${timeline} key needs both, each six or eight hex digits — and the ` +
|
|
3448
|
-
`runtime poses light (${show(foundLight)}), dark (${show(foundDark)})`,
|
|
3449
|
-
);
|
|
3450
|
-
continue;
|
|
3451
|
-
}
|
|
3452
|
-
// An `rgb2` key's light colour is three channels, and the posed alpha
|
|
3453
|
-
// is not its to state — `RGB2Timeline` leaves it where it was — so the
|
|
3454
|
-
// comparison is over the channels the timeline writes. For `rgba2`
|
|
3455
|
-
// that is all four, and the line reads as it always did.
|
|
3456
|
-
const lightChannels = timeline === 'rgba2' ? 4 : 3;
|
|
3457
|
-
if (foundLight.slice(0, lightChannels).some((n, i) => off(n, wantLight[i]))) {
|
|
3458
|
-
fail(
|
|
3459
|
-
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3460
|
-
`${where} (t=${time}): light posed (${show(foundLight.slice(0, lightChannels))}), the key states ` +
|
|
3461
|
-
`${JSON.stringify(rawKey.light)} = (${show(wantLight.slice(0, lightChannels))})`,
|
|
3462
|
-
);
|
|
3463
|
-
}
|
|
3464
|
-
if (foundDark.some((n, i) => off(n, wantDark[i]))) {
|
|
3465
|
-
fail(
|
|
3466
|
-
'A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN',
|
|
3467
|
-
`${where} (t=${time}): dark posed (${show(foundDark)}), the key states ${JSON.stringify(rawKey.dark)} ` +
|
|
3468
|
-
`= (${show(wantDark)})`,
|
|
3469
|
-
);
|
|
3470
|
-
}
|
|
3471
|
-
}
|
|
3472
|
-
}
|
|
3473
|
-
});
|
|
3180
|
+
//
|
|
3181
|
+
// The body is `./assertions/bodies/a43.ts` since issue #1025 (cut 4c-3);
|
|
3182
|
+
// what it reads off the skeleton JSON and the loaded skeleton is
|
|
3183
|
+
// `spineTwoColourFacts`.
|
|
3184
|
+
check('A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN', () => a43TwoColorTintLoadsAndPosesAsWritten(verdicts, spineTwoColourFacts(raw, data)));
|
|
3474
3185
|
|
|
3475
3186
|
// --- A45: the separable colour timelines own their channels ------------
|
|
3476
3187
|
//
|
|
@@ -3530,7 +3241,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3530
3241
|
// every key at sampled times — mid-frame, so a float32 key time cannot land
|
|
3531
3242
|
// a sample on a frame boundary — and compares the region the slot shows
|
|
3532
3243
|
// against the one the file's own statement gives: the frame the key's mode,
|
|
3533
|
-
// index and delay give (`frameOf`
|
|
3244
|
+
// index and delay give (`frameOf` in the body), and the frame names
|
|
3534
3245
|
// `attachmentRegionLookups` derives (the core's `frameRegionName`). Neither is
|
|
3535
3246
|
// read off the loaded timeline, so the check is not the runtime agreeing
|
|
3536
3247
|
// with itself.
|
|
@@ -3539,245 +3250,11 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3539
3250
|
// `applyToSlot` returns there, and a series that is hidden is not a series
|
|
3540
3251
|
// showing the wrong frame. A timeline with no comparable sample at all is
|
|
3541
3252
|
// counted in `stats.sequenceSamplesUnshown` rather than failed.
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
/** `skin\0slot\0placeholder` -> the loaded attachment, for the timelines to find. */
|
|
3548
|
-
const byJoin = new Map<string, object>();
|
|
3549
|
-
/** Attachments whose block was already refused above — their timelines say nothing more. */
|
|
3550
|
-
const refused = new Set<object>();
|
|
3551
|
-
const whole = (v: unknown, fallback: number): number | null =>
|
|
3552
|
-
v === undefined ? fallback : typeof v === 'number' && Number.isInteger(v) ? v : null;
|
|
3553
|
-
let blocks = 0;
|
|
3554
|
-
for (const skin of isObj(raw) && Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
|
|
3555
|
-
if (!isObj(skin) || !isObj(skin.attachments)) continue;
|
|
3556
|
-
const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
|
|
3557
|
-
const loadedSkin = data.findSkin(skinName);
|
|
3558
|
-
for (const [slotName, entries] of Object.entries(skin.attachments)) {
|
|
3559
|
-
if (!isObj(entries)) continue;
|
|
3560
|
-
const slotIndex = data.findSlot(slotName)?.index;
|
|
3561
|
-
for (const [placeholder, entry] of Object.entries(entries)) {
|
|
3562
|
-
if (!isObj(entry)) continue;
|
|
3563
|
-
const type = entry.type === undefined ? 'region' : entry.type;
|
|
3564
|
-
if (type !== 'region' && type !== 'mesh' && type !== 'linkedmesh') continue;
|
|
3565
|
-
const loaded = slotIndex === undefined ? null : loadedSkin?.getAttachment(slotIndex, placeholder) ?? null;
|
|
3566
|
-
const where = `skin ${JSON.stringify(skinName)} slot ${JSON.stringify(slotName)} attachment ${JSON.stringify(placeholder)}`;
|
|
3567
|
-
if (loaded !== null) byJoin.set(`${skinName}\u0000${slotName}\u0000${placeholder}`, loaded);
|
|
3568
|
-
if (entry.sequence === undefined || entry.sequence === null) continue;
|
|
3569
|
-
blocks++;
|
|
3570
|
-
const seq = entry.sequence;
|
|
3571
|
-
const name = typeof entry.name === 'string' ? entry.name : placeholder;
|
|
3572
|
-
const path = typeof entry.path === 'string' ? entry.path : name;
|
|
3573
|
-
const count = isObj(seq) ? whole(seq.count, 0) : null;
|
|
3574
|
-
const setup = isObj(seq) ? whole(seq.setup, 0) : null;
|
|
3575
|
-
if ((count === null || setup === null || count < 1) && loaded !== null) refused.add(loaded);
|
|
3576
|
-
if (count === null || setup === null || count < 1) {
|
|
3577
|
-
fail(
|
|
3578
|
-
NAME,
|
|
3579
|
-
`${where}: the sequence states ${JSON.stringify(seq)}` +
|
|
3580
|
-
(isObj(seq) && seq.count === undefined
|
|
3581
|
-
? ' and no "count" — `readSequence` reads 0 (`SkeletonJson.js:644`), so the attachment loads holding no region and draws nothing'
|
|
3582
|
-
: ' — "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'),
|
|
3583
|
-
);
|
|
3584
|
-
continue;
|
|
3585
|
-
}
|
|
3586
|
-
if ((setup < 0 || setup >= count) && loaded !== null) refused.add(loaded);
|
|
3587
|
-
if (setup < 0 || setup >= count) {
|
|
3588
|
-
fail(
|
|
3589
|
-
NAME,
|
|
3590
|
-
`${where}: the sequence's setup frame is ${setup} of a ${count}-frame series (frames 0 to ${count - 1}); ` +
|
|
3591
|
-
'`Sequence.resolveIndex` clamps it, so the setup pose shows ' +
|
|
3592
|
-
`${setup < 0 ? 'no frame at all' : `frame ${count - 1}, which the file does not name`}`,
|
|
3593
|
-
);
|
|
3594
|
-
continue;
|
|
3595
|
-
}
|
|
3596
|
-
if (loaded === null) continue; // A00/A08 own an attachment that did not load
|
|
3597
|
-
series.set(loaded, { where, lookups: attachmentRegionLookups(seq, path), count, setup });
|
|
3598
|
-
}
|
|
3599
|
-
}
|
|
3600
|
-
}
|
|
3601
|
-
|
|
3602
|
-
/**
|
|
3603
|
-
* The frame the file's statement gives for one key at one elapsed time —
|
|
3604
|
-
* A46's prediction, not a call into the runtime. What holds it is the
|
|
3605
|
-
* comparison it feeds: every sample sets it against the frame the posed
|
|
3606
|
-
* slot shows, so a reading that disagreed with the runtime would fail A46
|
|
3607
|
-
* on a correct file. A46 passing with samples posed on the selftest's
|
|
3608
|
-
* series probes is that measurement for the modes they key; `M73` is its
|
|
3609
|
-
* red half.
|
|
3610
|
-
*/
|
|
3611
|
-
const frameOf = (mode: string, index: number, elapsed: number, delay: number, count: number): number => {
|
|
3612
|
-
if (mode === 'hold') return index;
|
|
3613
|
-
let i = index + Math.trunc(elapsed / delay + 0.00001);
|
|
3614
|
-
const n = count * 2 - 2;
|
|
3615
|
-
switch (mode) {
|
|
3616
|
-
case 'once':
|
|
3617
|
-
return Math.min(count - 1, i);
|
|
3618
|
-
case 'loop':
|
|
3619
|
-
return i % count;
|
|
3620
|
-
case 'pingpong':
|
|
3621
|
-
i = n === 0 ? 0 : i % n;
|
|
3622
|
-
return i >= count ? n - i : i;
|
|
3623
|
-
case 'onceReverse':
|
|
3624
|
-
return Math.max(count - 1 - i, 0);
|
|
3625
|
-
case 'loopReverse':
|
|
3626
|
-
return count - 1 - (i % count);
|
|
3627
|
-
default: // pingpongReverse
|
|
3628
|
-
i = n === 0 ? 0 : (i + count - 1) % n;
|
|
3629
|
-
return i >= count ? n - i : i;
|
|
3630
|
-
}
|
|
3631
|
-
};
|
|
3632
|
-
|
|
3633
|
-
let timelines = 0;
|
|
3634
|
-
let compared = 0;
|
|
3635
|
-
let unshown = 0;
|
|
3636
|
-
const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
|
|
3637
|
-
for (const [animName, anim] of Object.entries(rawAnimations)) {
|
|
3638
|
-
if (!isObj(anim) || !isObj(anim.attachments)) continue;
|
|
3639
|
-
for (const [skinName, perSkin] of Object.entries(anim.attachments)) {
|
|
3640
|
-
if (!isObj(perSkin)) continue;
|
|
3641
|
-
for (const [slotName, perSlot] of Object.entries(perSkin)) {
|
|
3642
|
-
if (!isObj(perSlot)) continue;
|
|
3643
|
-
for (const [placeholder, perAttachment] of Object.entries(perSlot)) {
|
|
3644
|
-
if (!isObj(perAttachment) || !Array.isArray(perAttachment.sequence)) continue;
|
|
3645
|
-
const keys = perAttachment.sequence as unknown[];
|
|
3646
|
-
if (keys.length === 0) continue; // `readAnimation` skips it; A34 owns an empty timeline
|
|
3647
|
-
timelines++;
|
|
3648
|
-
const where = `animation ${JSON.stringify(animName)} ${skinName}/${slotName}/${placeholder} sequence`;
|
|
3649
|
-
const keyed = byJoin.get(`${skinName}\u0000${slotName}\u0000${placeholder}`);
|
|
3650
|
-
if (keyed === undefined) continue; // the parser throws on a missing target: A00's
|
|
3651
|
-
if (refused.has(keyed)) continue; // its block is already named above
|
|
3652
|
-
const own = series.get(keyed);
|
|
3653
|
-
if (own === undefined) {
|
|
3654
|
-
fail(
|
|
3655
|
-
NAME,
|
|
3656
|
-
`${where}: the timeline steps an attachment that carries no "sequence" block. The parser gives it a ` +
|
|
3657
|
-
'series of ONE region (`readSequence(null)` is `new Sequence(1, false)`), so every mode shows that ' +
|
|
3658
|
-
'region at every time — measured: a "loop" key on a plain region showed it throughout',
|
|
3659
|
-
);
|
|
3660
|
-
continue;
|
|
3661
|
-
}
|
|
3662
|
-
// -- the keys, as the file states them --------------------------
|
|
3663
|
-
type Key = { time: number; mode: string; index: number; delay: number };
|
|
3664
|
-
const read: Key[] = [];
|
|
3665
|
-
let carried = 0;
|
|
3666
|
-
let malformed = false;
|
|
3667
|
-
keys.forEach((rawKey, k) => {
|
|
3668
|
-
const key = isObj(rawKey) ? rawKey : {};
|
|
3669
|
-
const at = `${where} key ${k}`;
|
|
3670
|
-
const time = typeof key.time === 'number' ? key.time : 0;
|
|
3671
|
-
const mode = key.mode === undefined ? 'hold' : key.mode;
|
|
3672
|
-
const index = key.index === undefined ? 0 : key.index;
|
|
3673
|
-
if (key.delay !== undefined) carried = typeof key.delay === 'number' ? key.delay : Number.NaN;
|
|
3674
|
-
if (typeof mode !== 'string' || !(SEQUENCE_MODES as readonly string[]).includes(mode)) {
|
|
3675
|
-
malformed = true;
|
|
3676
|
-
fail(
|
|
3677
|
-
NAME,
|
|
3678
|
-
`${at} (t=${time}): mode ${JSON.stringify(key.mode)} is not one of the ${SEQUENCE_MODES.length} the ` +
|
|
3679
|
-
`format has (${SEQUENCE_MODES.join(', ')}); the parser reads \`SequenceMode[mode]\`, which is ` +
|
|
3680
|
-
'undefined, stores mode bits 0, and the key plays as "hold"',
|
|
3681
|
-
);
|
|
3682
|
-
return;
|
|
3683
|
-
}
|
|
3684
|
-
if (typeof index !== 'number' || !Number.isInteger(index) || index < 0 || index >= own.count) {
|
|
3685
|
-
malformed = true;
|
|
3686
|
-
fail(
|
|
3687
|
-
NAME,
|
|
3688
|
-
`${at} (t=${time}): index ${JSON.stringify(key.index)} is not a frame of the ${own.count}-frame series ` +
|
|
3689
|
-
`(0 to ${own.count - 1}); the runtime stores \`index << 4\`, truncating a fraction, and ` +
|
|
3690
|
-
'`Sequence.resolveIndex` clamps a frame past the end to the last one',
|
|
3691
|
-
);
|
|
3692
|
-
return;
|
|
3693
|
-
}
|
|
3694
|
-
if (mode !== 'hold' && !(carried > 0)) {
|
|
3695
|
-
malformed = true;
|
|
3696
|
-
fail(
|
|
3697
|
-
NAME,
|
|
3698
|
-
`${at} (t=${time}): "${mode}" at a delay of ${String(carried)}${key.delay === undefined ? ' (carried from the key before, 0 on the first)' : ''} — ` +
|
|
3699
|
-
'the frame advances by `(time - keyTime) / delay`, and at 0 that is Infinity, `Infinity | 0` is 0, ' +
|
|
3700
|
-
'and the key shows its first frame throughout: "hold" spelt as another mode',
|
|
3701
|
-
);
|
|
3702
|
-
return;
|
|
3703
|
-
}
|
|
3704
|
-
read.push({ time, mode, index, delay: carried });
|
|
3705
|
-
});
|
|
3706
|
-
if (malformed) continue;
|
|
3707
|
-
|
|
3708
|
-
// -- the pose, sampled ------------------------------------------
|
|
3709
|
-
const animation = data.findAnimation(animName);
|
|
3710
|
-
const slotIndex = data.findSlot(slotName)?.index;
|
|
3711
|
-
if (animation === null || slotIndex === undefined) continue; // A00's
|
|
3712
|
-
/** `key` is the index into `read`, or -1 before the first key (the setup frame). */
|
|
3713
|
-
const samples: Array<{ time: number; key: number; steps: number }> = [];
|
|
3714
|
-
const end = animation.duration;
|
|
3715
|
-
if (read[0].time > 0) samples.push({ time: read[0].time / 2, key: -1, steps: 0 });
|
|
3716
|
-
read.forEach((key, k) => {
|
|
3717
|
-
const until = k + 1 < read.length ? read[k + 1].time : end;
|
|
3718
|
-
if (key.mode === 'hold') {
|
|
3719
|
-
samples.push({ time: key.time, key: k, steps: 0 });
|
|
3720
|
-
return;
|
|
3721
|
-
}
|
|
3722
|
-
// Mid-frame, and enough steps to wrap every mode at least once.
|
|
3723
|
-
for (let step = 0; step < own.count * 2 + 2; step++) {
|
|
3724
|
-
const time = key.time + (step + 0.5) * key.delay;
|
|
3725
|
-
if (time >= until || time > end) break;
|
|
3726
|
-
samples.push({ time, key: k, steps: step + 0.5 });
|
|
3727
|
-
}
|
|
3728
|
-
});
|
|
3729
|
-
let shownHere = 0;
|
|
3730
|
-
for (const sample of samples) {
|
|
3731
|
-
const skeleton = new Skeleton(data);
|
|
3732
|
-
const state = new AnimationState(new AnimationStateData(data));
|
|
3733
|
-
state.setAnimation(0, animName, false);
|
|
3734
|
-
skeleton.setupPose();
|
|
3735
|
-
skeleton.update(0);
|
|
3736
|
-
skeleton.updateWorldTransform(Physics.reset);
|
|
3737
|
-
// A `hold` sample is AT its key, so it is posed at the key as
|
|
3738
|
-
// the runtime stores it (`atStoredKey`); a mid-frame sample is
|
|
3739
|
-
// half a delay from any key and is posed where it is.
|
|
3740
|
-
state.update(sample.key >= 0 && sample.steps === 0 ? atStoredKey(sample.time) : sample.time);
|
|
3741
|
-
state.apply(skeleton);
|
|
3742
|
-
const pose = skeleton.slots[slotIndex].appliedPose;
|
|
3743
|
-
const shown = pose.attachment;
|
|
3744
|
-
// The slot must show the keyed attachment or one playing its
|
|
3745
|
-
// timelines — the only case `applyToSlot` writes.
|
|
3746
|
-
if (shown === null || (shown !== keyed && shown.timelineAttachment !== keyed)) continue;
|
|
3747
|
-
const drawn = series.get(shown);
|
|
3748
|
-
if (drawn === undefined || drawn.lookups === null) continue;
|
|
3749
|
-
shownHere++;
|
|
3750
|
-
compared++;
|
|
3751
|
-
// The frame count the runtime folds by is the SHOWN attachment's:
|
|
3752
|
-
// a link with a series of its own steps it by its source's keys.
|
|
3753
|
-
const key = sample.key < 0 ? null : read[sample.key];
|
|
3754
|
-
const want = key === null ? drawn.setup : frameOf(key.mode, key.index, sample.time - key.time, key.delay, drawn.count);
|
|
3755
|
-
const sequence = (shown as RegionAttachment | MeshAttachment).sequence;
|
|
3756
|
-
const region = (sequence.regions[sequence.resolveIndex(pose)] as TextureAtlasRegion | null | undefined)?.name ?? null;
|
|
3757
|
-
if (region !== drawn.lookups[want]) {
|
|
3758
|
-
fail(
|
|
3759
|
-
NAME,
|
|
3760
|
-
`${where} (t=${Number(sample.time.toFixed(4))}): the slot shows region ${JSON.stringify(region)}, and ` +
|
|
3761
|
-
`the file states frame ${want} of ${drawn.count} — ${JSON.stringify(drawn.lookups[want])} — ` +
|
|
3762
|
-
(key === null
|
|
3763
|
-
? 'the setup frame, before the first key'
|
|
3764
|
-
: `key ${sample.key} plays "${key.mode}" from frame ${key.index} every ${key.delay}s, ` +
|
|
3765
|
-
`${sample.steps} delay(s) in`),
|
|
3766
|
-
);
|
|
3767
|
-
break;
|
|
3768
|
-
}
|
|
3769
|
-
}
|
|
3770
|
-
if (shownHere === 0) unshown++;
|
|
3771
|
-
}
|
|
3772
|
-
}
|
|
3773
|
-
}
|
|
3774
|
-
}
|
|
3775
|
-
if (blocks === 0 && timelines === 0) return skip(NAME, SKIP_NO_SEQUENCE);
|
|
3776
|
-
stats.sequenceBlocks = blocks;
|
|
3777
|
-
stats.sequenceTimelines = timelines;
|
|
3778
|
-
stats.sequenceSamples = compared;
|
|
3779
|
-
if (unshown > 0) stats.sequenceSamplesUnshown = unshown;
|
|
3780
|
-
});
|
|
3253
|
+
//
|
|
3254
|
+
// The body is `./assertions/bodies/a46.ts` since issue #1025 (cut 4c-3);
|
|
3255
|
+
// what it reads off the skeleton JSON and the loaded skeleton is
|
|
3256
|
+
// `spineSequenceFacts`, which answers an attachment by its address.
|
|
3257
|
+
check('A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES', () => a46SequenceAttachmentsShowTheFrameTheFileStates(verdicts, spineSequenceFacts(raw, data)));
|
|
3781
3258
|
}
|
|
3782
3259
|
|
|
3783
3260
|
// --- A06 / A17 / A19: the atlas against the PNGs on disk ------------------
|
|
@@ -3853,63 +3330,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3853
3330
|
//
|
|
3854
3331
|
// The axis bone itself must carry no keys at all: `invariants.axisBone` names
|
|
3855
3332
|
// a per-cut SETUP value, and animating it swings the whole formation.
|
|
3856
|
-
check('A24_AXIS_SPACE_STROKE', () =>
|
|
3857
|
-
const rig = input.rig;
|
|
3858
|
-
if (!rig) return skip('A24_AXIS_SPACE_STROKE', 'no rig info (validating a bare directory)');
|
|
3859
|
-
if (!rig.axisBone) {
|
|
3860
|
-
return skip(
|
|
3861
|
-
'A24_AXIS_SPACE_STROKE',
|
|
3862
|
-
`the rig "${rig.archetype}" declares no axis bone, so there is no axis space for a stroke to leave`,
|
|
3863
|
-
);
|
|
3864
|
-
}
|
|
3865
|
-
const subtree = new Set(rig.axisSubtree);
|
|
3866
|
-
const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
|
|
3867
|
-
// The stroke is the subject and its keys are where it lives (#580). A rig
|
|
3868
|
-
// that names an axis bone and then keys neither it nor anything under it has
|
|
3869
|
-
// no stroke for this rule to find out of axis space, and a loop over nothing
|
|
3870
|
-
// used to report that as held.
|
|
3871
|
-
let keyed = 0;
|
|
3872
|
-
for (const [animName, anim] of Object.entries(anims)) {
|
|
3873
|
-
if (!isObj(anim) || !isObj(anim.bones)) continue;
|
|
3874
|
-
for (const [boneName, timelines] of Object.entries(anim.bones as Json)) {
|
|
3875
|
-
if (boneName === rig.axisBone) {
|
|
3876
|
-
keyed++;
|
|
3877
|
-
fail(
|
|
3878
|
-
'A24_AXIS_SPACE_STROKE',
|
|
3879
|
-
`"${animName}" keys the axis bone "${boneName}"; the axis angle is a per-cut SETUP value, not animation`,
|
|
3880
|
-
);
|
|
3881
|
-
continue;
|
|
3882
|
-
}
|
|
3883
|
-
if (subtree.has(boneName)) keyed++;
|
|
3884
|
-
if (!subtree.has(boneName) || !isObj(timelines)) continue;
|
|
3885
|
-
if ('translatey' in timelines) {
|
|
3886
|
-
fail(
|
|
3887
|
-
'A24_AXIS_SPACE_STROKE',
|
|
3888
|
-
`"${animName}" gives "${boneName}" a translatey timeline; a bone under "${rig.axisBone}" moves along the axis only`,
|
|
3889
|
-
);
|
|
3890
|
-
}
|
|
3891
|
-
const keys = (timelines as Json).translate;
|
|
3892
|
-
if (!Array.isArray(keys)) continue;
|
|
3893
|
-
for (const key of keys) {
|
|
3894
|
-
if (!isObj(key)) continue;
|
|
3895
|
-
const y = Number(key.y ?? 0);
|
|
3896
|
-
if (Number.isFinite(y) && Math.abs(y) > 1e-6) {
|
|
3897
|
-
fail(
|
|
3898
|
-
'A24_AXIS_SPACE_STROKE',
|
|
3899
|
-
`"${animName}" keys "${boneName}" translate y=${y} at t=${String(key.time ?? 0)}; the axis bone carries the direction, so keys are translateX only`,
|
|
3900
|
-
);
|
|
3901
|
-
}
|
|
3902
|
-
}
|
|
3903
|
-
}
|
|
3904
|
-
}
|
|
3905
|
-
if (keyed === 0) {
|
|
3906
|
-
return skip(
|
|
3907
|
-
'A24_AXIS_SPACE_STROKE',
|
|
3908
|
-
`no animation keys the axis bone "${rig.axisBone}" or any of the ${rig.axisSubtree.length} bone(s) under ` +
|
|
3909
|
-
'it, so this rig has no stroke to hold in axis space',
|
|
3910
|
-
);
|
|
3911
|
-
}
|
|
3912
|
-
});
|
|
3333
|
+
check('A24_AXIS_SPACE_STROKE', () => a24AxisSpaceStroke(verdicts, rawBoneTimelines(raw), input));
|
|
3913
3334
|
|
|
3914
3335
|
// --- A25: parentage that must never happen -------------------------------
|
|
3915
3336
|
//
|
|
@@ -3920,36 +3341,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3920
3341
|
// the reason it is tempting, because the wrong parentage still loads and still
|
|
3921
3342
|
// animates — it just lies. That is exactly the class of invariant that belongs
|
|
3922
3343
|
// in a machine guard rather than in prose.
|
|
3923
|
-
check('A25_DETACHED_BONE_PARENTAGE', () =>
|
|
3924
|
-
const rig = input.rig;
|
|
3925
|
-
if (!rig) return skip('A25_DETACHED_BONE_PARENTAGE', 'no rig info (validating a bare directory)');
|
|
3926
|
-
if (!rig.detached.length) {
|
|
3927
|
-
return skip('A25_DETACHED_BONE_PARENTAGE', `the rig "${rig.archetype}" declares no forbidden parentage`);
|
|
3928
|
-
}
|
|
3929
|
-
const parentOf = new Map<string, string | null>();
|
|
3930
|
-
for (const bone of Array.isArray(raw?.bones) ? (raw.bones as unknown[]) : []) {
|
|
3931
|
-
if (isObj(bone) && typeof bone.name === 'string') {
|
|
3932
|
-
parentOf.set(bone.name, typeof bone.parent === 'string' ? bone.parent : null);
|
|
3933
|
-
}
|
|
3934
|
-
}
|
|
3935
|
-
for (const [child, forbidden] of rig.detached) {
|
|
3936
|
-
if (!parentOf.has(child)) {
|
|
3937
|
-
fail('A25_DETACHED_BONE_PARENTAGE', `the rig declares "${child}" detached from "${forbidden}" but has no such bone`);
|
|
3938
|
-
continue;
|
|
3939
|
-
}
|
|
3940
|
-
const seen = new Set<string>();
|
|
3941
|
-
for (let cursor = parentOf.get(child) ?? null; cursor; cursor = parentOf.get(cursor) ?? null) {
|
|
3942
|
-
if (seen.has(cursor)) break; // a cycle; the loader would have thrown first
|
|
3943
|
-
seen.add(cursor);
|
|
3944
|
-
if (cursor !== forbidden) continue;
|
|
3945
|
-
fail(
|
|
3946
|
-
'A25_DETACHED_BONE_PARENTAGE',
|
|
3947
|
-
`"${child}" is a descendant of "${forbidden}"; it must not be dragged by that bone's motion`,
|
|
3948
|
-
);
|
|
3949
|
-
break;
|
|
3950
|
-
}
|
|
3951
|
-
}
|
|
3952
|
-
});
|
|
3344
|
+
check('A25_DETACHED_BONE_PARENTAGE', () => a25DetachedBoneParentage(verdicts, rawSkeletonRoster(raw), input));
|
|
3953
3345
|
|
|
3954
3346
|
// --- A26: the slots array IS the rig's slot table ------------------------
|
|
3955
3347
|
//
|
|
@@ -3969,56 +3361,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3969
3361
|
// fills it, so a shorter array is a loss and is named as one here. A slot
|
|
3970
3362
|
// missing from the array moves every slot below it up one index, which is
|
|
3971
3363
|
// what a `drawOrder` key's offsets are counted against.
|
|
3972
|
-
check('A26_SLOT_DRAW_ORDER', () =>
|
|
3973
|
-
if (!input.rig) return skip('A26_SLOT_DRAW_ORDER', 'no rig info (validating a bare directory)');
|
|
3974
|
-
const order = input.rig.slotOrder;
|
|
3975
|
-
if (!order) {
|
|
3976
|
-
return skip(
|
|
3977
|
-
'A26_SLOT_DRAW_ORDER',
|
|
3978
|
-
`the rig "${input.rig.archetype}" declares no canonical slot order, so the emitted order has nothing to disagree with`,
|
|
3979
|
-
);
|
|
3980
|
-
}
|
|
3981
|
-
const names = (Array.isArray(raw?.slots) ? (raw.slots as unknown[]) : [])
|
|
3982
|
-
.filter(isObj)
|
|
3983
|
-
.map((s) => String(s.name));
|
|
3984
|
-
// ⛔ **No empty-subject SKIP here, and #575 is the whole reason** (#580). The
|
|
3985
|
-
// empty sequence is a subsequence of every table, so before #575 a skeleton
|
|
3986
|
-
// with no slot reported its draw order held and this rule wanted the same
|
|
3987
|
-
// guard its neighbours got. It no longer does: the completeness clause below
|
|
3988
|
-
// reads zero emitted slots against a declared table as EVERY slot lost,
|
|
3989
|
-
// which is the maximal case of exactly the defect #575 filed — so the honest
|
|
3990
|
-
// verdict is a FAIL naming them, and a SKIP here would suppress it. Measured:
|
|
3991
|
-
// with the guard in place a rig declaring one slot beside an artifact
|
|
3992
|
-
// carrying none reported SKIP; with it gone, `"block" is declared and not
|
|
3993
|
-
// emitted`. A26 therefore has no vacuous pass left to convert, the way
|
|
3994
|
-
// `A07_ATLAS_TEXT_SHAPE` has none.
|
|
3995
|
-
let at = 0;
|
|
3996
|
-
for (const name of names) {
|
|
3997
|
-
const found = order.indexOf(name, at);
|
|
3998
|
-
if (found < 0) {
|
|
3999
|
-
const known = order.indexOf(name);
|
|
4000
|
-
fail(
|
|
4001
|
-
'A26_SLOT_DRAW_ORDER',
|
|
4002
|
-
known < 0
|
|
4003
|
-
? `slot "${name}" is not in the archetype's slot table`
|
|
4004
|
-
: `slot "${name}" is drawn out of order (table position ${known}, after a slot at ${at})`,
|
|
4005
|
-
);
|
|
4006
|
-
return;
|
|
4007
|
-
}
|
|
4008
|
-
at = found + 1;
|
|
4009
|
-
}
|
|
4010
|
-
const emitted = new Set(names);
|
|
4011
|
-
const missing = order.filter((name) => !emitted.has(name));
|
|
4012
|
-
if (missing.length > 0) {
|
|
4013
|
-
fail(
|
|
4014
|
-
'A26_SLOT_DRAW_ORDER',
|
|
4015
|
-
`the rig "${input.rig.archetype}" declares ${order.length} slot(s) and the skeleton has ${names.length}: ` +
|
|
4016
|
-
`${missing.map((name) => `"${name}"`).join(', ')} ${missing.length === 1 ? 'is' : 'are'} declared and ` +
|
|
4017
|
-
'not emitted. A slot nothing fills is emitted with no setup attachment, not dropped — dropping one ' +
|
|
4018
|
-
'moves every slot below it up one index',
|
|
4019
|
-
);
|
|
4020
|
-
}
|
|
4021
|
-
});
|
|
3364
|
+
check('A26_SLOT_DRAW_ORDER', () => a26SlotDrawOrder(verdicts, rawSkeletonRoster(raw), input));
|
|
4022
3365
|
|
|
4023
3366
|
// --- A27: region name == the PNG's basename ------------------------------
|
|
4024
3367
|
//
|
|
@@ -4059,25 +3402,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4059
3402
|
// get projected onto the axis rather than read as axis coordinates.
|
|
4060
3403
|
// Ignoring them would let a rig pass while a recoil key closed the last few
|
|
4061
3404
|
// pixels of the gap.
|
|
4062
|
-
check('A29_STROKE_WITHIN_CONTACT_DEPTH', () =>
|
|
4063
|
-
const rig = input.rig;
|
|
4064
|
-
if (!rig) return skip('A29_STROKE_WITHIN_CONTACT_DEPTH', 'no rig info (validating a bare directory)');
|
|
4065
|
-
if (!rig.contactDepth) {
|
|
4066
|
-
return skip(
|
|
4067
|
-
'A29_STROKE_WITHIN_CONTACT_DEPTH',
|
|
4068
|
-
'the manifest declares no `stroke.contact_depth`, so this cut has no measured contact ceiling to hold the stroke to',
|
|
4069
|
-
);
|
|
4070
|
-
}
|
|
4071
|
-
const deep = deepestInwardAdvance(raw, rig);
|
|
4072
|
-
stats.contactDepth = rig.contactDepth;
|
|
4073
|
-
stats.deepestAdvance = Math.round(deep.total * 1000) / 1000;
|
|
4074
|
-
if (deep.total > rig.contactDepth + 1e-6) {
|
|
4075
|
-
fail(
|
|
4076
|
-
'A29_STROKE_WITHIN_CONTACT_DEPTH',
|
|
4077
|
-
`${deep.describe()} but the masses meet at ${rig.contactDepth}px — the two plates would interpenetrate`,
|
|
4078
|
-
);
|
|
4079
|
-
}
|
|
4080
|
-
});
|
|
3405
|
+
check('A29_STROKE_WITHIN_CONTACT_DEPTH', () => a29StrokeWithinContactDepth(verdicts, rawBoneTimelines(raw), input));
|
|
4081
3406
|
|
|
4082
3407
|
// --- A30: inward travel stops where the drawn cover runs out -------------
|
|
4083
3408
|
//
|
|
@@ -4100,42 +3425,7 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4100
3425
|
// validates. Rather than assert a number that no longer means anything, refuse
|
|
4101
3426
|
// the deformation. A cut that wants squash under a declared ceiling has to
|
|
4102
3427
|
// re-measure containment for the scaled contour and say so.
|
|
4103
|
-
check('A30_STROKE_WITHIN_CAP_CONTAINMENT', () =>
|
|
4104
|
-
const rig = input.rig;
|
|
4105
|
-
if (!rig) return skip('A30_STROKE_WITHIN_CAP_CONTAINMENT', 'no rig info (validating a bare directory)');
|
|
4106
|
-
if (!rig.capContainmentCeiling) {
|
|
4107
|
-
return skip(
|
|
4108
|
-
'A30_STROKE_WITHIN_CAP_CONTAINMENT',
|
|
4109
|
-
'the manifest declares no `stroke.cap_containment_ceiling`, so this cut has no measured containment ceiling',
|
|
4110
|
-
);
|
|
4111
|
-
}
|
|
4112
|
-
const deep = deepestInwardAdvance(raw, rig);
|
|
4113
|
-
stats.capCeiling = rig.capContainmentCeiling;
|
|
4114
|
-
stats.deepestAdvance = Math.round(deep.total * 1000) / 1000;
|
|
4115
|
-
if (deep.total > rig.capContainmentCeiling + 1e-6) {
|
|
4116
|
-
fail(
|
|
4117
|
-
'A30_STROKE_WITHIN_CAP_CONTAINMENT',
|
|
4118
|
-
`${deep.describe()} but the leading contour leaves the occluder's opaque footprint at ` +
|
|
4119
|
-
`${rig.capContainmentCeiling}px — the part would be drawn where it should be covered`,
|
|
4120
|
-
);
|
|
4121
|
-
}
|
|
4122
|
-
const subtree = new Set(rig.axisSubtree);
|
|
4123
|
-
const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
|
|
4124
|
-
for (const [animName, anim] of Object.entries(anims)) {
|
|
4125
|
-
if (!isObj(anim) || !isObj(anim.bones)) continue;
|
|
4126
|
-
for (const [boneName, timelines] of Object.entries(anim.bones as Json)) {
|
|
4127
|
-
if (!subtree.has(boneName) || !isObj(timelines)) continue;
|
|
4128
|
-
for (const name of Object.keys(timelines)) {
|
|
4129
|
-
if (name !== 'scale' && name !== 'scalex' && name !== 'scaley') continue;
|
|
4130
|
-
fail(
|
|
4131
|
-
'A30_STROKE_WITHIN_CAP_CONTAINMENT',
|
|
4132
|
-
`"${animName}" gives "${boneName}" a ${name} timeline while a cap-containment ceiling is declared; ` +
|
|
4133
|
-
'the ceiling was measured on the undeformed contour, so a scaled plate is outside its evidence',
|
|
4134
|
-
);
|
|
4135
|
-
}
|
|
4136
|
-
}
|
|
4137
|
-
}
|
|
4138
|
-
});
|
|
3428
|
+
check('A30_STROKE_WITHIN_CAP_CONTAINMENT', () => a30StrokeWithinCapContainment(verdicts, rawBoneTimelines(raw), input));
|
|
4139
3429
|
|
|
4140
3430
|
// --- A18: determinism ----------------------------------------------------
|
|
4141
3431
|
check('A18_DETERMINISTIC_EMIT', () => {
|
|
@@ -4211,63 +3501,6 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
4211
3501
|
return { failures, passed, skipped, profileSkipped, profile, stats };
|
|
4212
3502
|
}
|
|
4213
3503
|
|
|
4214
|
-
/**
|
|
4215
|
-
* Deepest inward advance the animation data asks for, in axis pixels.
|
|
4216
|
-
*
|
|
4217
|
-
* Shared by A29 and A30 because they bound the SAME quantity against two
|
|
4218
|
-
* different measured facts. Two things spend the same clearance and so are added:
|
|
4219
|
-
*
|
|
4220
|
-
* * the stroke — a translateX on a bone in the axis subtree. A24 guarantees
|
|
4221
|
-
* there is no hidden screen-space component to miss.
|
|
4222
|
-
* * the mass bone's own inward keys. It typically hangs outside the axis
|
|
4223
|
-
* subtree, so its keys are screen-space by design and get PROJECTED onto the
|
|
4224
|
-
* axis rather than read as axis coordinates. Ignoring them would let a rig
|
|
4225
|
-
* pass while a recoil key closed the last few pixels.
|
|
4226
|
-
*/
|
|
4227
|
-
function deepestInwardAdvance(
|
|
4228
|
-
raw: Json | null,
|
|
4229
|
-
rig: NonNullable<ValidateInput['rig']>,
|
|
4230
|
-
): { total: number; describe: () => string } {
|
|
4231
|
-
const anims = isObj(raw?.animations) ? (raw.animations as Json) : {};
|
|
4232
|
-
const subtree = new Set(rig.axisSubtree);
|
|
4233
|
-
let strokeMax = 0;
|
|
4234
|
-
let strokeWhere = '';
|
|
4235
|
-
let massMax = 0;
|
|
4236
|
-
let massWhere = '';
|
|
4237
|
-
for (const [animName, anim] of Object.entries(anims)) {
|
|
4238
|
-
if (!isObj(anim) || !isObj(anim.bones)) continue;
|
|
4239
|
-
for (const [boneName, timelines] of Object.entries(anim.bones as Json)) {
|
|
4240
|
-
if (!isObj(timelines)) continue;
|
|
4241
|
-
const keys = (timelines as Json).translate;
|
|
4242
|
-
if (!Array.isArray(keys)) continue;
|
|
4243
|
-
for (const key of keys) {
|
|
4244
|
-
if (!isObj(key)) continue;
|
|
4245
|
-
if (subtree.has(boneName)) {
|
|
4246
|
-
// +x is inward along the axis; a retracted key is negative and spends
|
|
4247
|
-
// no clearance, so only the inward extreme matters.
|
|
4248
|
-
const x = Number(key.x ?? 0);
|
|
4249
|
-
if (Number.isFinite(x) && x > strokeMax) {
|
|
4250
|
-
strokeMax = x;
|
|
4251
|
-
strokeWhere = `${animName}.${boneName} t=${String(key.time ?? 0)}`;
|
|
4252
|
-
}
|
|
4253
|
-
} else if (boneName === rig.massBone && rig.inwardUnit) {
|
|
4254
|
-
const inward = Number(key.x ?? 0) * rig.inwardUnit[0] + Number(key.y ?? 0) * rig.inwardUnit[1];
|
|
4255
|
-
if (Number.isFinite(inward) && inward > massMax) {
|
|
4256
|
-
massMax = inward;
|
|
4257
|
-
massWhere = `${animName}.${boneName} t=${String(key.time ?? 0)}`;
|
|
4258
|
-
}
|
|
4259
|
-
}
|
|
4260
|
-
}
|
|
4261
|
-
}
|
|
4262
|
-
}
|
|
4263
|
-
return {
|
|
4264
|
-
total: strokeMax + massMax,
|
|
4265
|
-
describe: () =>
|
|
4266
|
-
`deepest inward advance is ${(strokeMax + massMax).toFixed(3)}px (stroke ${strokeMax.toFixed(3)} at ${strokeWhere}` +
|
|
4267
|
-
`${massMax > 0 ? ` + mass ${massMax.toFixed(3)} at ${massWhere}` : ''})`,
|
|
4268
|
-
};
|
|
4269
|
-
}
|
|
4270
|
-
|
|
4271
3504
|
/**
|
|
4272
3505
|
* Decode a weighted mesh's `vertices` run into per-vertex (boneIndex, weight).
|
|
4273
3506
|
*
|