spine-rigc 1.9.1 → 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/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,
@@ -65,7 +67,7 @@ import {
65
67
  TOPLEVEL_CONSTRAINT_ARRAYS,
66
68
  type SpineGeneration,
67
69
  } from './generation.ts';
68
- import { nonFiniteOfPosed } from './render.ts';
70
+ import { posedNumbersOf } from './render.ts';
69
71
  import { BONE_INHERIT_KNOWN } from './rig.ts';
70
72
  import {
71
73
  CHANNELS_BY_KIND,
@@ -145,6 +147,12 @@ import { a39DeformKeepsTriangleWinding } from './assertions/bodies/a39.ts';
145
147
  import { a09AnimationDurationMatchesSpec } from './assertions/bodies/a09.ts';
146
148
  import { a43TwoColorTintLoadsAndPosesAsWritten } from './assertions/bodies/a43.ts';
147
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';
148
156
 
149
157
  export type { Failure } from './assertions/harness.ts';
150
158
 
@@ -292,8 +300,6 @@ export interface ValidateReport {
292
300
  // with, and A09's one-frame slack, are `./assertions/bodies/a39.ts`'s and
293
301
  // `./assertions/bodies/a09.ts`'s since issue #1025 (cut 4c-3).
294
302
 
295
- const STEP_FRAMES = 120;
296
-
297
303
  /**
298
304
  * The constraint groups that spell `group.<constraint>.<timeline>` — a
299
305
  * constraint name, then named timelines under it (A34's second shape).
@@ -649,18 +655,18 @@ export type TimelineAddBehaviour = 'accumulates' | 'overwrites' | 'inert';
649
655
  */
650
656
  const PROBE_DISPLACEMENT = 0.375;
651
657
 
652
- /** Every own value of a pose object a timeline could have written, as text. */
653
- function poseReading(pose: object): string {
654
- 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]> = [];
655
661
  for (const key of Object.keys(pose).sort()) {
656
662
  const value = (pose as Record<string, unknown>)[key];
657
663
  if (value === null || value === undefined || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
658
- parts.push(`${key}=${String(value)}`);
664
+ parts.push([key, String(value)]);
659
665
  continue;
660
666
  }
661
667
  if (Array.isArray(value)) {
662
668
  const entries: unknown[] = value;
663
- if (entries.every((one) => typeof one === 'number')) parts.push(`${key}=[${entries.join(',')}]`);
669
+ if (entries.every((one) => typeof one === 'number')) parts.push([key, `[${entries.join(',')}]`]);
664
670
  continue;
665
671
  }
666
672
  if (typeof value !== 'object') continue;
@@ -670,12 +676,17 @@ function poseReading(pose: object): string {
670
676
  // structure — a bone back-reference, a slot's data — is either an identity
671
677
  // (read by name below) or something no timeline writes.
672
678
  if (keys.length > 0 && keys.every((one) => typeof record[one] === 'number')) {
673
- parts.push(`${key}={${keys.map((one) => `${one}:${String(record[one])}`).join(',')}}`);
679
+ parts.push([key, `{${keys.map((one) => `${one}:${String(record[one])}`).join(',')}}`]);
674
680
  } else if (typeof record.name === 'string') {
675
- parts.push(`${key}=@${record.name}`);
681
+ parts.push([key, `@${record.name}`]);
676
682
  }
677
683
  }
678
- return parts.join(',');
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(',');
679
690
  }
680
691
 
681
692
  /** The whole of what a timeline could have changed, in one comparable string. */
@@ -801,6 +812,73 @@ export function timelineAddBehaviour(data: ReturnType<SkeletonJson['readSkeleton
801
812
  return wrote ? 'overwrites' : 'inert';
802
813
  }
803
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
+
804
882
  /**
805
883
  * Where two texts first differ, as A18 names it: the 1-based line and each
806
884
  * side's line, trimmed and cut to 120 characters.
@@ -1635,6 +1713,129 @@ export function spineConstraintFacts(data: ReturnType<SkeletonJson['readSkeleton
1635
1713
  return { animations: data.animations.length, constraints, timelines, pathSlots };
1636
1714
  }
1637
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
+
1638
1839
  /**
1639
1840
  * The facts cut 4c-2's bodies read, as `validate()` supplies them from a pair
1640
1841
  * spine-core loads — or `null` when the load throws (A00's failure). For the
@@ -1660,7 +1861,98 @@ export function runtimeRigFacts(skeletonText: string, atlasText: string): { mesh
1660
1861
  * `tools/verdict_gate.ts`, which hand them to the bodies and ask the model
1661
1862
  * side's suppliers the same questions, value by value.
1662
1863
  */
1663
- export function runtimePosedFacts(skeletonText: string, atlasText: string): { deformSurvey: DeformSurveyFacts; animationDurations: AnimationDurationFacts; twoColour: TwoColourFacts; sequences: SequenceFacts } | null {
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 {
1664
1956
  let data: ReturnType<SkeletonJson['readSkeletonData']>;
1665
1957
  let raw: Json;
1666
1958
  try {
@@ -1669,7 +1961,26 @@ export function runtimePosedFacts(skeletonText: string, atlasText: string): { de
1669
1961
  } catch {
1670
1962
  return null;
1671
1963
  }
1672
- return { deformSurvey: spineDeformSurvey(data), animationDurations: spineAnimationDurations(data), twoColour: spineTwoColourFacts(raw, data), sequences: spineSequenceFacts(raw, data) };
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) };
1673
1984
  }
1674
1985
 
1675
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. */
@@ -1982,129 +2293,15 @@ export function validate(input: ValidateInput): ValidateReport {
1982
2293
  // constant now, and the message that names the timelines a group takes reads
1983
2294
  // them off `CHANNELS_BY_KIND` — it used to be a ternary over two groups,
1984
2295
  // which is a sentence that cannot be extended without being rewritten.
1985
- check('A34_CONSTRAINT_TIMELINE_TARGETS', () => {
1986
- if (!raw) return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'the skeleton JSON did not parse (A00 owns that failure)');
1987
- if (!isObj(raw.animations)) return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'the skeleton declares no animations');
1988
- // name -> the KINDS declared under it, because that is the namespace the
1989
- // lookup this assertion is about resolves in: `findConstraint(name, type)`
1990
- // tests the type first, so a skeleton may carry `leg` as an ik constraint
1991
- // AND as a transform one and each group finds its own (issue #692). A map
1992
- // keyed by the name alone let the second of a pair overwrite the first, and
1993
- // this assertion then reported a correct file as a type mismatch.
1994
- const kindsOf = new Map<string, string[]>();
1995
- for (const entry of Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []) {
1996
- if (isObj(entry) && typeof entry.name === 'string') {
1997
- kindsOf.set(entry.name, [...(kindsOf.get(entry.name) ?? []), String(entry.type)]);
1998
- }
1999
- }
2000
- /** The file's physics constraints, which the unnamed physics group is read against. */
2001
- const rawPhysics = (Array.isArray(raw.constraints) ? (raw.constraints as unknown[]) : []).filter(
2002
- (entry): entry is Json => isObj(entry) && entry.type === 'physics',
2003
- );
2004
- let sawATimeline = false;
2005
- /** One target of one group: the name resolves, the type matches, keys exist. */
2006
- const checkTarget = (at: string, group: string, name: string, keyArrays: Array<[string, unknown]>): void => {
2007
- sawATimeline = true;
2008
- const declared = kindsOf.get(name) ?? [];
2009
- if (declared.length === 0) {
2010
- const known = [...kindsOf.entries()].filter(([, kinds]) => kinds.includes(group)).map(([n]) => n);
2011
- fail(
2012
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
2013
- `${at}: the skeleton's constraints array has no "${name}"` +
2014
- (known.length ? ` (${group} constraints: ${known.join(', ')})` : `, and no ${group} constraint at all`),
2015
- );
2016
- return;
2017
- }
2018
- if (!declared.includes(group)) {
2019
- fail(
2020
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
2021
- `${at}: "${name}" is declared as a "${declared.join('"/"')}" constraint, so the ${group} lookup misses it and the loader throws`,
2022
- );
2023
- return;
2024
- }
2025
- checkKeys(at, keyArrays);
2026
- };
2027
- /** The key arrays of one group entry that resolved: each one is walked, so each has to hold a key. */
2028
- const checkKeys = (at: string, keyArrays: Array<[string, unknown]>): void => {
2029
- if (keyArrays.length === 0) {
2030
- fail(
2031
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
2032
- `${at}: the constraint is named and carries no timeline at all; the group is walked and nothing happens`,
2033
- );
2034
- return;
2035
- }
2036
- for (const [timeline, keys] of keyArrays) {
2037
- if (Array.isArray(keys) && keys.length > 0) continue;
2038
- fail(
2039
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
2040
- `${at}${timeline ? ` timeline "${timeline}"` : ''}: the key array is ` +
2041
- `${Array.isArray(keys) ? 'empty' : JSON.stringify(keys)}; the parser reads key 0, finds nothing and ` +
2042
- 'skips the whole timeline without a word',
2043
- );
2044
- }
2045
- };
2046
- for (const [animName, anim] of Object.entries(raw.animations as Json)) {
2047
- if (!isObj(anim)) continue;
2048
- // group.<constraint> = keys[]
2049
- for (const group of ['ik', 'transform'] as const) {
2050
- if (!isObj(anim[group])) continue;
2051
- for (const [name, keys] of Object.entries(anim[group] as Json)) {
2052
- checkTarget(`animation "${animName}" ${group} timeline "${name}"`, group, name, [['', keys]]);
2053
- }
2054
- }
2055
- // group.<constraint>.<timeline> = keys[]
2056
- for (const group of NAMED_TIMELINE_GROUPS) {
2057
- if (!isObj(anim[group])) continue;
2058
- for (const [name, timelines] of Object.entries(anim[group] as Json)) {
2059
- const at = `animation "${animName}" ${group} constraint "${name}"`;
2060
- if (!isObj(timelines)) {
2061
- sawATimeline = true;
2062
- fail(
2063
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
2064
- `${at}: this group maps a constraint to NAMED timelines ` +
2065
- `(${Object.keys(CHANNELS_BY_KIND[group]).join('/')}), and this one holds ${JSON.stringify(timelines)} — ` +
2066
- 'a bare key array here is the ik/transform shape and is walked as an object',
2067
- );
2068
- continue;
2069
- }
2070
- // The empty name is not a miss: it is the physics group's global form,
2071
- // which `SkeletonJson` loads as `constraintIndex -1` rather than looking
2072
- // anything up (`:1048-1054`), and which writes every physics constraint
2073
- // that declares the timeline's property global (issue #726). Refusing
2074
- // it as "no constraint called ''" was a sentence that is false about
2075
- // the file. What CAN be wrong with it is that it reaches nobody: the
2076
- // parser accepts it, the runtime walks every constraint, and none of
2077
- // them takes the key.
2078
- if (group === 'physics' && name === '') {
2079
- sawATimeline = true;
2080
- for (const [timelineName] of Object.entries(timelines)) {
2081
- const timeline = unnamedPhysicsTimeline(timelineName);
2082
- // A name the parser skips is skipped here too: no timeline exists
2083
- // to reach anybody, and whether the NAME is right is not a target
2084
- // question.
2085
- if (timeline === null || unnamedPhysicsReach(timeline, rawPhysics).length > 0) continue;
2086
- const resets = timeline instanceof PhysicsConstraintResetTimeline;
2087
- fail(
2088
- 'A34_CONSTRAINT_TIMELINE_TARGETS',
2089
- `${at} timeline "${timelineName}": a physics group that names no constraint writes every physics ` +
2090
- `constraint ${resets ? 'the skeleton has' : `declaring "${timelineName}Global": true`}, and ` +
2091
- (rawPhysics.length === 0
2092
- ? 'the skeleton has no physics constraint'
2093
- : `none of ${rawPhysics.map((one) => `"${String(one.name)}"`).join(', ')} does`) +
2094
- ' — the parser loads it, the runtime walks every constraint, and no constraint takes the key',
2095
- );
2096
- }
2097
- checkKeys(at, Object.entries(timelines));
2098
- continue;
2099
- }
2100
- checkTarget(at, group, name, Object.entries(timelines));
2101
- }
2102
- }
2103
- }
2104
- if (!sawATimeline) {
2105
- return skip('A34_CONSTRAINT_TIMELINE_TARGETS', 'no animation carries a constraint timeline');
2106
- }
2107
- });
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
+ );
2108
2305
 
2109
2306
  // --- A35: a deform key's run lands inside the attachment it edits ---------
2110
2307
  //
@@ -2757,127 +2954,13 @@ export function validate(input: ValidateInput): ValidateReport {
2757
2954
  // measurement instead of two exceptions. A list of unobservable spellings
2758
2955
  // written into this file would have been the hand-kept table that produced
2759
2956
  // the defect above.
2760
- check('A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET', () => {
2761
- const sliders = data.constraints.filter((c) => c instanceof SliderData);
2762
- if (sliders.length < 2) {
2763
- return skip(
2764
- 'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
2765
- `the skeleton declares ${sliders.length} slider constraint${sliders.length === 1 ? '' : 's'}, and one slider has nothing to compose with`,
2766
- );
2767
- }
2768
- // Clause 1 of the "could this be correct?" list above.
2769
- // "Keyed at all" rather than "keyed live", and on purpose: this clause asks
2770
- // whether the mix can MOVE from its setup value, so any mix timeline
2771
- // disqualifies it — the question `keyedBy` answered here, through the one
2772
- // reading `switchedOn` gives (`./assertions/constraint_words.ts`, over
2773
- // the same facts the moved constraint bodies read — issue #1025), and
2774
- // unchanged by issue #752.
2775
- const mixKeyed = switchedOn(constraintFacts(), (timeline) => timeline.kind === 'slider' && timeline.word === 'mix', 1, () => true);
2776
- const authoritative = sliders.filter((s) => s.setupPose.mix >= 1 && !mixKeyed.has(data.constraints.indexOf(s)));
2777
- if (authoritative.length < 2) {
2778
- return skip(
2779
- 'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
2780
- `${authoritative.length} of the ${sliders.length} slider constraints apply at full authority; below mix 1 an ` +
2781
- 'apply is a lerp from the current pose rather than an overwrite, so what the others do to a shared property is a weighting',
2782
- );
2783
- }
2784
- /** Which skins switch a slider on, or null when it is active under every skin. */
2785
- const skinsOf = new Map<SliderData, Set<string> | null>();
2786
- for (const slider of authoritative) {
2787
- if (!slider.skinRequired) {
2788
- skinsOf.set(slider, null);
2789
- continue;
2790
- }
2791
- const names = new Set<string>();
2792
- for (const skin of data.skins) {
2793
- if (skin.constraints.includes(slider)) names.add(skin.name);
2794
- }
2795
- skinsOf.set(slider, names);
2796
- }
2797
- /** Clause 2: can these two ever run in the same frame? */
2798
- const canOverlap = (a: SliderData, b: SliderData): boolean => {
2799
- const skinsA = skinsOf.get(a) ?? null;
2800
- const skinsB = skinsOf.get(b) ?? null;
2801
- if (!skinsA || !skinsB) return true;
2802
- for (const name of skinsA) {
2803
- if (skinsB.has(name)) return true;
2804
- }
2805
- return false;
2806
- };
2807
- /** What a property id points at, in the words the rig spec uses. */
2808
- const describe = (timeline: Timeline, id: string): string => {
2809
- const property = Property[Number(id.split('|')[0])] ?? id;
2810
- if (isBoneTimeline(timeline)) return `bone "${data.bones[timeline.boneIndex]?.name ?? timeline.boneIndex}" ${property}`;
2811
- if (timeline instanceof DeformTimeline) {
2812
- return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" deform of "${timeline.attachment.name}"`;
2813
- }
2814
- if (isSlotTimeline(timeline)) return `slot "${data.slots[timeline.slotIndex]?.name ?? timeline.slotIndex}" ${property}`;
2815
- if (isConstraintTimeline(timeline) && timeline.constraintIndex >= 0) {
2816
- return `constraint "${data.constraints[timeline.constraintIndex]?.name ?? timeline.constraintIndex}" ${property}`;
2817
- }
2818
- return `the skeleton's ${property}`;
2819
- };
2820
- /** property id -> the sliders whose animation keys it, in constraints-array order. */
2821
- const byProperty = new Map<string, Array<{ slider: SliderData; timeline: Timeline }>>();
2822
- for (const slider of authoritative) {
2823
- const seen = new Set<string>();
2824
- for (const timeline of slider.animation?.timelines ?? []) {
2825
- for (const id of timeline.propertyIds) {
2826
- if (seen.has(id)) continue;
2827
- seen.add(id);
2828
- byProperty.set(id, [...(byProperty.get(id) ?? []), { slider, timeline }]);
2829
- }
2830
- }
2831
- }
2832
- /** Posed once per timeline, because the answer is the class's and the rigs that reach here share timelines. */
2833
- const behaviour = new Map<Timeline, TimelineAddBehaviour>();
2834
- const addBehaviourOf = (timeline: Timeline): TimelineAddBehaviour => {
2835
- const known = behaviour.get(timeline);
2836
- if (known !== undefined) return known;
2837
- const measured = timelineAddBehaviour(data, timeline);
2838
- behaviour.set(timeline, measured);
2839
- return measured;
2840
- };
2841
- let shared = 0;
2842
- for (const [id, users] of byProperty) {
2843
- if (users.length < 2) continue;
2844
- shared++;
2845
- const at = (slider: SliderData): string =>
2846
- `"${slider.name}" (constraints[${data.constraints.indexOf(slider)}], additive: ${String(slider.additive)})`;
2847
- const chain = users.map((u) => at(u.slider)).join(', ');
2848
- for (let j = 1; j < users.length; j++) {
2849
- const later = users[j];
2850
- const composes = addBehaviourOf(later.timeline);
2851
- if (composes === 'accumulates' && later.slider.additive) continue;
2852
- // Nothing a second slider could take away: applied with the arguments
2853
- // a slider passes — `firedEvents` null among them — this timeline
2854
- // moves no pose at all.
2855
- if (composes === 'inert') continue;
2856
- const erased = users.slice(0, j).filter((e) => canOverlap(e.slider, later.slider));
2857
- if (!erased.length) continue;
2858
- const erasedNames = `${erased.map((e) => `"${e.slider.name}"`).join(', ')} contribute${erased.length === 1 ? 's' : ''}`;
2859
- const why =
2860
- composes === 'accumulates'
2861
- ? `slider "${later.slider.name}" applies animation "${later.slider.animation?.name}" with additive false, and at ` +
2862
- 'mix 1 a non-additive apply writes the value outright (`getRelativeValue` returns `setup + value`, ' +
2863
- '`getAbsoluteValue` returns `value`) rather than adding to the pose it found. Set `"additive": true` on ' +
2864
- `slider "${later.slider.name}" in the rig spec — rigc will not choose that flag for you — or key this ` +
2865
- `property from one slider only. [measured] \`${later.timeline.constructor.name}.apply\` posed twice with ` +
2866
- '`add` set accumulates, so that flag is the repair here'
2867
- : `the ${Property[Number(id.split('|')[0])] ?? id} timeline they share writes its value outright whatever the ` +
2868
- `flags say — [measured] \`${later.timeline.constructor.name}.apply\` posed twice with \`add\` set left the ` +
2869
- 'same value there rather than adding to it — so `"additive": true` would NOT compose these. Key this ' +
2870
- 'property from one slider only, or move both edits into the one animation a single slider applies';
2871
- fail(
2872
- 'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
2873
- `${describe(later.timeline, id)} is keyed by the animations of ${users.length} sliders — ${chain} — and every ` +
2874
- `one of them applies at mix 1. Today ${at(later.slider)} wins that property and ${erasedNames} ` +
2875
- `nothing to it: ${why}.`,
2876
- );
2877
- }
2878
- }
2879
- stats.sliderSharedTargets = shared;
2880
- });
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()));
2881
2964
 
2882
2965
  // --- A42: a dial that drives a constraint the array already ran ---------
2883
2966
  //
@@ -2967,206 +3050,98 @@ export function validate(input: ValidateInput): ValidateReport {
2967
3050
  check('A09_ANIMATION_DURATION_MATCHES_SPEC', () => a09AnimationDurationMatchesSpec(verdicts, spineAnimationDurations(data), input.declaredDurations));
2968
3051
 
2969
3052
  // --- A10: step every animation and look for NaN ------------------------
2970
- check('A10_NO_NAN_AFTER_STEPPING', () => {
2971
- // Two clauses, and since issue #902 each is read on its own subject. The
2972
- // SETUP POSE is posed from the bones, so it has something to read on any
2973
- // skeleton that carries one; the STEPPED FRAMES are posed once per
2974
- // animation, so a skeleton with none has nothing to step. This used to
2975
- // skip the whole rule on the second fact alone, which was A15's pattern
2976
- // (#580) for a rule whose only clause was the stepping — and #882 gave it
2977
- // the setup clause without moving the skip. Measured on the static probe
2978
- // with a leaf bone at `rotation: 1e309` in its emitted file: `validate`
2979
- // printed this rule as SKIP and the run green under both profiles, and
2980
- // `render` refused the same file by the bone. A static rig's setup pose
2981
- // is the whole of what it shows, so that was the one pose nothing read.
2982
- //
2983
- // ⇒ The multi-clause rule of #580 now governs, the shape A09, A13, A33
2984
- // and A38 carry: the rule SKIPs only when neither clause has anything to
2985
- // measure, the clause that ran decides PASS or FAIL, and the clause that
2986
- // had nothing is named on the stats line (`nanStepping=skipped`, beside
2987
- // `animations=0`) the way A47 names a constraint it did not measure. A
2988
- // PASS row carries no detail, which is why the stats line is where.
2989
- //
2990
- // ⚠️ A09 does not follow, and that is its own name read at its word: a
2991
- // declared duration is a fact about an animation and nothing else, so a
2992
- // static rig still gives it nothing at all.
2993
- if (data.animations.length === 0 && data.bones.length === 0) {
2994
- return skip('A10_NO_NAN_AFTER_STEPPING', SKIP_NO_POSE);
2995
- }
2996
- /** Is this a mode `updateWorldTransform`'s switch has a case for? Read off the runtime's own enum. */
2997
- const isMode = (inherit: unknown): boolean => typeof inherit === 'number' && Inherit[inherit] !== undefined;
2998
-
2999
- // -- the bone's inheritance mode, posed at every `inherit` key ---------
3000
- //
3001
- // 🚨 The one bone timeline whose value is a NAME, and the only one that
3002
- // can pose a NaN with a finite world: `SkeletonJson` resolves a key's mode
3003
- // through `Utils.enumValue`, which folds the first letter's case and
3004
- // nothing else, so `"NOSCALE"` resolves to `undefined` and the timeline's
3005
- // `Float32Array` frame stores NaN. `InheritTimeline.apply` then sets
3006
- // `pose.inherit = NaN`, `updateWorldTransform`'s switch matches no case,
3007
- // and the bone keeps whatever world matrix it had — measured on a forged
3008
- // two-bone chain: at the key the child's `a,b,c,d` equal the setup
3009
- // Normal-mode pose to the last digit while the file says `noScale`. The
3010
- // world position stays finite, so the loop below never saw it (#733).
3011
- //
3012
- // ⚠️ Posed AT each key rather than read off the stepping loop, because a
3013
- // stepped value lives from its key to the next one and a sampling grid
3014
- // can step over a short span entirely. What is judged is whether the
3015
- // posed value IS a mode — which is this assertion's name exactly — and
3016
- // not which mode: for any spelling the lookup resolves, the mode posed
3017
- // is the mode written by construction of the same lookup, so an equality
3018
- // here would be the parser agreeing with itself.
3019
- const rawAnimations = isObj(raw) && isObj(raw.animations) ? raw.animations : {};
3020
- let unresolved = 0;
3021
- for (const [animName, rawAnim] of Object.entries(rawAnimations)) {
3022
- if (!isObj(rawAnim) || !isObj(rawAnim.bones)) continue;
3023
- for (const [boneName, timelines] of Object.entries(rawAnim.bones)) {
3024
- if (!isObj(timelines) || !Array.isArray(timelines.inherit)) continue;
3025
- if (!data.findAnimation(animName) || !data.findBone(boneName)) continue;
3026
- for (const rawKey of timelines.inherit as unknown[]) {
3027
- if (!isObj(rawKey)) continue;
3028
- const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
3029
- const skeleton = new Skeleton(data);
3030
- const state = new AnimationState(new AnimationStateData(data));
3031
- state.setAnimation(0, animName, false);
3032
- skeleton.setupPose();
3033
- skeleton.update(0);
3034
- skeleton.updateWorldTransform(Physics.reset);
3035
- state.update(time);
3036
- state.apply(skeleton);
3037
- skeleton.update(time);
3038
- skeleton.updateWorldTransform(Physics.update);
3039
- const posed = skeleton.findBone(boneName)?.appliedPose.inherit;
3040
- if (isMode(posed)) continue;
3041
- unresolved++;
3042
- fail(
3043
- 'A10_NO_NAN_AFTER_STEPPING',
3044
- `animation "${animName}" bone "${boneName}" inherit (t=${time}): the bone poses inheritance mode ` +
3045
- `${String(posed)} — the key spells ${JSON.stringify(rawKey.inherit)}, which the runtime's mode lookup ` +
3046
- `does not resolve (${BONE_INHERIT_KNOWN}), so no mode applies until the next key and the bone keeps ` +
3047
- 'the world rotation, scale and shear it had',
3048
- );
3049
- }
3050
- }
3051
- }
3052
- if (unresolved > 0) return;
3053
-
3054
- // -- the whole world transform, at the setup pose and every stepped frame
3055
- //
3056
- // 🚨 Read through `nonFiniteOfPosed`, the scan `render` refuses on, and
3057
- // not a second opinion of it (issue #882). This loop read `worldX` and
3058
- // `worldY` alone until then, and a bone at `rotation: 1e309` — in its setup
3059
- // pose or as a `rotate` key — poses a finite position over a NaN `a`, `b`,
3060
- // `c` and `d` whenever it has no child offset from it: measured on every
3061
- // leaf bone of the three generated probes, 20 of 20 green through the whole
3062
- // gate, and `render` then refused the same file by the bone. So the gate
3063
- // said the skeleton was fine and the renderer said it was not, which is two
3064
- // definitions of one word. The scan reads the six terms of every bone, then
3065
- // the vertices of every region and mesh shown — because a bone can be
3066
- // finite over a vertex that is not (a `scaleX` chain whose product stays
3067
- // under the largest double while every corner of the child's region
3068
- // passes it) — and names the bone or the vertex, the term and the frame.
3069
- //
3070
- // ⚠️ The setup pose is its own surface and is read as such: every frame
3071
- // below is posed AFTER `state.apply`, so a bone the animation keys from
3072
- // t=0 never shows its setup value to the loop, and a runtime that shows
3073
- // the rig at rest does show it.
3074
- //
3075
- // 🔸 Posed once, before any animation is set, rather than once per
3076
- // animation as it was until #902: `setAnimation` does not touch the
3077
- // skeleton, so every animation's copy of this pose was the same pose, and
3078
- // the first of them was the only one ever read.
3079
- if (data.animations.length === 0) stats.nanStepping = 'skipped';
3080
- const atRest = new Skeleton(data);
3081
- atRest.setupPose();
3082
- atRest.update(0);
3083
- atRest.updateWorldTransform(Physics.reset);
3084
- const atSetup = nonFiniteOfPosed('the setup pose', atRest);
3085
- if (atSetup !== null) {
3086
- fail('A10_NO_NAN_AFTER_STEPPING', atSetup);
3087
- return;
3088
- }
3089
-
3090
- /**
3091
- * What a posed frame shows that the world-transform scan does not read: a
3092
- * bone posing no inheritance mode, and a slot colour that is not finite.
3093
- * `where` leads the sentence — the animation's name at a stepped frame,
3094
- * as it always has, or `the setup pose` on a skeleton with no animation.
3095
- */
3096
- const poseDefect = (where: string, skeleton: Skeleton): string | null => {
3097
- for (const bone of skeleton.bones) {
3098
- const pose = bone.appliedPose;
3099
- // The setup half of the inherit-key clause above: a bone's own
3100
- // `inherit` goes through the same lookup, and a miss there loads
3101
- // `undefined` into the setup pose, which every frame copies. Only
3102
- // reachable on a file rigc did not write — `parseRigSpec` refuses the
3103
- // spelling.
3104
- if (!isMode(pose.inherit)) {
3105
- const rawBone = Array.isArray(raw?.bones)
3106
- ? (raw.bones as unknown[]).find((b) => isObj(b) && b.name === bone.data.name)
3107
- : undefined;
3108
- return (
3109
- `${where}: bone "${bone.data.name}" poses inheritance mode ${String(pose.inherit)} — its setup ` +
3110
- `spells inherit ${JSON.stringify(isObj(rawBone) ? rawBone.inherit : undefined)}, which the ` +
3111
- `runtime's mode lookup does not resolve (${BONE_INHERIT_KNOWN}), so its world rotation, scale and shear ` +
3112
- 'are never computed — they stay 0 and everything the bone carries collapses to a point'
3113
- );
3114
- }
3115
- }
3116
- for (const slot of skeleton.slots) {
3117
- const c = slot.appliedPose.color;
3118
- if (![c.r, c.g, c.b, c.a].every(Number.isFinite)) return `${where}: slot "${slot.data.name}" colour is non-finite`;
3119
- // The other colour a slot poses, and it was outside this loop until
3120
- // issue #690 for the reason every gap here has: nothing emitted one.
3121
- // `null` is the ordinary case — a slot with no `dark` allocates no
3122
- // dark colour at all — and is not a reading to make, so it is
3123
- // skipped rather than treated as zero.
3124
- const d = slot.appliedPose.darkColor;
3125
- if (d !== null && ![d.r, d.g, d.b].every(Number.isFinite)) {
3126
- return `${where}: slot "${slot.data.name}" dark colour is non-finite`;
3127
- }
3128
- }
3129
- return null;
3130
- };
3131
-
3132
- // The stepping half, and on a static rig there is nothing to step: the
3133
- // setup pose is then the only frame the skeleton has, and the two readings
3134
- // a stepped frame gets are made on it instead, so a colour or an
3135
- // inheritance mode the rig shows at rest is not left to `render`.
3136
- if (data.animations.length === 0) {
3137
- const atRestDefect = poseDefect('the setup pose', atRest);
3138
- if (atRestDefect !== null) fail('A10_NO_NAN_AFTER_STEPPING', atRestDefect);
3139
- return;
3140
- }
3141
- for (const anim of data.animations) {
3142
- const skeleton = new Skeleton(data);
3143
- const state = new AnimationState(new AnimationStateData(data));
3144
- state.setAnimation(0, anim.name, true);
3145
- skeleton.setupPose();
3146
- skeleton.update(0);
3147
- skeleton.updateWorldTransform(Physics.reset);
3148
- const step = Math.max(anim.duration, 1) / STEP_FRAMES;
3149
- for (let i = 0; i < STEP_FRAMES; i++) {
3150
- state.update(step);
3151
- state.apply(skeleton);
3152
- skeleton.update(step);
3153
- skeleton.updateWorldTransform(Physics.update);
3154
- const found = nonFiniteOfPosed(
3155
- `animation ${JSON.stringify(anim.name)} frame ${i + 1} of ${STEP_FRAMES} (t=${((i + 1) * step).toFixed(4)}s)`,
3156
- skeleton,
3157
- );
3158
- if (found !== null) {
3159
- fail('A10_NO_NAN_AFTER_STEPPING', found);
3160
- return;
3161
- }
3162
- const defect = poseDefect(anim.name, skeleton);
3163
- if (defect !== null) {
3164
- fail('A10_NO_NAN_AFTER_STEPPING', defect);
3165
- return;
3166
- }
3167
- }
3168
- }
3169
- });
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)));
3170
3145
 
3171
3146
  // --- A43: the two-colour tint, read back off the runtime ----------------
3172
3147
  //