spine-rigc 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/cli.ts +2 -2
  2. package/docs/AUTHORING.md +3 -3
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a04.ts +40 -0
  5. package/src/assertions/bodies/a06.ts +195 -0
  6. package/src/assertions/bodies/a08.ts +76 -0
  7. package/src/assertions/bodies/a13.ts +51 -0
  8. package/src/assertions/bodies/a14.ts +35 -0
  9. package/src/assertions/bodies/a15.ts +97 -0
  10. package/src/assertions/bodies/a19.ts +295 -0
  11. package/src/assertions/bodies/a20.ts +122 -0
  12. package/src/assertions/bodies/a21.ts +190 -0
  13. package/src/assertions/bodies/a22.ts +39 -0
  14. package/src/assertions/bodies/a23.ts +255 -0
  15. package/src/assertions/bodies/a27.ts +33 -0
  16. package/src/assertions/bodies/a28.ts +70 -0
  17. package/src/assertions/bodies/a33.ts +110 -0
  18. package/src/assertions/bodies/a36.ts +81 -0
  19. package/src/assertions/bodies/a37.ts +77 -0
  20. package/src/assertions/bodies/a38.ts +73 -0
  21. package/src/assertions/bodies/a41.ts +83 -0
  22. package/src/assertions/bodies/a42.ts +97 -0
  23. package/src/assertions/bodies/a44.ts +23 -0
  24. package/src/assertions/bodies/a47.ts +59 -0
  25. package/src/assertions/bodies/a48.ts +83 -0
  26. package/src/assertions/constraint_words.ts +177 -0
  27. package/src/assertions/facts/animated_bones.ts +28 -0
  28. package/src/assertions/facts/atlas_regions.ts +52 -0
  29. package/src/assertions/facts/constraints.ts +131 -0
  30. package/src/assertions/facts/linked_meshes.ts +38 -0
  31. package/src/assertions/facts/mesh_attachments.ts +71 -0
  32. package/src/assertions/facts/region_joins.ts +34 -0
  33. package/src/assertions/facts/skin_entries.ts +4 -2
  34. package/src/assertions/facts/skin_members.ts +53 -0
  35. package/src/assertions/facts/skin_meshes.ts +49 -0
  36. package/src/assertions/facts/stage.ts +24 -0
  37. package/src/assertions/facts/vertex_polygons.ts +53 -0
  38. package/src/assertions/mesh_kinds.ts +56 -0
  39. package/src/assertions/model/animated_bones.ts +30 -0
  40. package/src/assertions/model/atlas_regions.ts +76 -0
  41. package/src/assertions/model/constraints.ts +207 -0
  42. package/src/assertions/model/given.ts +45 -0
  43. package/src/assertions/model/index.ts +130 -13
  44. package/src/assertions/model/linked_meshes.ts +24 -0
  45. package/src/assertions/model/mesh_attachments.ts +90 -0
  46. package/src/assertions/model/parse.ts +2 -0
  47. package/src/assertions/model/region_joins.ts +59 -0
  48. package/src/assertions/model/skin_entries.ts +2 -1
  49. package/src/assertions/model/skin_members.ts +62 -0
  50. package/src/assertions/model/skin_meshes.ts +90 -0
  51. package/src/assertions/model/stage.ts +28 -0
  52. package/src/assertions/model/vertex_polygons.ts +71 -0
  53. package/src/assertions/region_lookups.ts +61 -0
  54. package/src/check.ts +8 -6
  55. package/src/compile.ts +145 -38
  56. package/src/core/index.ts +154 -24
  57. package/src/deformstructure.ts +16 -7
  58. package/src/emit_spine.ts +21 -41
  59. package/src/keyorder.ts +40 -0
  60. package/src/model.ts +183 -20
  61. package/src/render.ts +66 -25
  62. package/src/render_core.ts +41 -13
  63. package/src/validate.ts +601 -2328
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A38, the body (issue #1025, step 4c of #380): a skin's member list and its
3
+ * members' `skin: true` flag agree.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the bones,
6
+ * the constraints and the skins' lists are a fact (`../facts/skin_members.ts`)
7
+ * — which spine-core's loaded `SkeletonData` satisfies as it stands. Why both
8
+ * halves of the switch are dead data alone stays above the `check` call in
9
+ * `validate()`.
10
+ */
11
+ import type { Verdicts } from '../harness.ts';
12
+ import type { MemberBone, SkinMemberFacts } from '../facts/skin_members.ts';
13
+
14
+ export function a38SkinMembersAreSkinRequired({ fail, skip, stats }: Verdicts, data: SkinMemberFacts): void {
15
+ /** Every bone a skin can switch on, including the ancestors it drags in. */
16
+ const activatable = new Set<string>();
17
+ // The constraint OBJECTS a skin lists, never their names: a name is not a
18
+ // constraint in this format, and two constraints of one name under two
19
+ // kinds are two objects a skin may list separately (issue #692). Keyed by
20
+ // name, a `transform` `leg` that no skin lists read as listed because an
21
+ // `ik` `leg` was — a skinRequired constraint that never runs, reported
22
+ // green by the one assertion that looks for exactly that.
23
+ const listedConstraints = new Set(data.skins.flatMap((skin) => skin.constraints));
24
+ let listed = 0;
25
+ for (const skin of data.skins) {
26
+ for (const bone of skin.bones) {
27
+ listed++;
28
+ for (let cursor: MemberBone | null = bone; cursor; cursor = cursor.parent) activatable.add(cursor.name);
29
+ if (!bone.skinRequired) {
30
+ fail(
31
+ 'A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED',
32
+ `skin "${skin.name}" activates bone "${bone.name}", which is not skinRequired — updateCache starts it ` +
33
+ 'active anyway, so it poses under every skin and this list changes nothing',
34
+ );
35
+ }
36
+ }
37
+ for (const constraint of skin.constraints) {
38
+ listed++;
39
+ if (!constraint.skinRequired) {
40
+ fail(
41
+ 'A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED',
42
+ `skin "${skin.name}" activates constraint "${constraint.name}", which is not skinRequired — it runs ` +
43
+ 'under every skin, so this list changes nothing',
44
+ );
45
+ }
46
+ }
47
+ }
48
+ const required = data.bones.filter((b) => b.skinRequired).length + data.constraints.filter((c) => c.skinRequired).length;
49
+ if (listed === 0 && required === 0) {
50
+ return skip(
51
+ 'A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED',
52
+ 'no skin activates a bone or a constraint, and nothing declares itself skinRequired',
53
+ );
54
+ }
55
+ for (const bone of data.bones) {
56
+ if (bone.skinRequired && !activatable.has(bone.name)) {
57
+ fail(
58
+ 'A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED',
59
+ `bone "${bone.name}" is skinRequired and no skin's bones list reaches it, so it is never active — ` +
60
+ 'it and its subtree hold the setup pose under every skin',
61
+ );
62
+ }
63
+ }
64
+ for (const constraint of data.constraints) {
65
+ if (constraint.skinRequired && !listedConstraints.has(constraint)) {
66
+ fail(
67
+ 'A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED',
68
+ `constraint "${constraint.name}" is skinRequired and no skin lists it, so it never runs`,
69
+ );
70
+ }
71
+ }
72
+ stats.skinMembers = listed;
73
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A41, the body (issue #1025, step 4c of #380): a physics component the Spine
3
+ * editor cannot hold, refused on a rig declared for the editor.
4
+ *
5
+ * 🚨 The silence this converts is not in the artifact — it is one consumer
6
+ * downstream of it. An author builds a rig whose cowlick jiggles, opens it
7
+ * in the editor to move an eyebrow, saves, exports, and the hair has stopped
8
+ * moving. The returned file says nothing: the component is simply absent,
9
+ * and absent parses as 0. `gallery/look` is exactly that rig.
10
+ *
11
+ * 🔑 **rigc's output is correct, so the refusal is opt-in.** `rotate` on a
12
+ * physics constraint is valid Spine 4.3 that every runtime plays, and
13
+ * refusing it by default would be refusing correct data on behalf of a
14
+ * pipeline rigc was never told about. What rigc can see is the object; which
15
+ * consumers it is for is the rig's to say, and it says it with
16
+ * `invariants.editorRoundTrip` (`src/rig.ts`).
17
+ *
18
+ * ⚠️ **The SKIP is the other half of the product and is not a shrug.** A rig
19
+ * that declares nothing is not gated — but the reason names the constraint
20
+ * and the components a round trip would drop, so the one thing that must not
21
+ * happen (nobody finds out) does not happen either. That is why this reads
22
+ * the whole skeleton before it reads the declaration, rather than returning
23
+ * early on a rig that asked for nothing.
24
+ *
25
+ * 🔸 **Against A23, on the far side of the same trip.** A23 refuses a
26
+ * constraint driving NOTHING — which is what the editor hands back, after
27
+ * the loss — and it is what fires today on a round-tripped `look`. This
28
+ * refuses a constraint driving something the editor will not keep, before
29
+ * the trip. They cannot both fire on one constraint: A23's condition is an
30
+ * empty driven set and this one's is a non-empty one (see
31
+ * `PHYSICS_COMPONENTS`), so the two are disjoint by construction rather than
32
+ * by agreement.
33
+ *
34
+ * Moved out of `src/validate.ts` whole: the components are the document's
35
+ * physics records, the declaration the rig's.
36
+ */
37
+ import type { Verdicts } from '../harness.ts';
38
+ import type { ConstraintFacts } from '../facts/constraints.ts';
39
+ import { EDITOR_PHYSICS_COMPONENTS, PHYSICS_COMPONENTS } from '../constraint_words.ts';
40
+ import type { RigInfo } from '../../types.ts';
41
+
42
+ export function a41PhysicsSurvivesEditorRoundTrip({ fail, skip }: Verdicts, facts: ConstraintFacts, rig: RigInfo | undefined): void {
43
+ // Counted here rather than read off `stats.physicsConstraints`: that entry
44
+ // is written by A23, and a rule whose SKIP depends on another rule having
45
+ // run is a rule that reports "nothing to measure" when its neighbour threw.
46
+ const physics = facts.constraints.flatMap((c) => (c.physics === undefined ? [] : [{ name: c.name, components: c.physics.components }]));
47
+ const dropped: string[] = [];
48
+ for (const constraint of physics) {
49
+ const lost = PHYSICS_COMPONENTS.filter((k) => constraint.components[k] > 0 && !EDITOR_PHYSICS_COMPONENTS.has(k));
50
+ if (lost.length) dropped.push(`physics "${constraint.name}" drives ${lost.join(', ')}`);
51
+ }
52
+ const editorKeeps = [...EDITOR_PHYSICS_COMPONENTS].join(' and ');
53
+ const found =
54
+ dropped.length === 0
55
+ ? 'no physics constraint here drives a component it would discard'
56
+ : `${dropped.join('; ')}, and the editor's physics model holds ${editorKeeps} only, so a round trip ` +
57
+ 'returns that constraint driving nothing at all (issue #540)';
58
+ if (rig?.editorRoundTrip !== true) {
59
+ return skip(
60
+ 'A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP',
61
+ `${
62
+ rig
63
+ ? `the rig "${rig.archetype}" does not declare \`invariants.editorRoundTrip\``
64
+ : 'this is a bare directory, with no rig info to declare `invariants.editorRoundTrip`'
65
+ }, so nothing here is gated against the Spine editor. What is here: ${found}`,
66
+ );
67
+ }
68
+ if (physics.length === 0) {
69
+ return skip(
70
+ 'A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP',
71
+ `the rig "${rig.archetype}" is declared for the editor, but it carries no physics constraint — ` +
72
+ 'there is nothing here whose components could be lost',
73
+ );
74
+ }
75
+ for (const one of dropped) {
76
+ fail(
77
+ 'A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP',
78
+ `${one}; the editor's physics model holds ${editorKeeps} only, and this ` +
79
+ 'rig declares `invariants.editorRoundTrip` — drive it in x/y, or drop the declaration if this rig never ' +
80
+ 'goes through the editor (issue #540)',
81
+ );
82
+ }
83
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A42, the body (issue #1025, step 4c of #380): a constraint a slider's
3
+ * animation keys updates after that slider.
4
+ *
5
+ * `Skeleton.updateCache` walks the `constraints` array in order and each
6
+ * constraint's `sort` pushes itself as it is reached — `Slider.sort` included,
7
+ * which pushes bones and never a constraint — so the array IS the update order,
8
+ * for every kind. Every constraint then opens its `update` by reading its own
9
+ * applied pose, so a slider that keys any property of a constraint is read by
10
+ * that constraint only when it comes LATER in the array; written the other way
11
+ * round the value lands in a pose whose only reader has already run, and
12
+ * `Posed.resetConstrained` puts the pose back before the next frame (the
13
+ * argument and its measurements sit above the `check` call in `validate()`).
14
+ *
15
+ * Moved out of `src/validate.ts` whole: the array's order, the slider's
16
+ * animation and the constraint timelines it holds — which constraint each
17
+ * names, what it keys, and whom a physics timeline naming none reaches — are
18
+ * the rig's, and a document's records state each. The words the sentence
19
+ * prints are the facts' own: what a timeline keys (`word`, the motion spec's
20
+ * word for it) and the runtime class whose `update` reads it (`runtimeClass`).
21
+ */
22
+ import type { Verdicts } from '../harness.ts';
23
+ import type { ConstraintFacts } from '../facts/constraints.ts';
24
+
25
+ export function a42DrivenConstraintsUpdateAfterTheirDriver({ fail, skip, stats }: Verdicts, facts: ConstraintFacts): void {
26
+ const sliders = facts.constraints.flatMap((c, index) => (c.slider === undefined ? [] : [{ name: c.name, slider: c.slider, index }]));
27
+ if (!sliders.length) {
28
+ return skip('A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER', 'the skeleton declares no slider constraint');
29
+ }
30
+ let pairs = 0;
31
+ let resets = 0;
32
+ for (const driver of sliders) {
33
+ const driverIndex = driver.index;
34
+ const animationName = driver.slider.animation?.name;
35
+ for (const timeline of facts.timelines) {
36
+ if (animationName === undefined || timeline.animation !== animationName) continue;
37
+ if (timeline.kind === 'physics' && timeline.word === 'reset') {
38
+ resets++;
39
+ continue;
40
+ }
41
+ const word = timeline.word;
42
+ const everyPhysics = timeline.constraint < 0;
43
+ // A physics timeline whose animation names no constraint reaches every
44
+ // ACTIVE physics constraint whose own data declares that property
45
+ // global — the timeline's `reach`, the one reading of it A23 and A34
46
+ // share (issue #726).
47
+ for (const drivenIndex of timeline.reach) {
48
+ const driven = facts.constraints[drivenIndex];
49
+ if (driven === undefined) continue;
50
+ pairs++;
51
+ if (drivenIndex > driverIndex) continue;
52
+ const kind = { word: driven.kind, runtime: driven.runtimeClass };
53
+ const animation = `animation "${animationName}"`;
54
+ const reference = `\`${kind.word}.${driven.name}${word === kind.word ? '' : `.${word}`}\``;
55
+ fail(
56
+ 'A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER',
57
+ drivenIndex === driverIndex
58
+ ? `slider "${driver.name}" (constraints[${driverIndex}]) keys its own \`${word}\` in ${animation}. ` +
59
+ '`Slider.update` reads `appliedPose.' +
60
+ `${word}\` as the ${word === 'mix' ? 'alpha it applies that animation with' : 'time it applies that animation at'}, ` +
61
+ 'before the animation runs, so the key is written after its only reader and `Posed.resetConstrained` ' +
62
+ `puts the pose back before the next frame${
63
+ word === 'mix'
64
+ ? ' — and at `mix` 0 `update` returns before applying anything at all, so the key that would raise it is unreachable'
65
+ : ''
66
+ }. Key ${reference} from a slider EARLIER in \`constraints\`, or state the ` +
67
+ `\`${word}\` this slider should start at in the rig spec`
68
+ : `slider "${driver.name}" (constraints[${driverIndex}]) keys ${
69
+ word === kind.word ? `the \`${word}\` timeline` : `\`${word}\``
70
+ } of ${kind.word} constraint ` +
71
+ `"${driven.name}" (constraints[${drivenIndex}]) in ${animation}${
72
+ everyPhysics ? ' — the timeline names no constraint, which the runtime reads as every physics constraint declaring that property global —' : ''
73
+ }, and "${driven.name}" updates FIRST. The \`constraints\` array is the update order ` +
74
+ '(`Skeleton.updateCache` walks it and each constraint\'s `sort` pushes itself as it is reached) and ' +
75
+ `\`${kind.runtime}.update\` reads its own \`appliedPose\` before applying anything, so that key is ` +
76
+ 'written after the only read of it and `Posed.resetConstrained` discards it before the next frame: ' +
77
+ `what "${driven.name}" drives is dead at every reading of "${driver.name}"'s dial, although its pose ` +
78
+ `still holds the number. Move "${driver.name}" before "${driven.name}" in \`constraints\`, or key ` +
79
+ `${reference} from a slider that already is`,
80
+ );
81
+ }
82
+ }
83
+ }
84
+ if (pairs === 0) {
85
+ return skip(
86
+ 'A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER',
87
+ `no animation applied by one of the ${sliders.length} slider constraint${sliders.length === 1 ? '' : 's'} keys a ` +
88
+ `property of a constraint, so no slider here drives a constraint${
89
+ resets === 0
90
+ ? ''
91
+ : ` — the ${resets} \`physics\` \`reset\` key(s) they do carry are not a pose write, and [measured] a slider ` +
92
+ 'applies its animation at one instant, so `PhysicsConstraintResetTimeline` never fires from one in either array order'
93
+ }`,
94
+ );
95
+ }
96
+ stats.sliderDrivenConstraints = pairs;
97
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A44, the body (issue #1025, step 4c of #380): a linked mesh states no
3
+ * geometry of its own.
4
+ *
5
+ * Moved out of `src/validate.ts` by its subject only. Which links the file
6
+ * declares is the rig's — a document's `linkedmesh` record is one — so the
7
+ * roster moved, and a rig with no link SKIPs on both sides. What A44 refuses
8
+ * is the encoding's: a link record holds no geometry field, and `uvs`,
9
+ * `triangles`, `vertices`, `hull` and `edges` written on a link exist only in
10
+ * the Spine text (the census's row for A44). That clause, and the sentence it
11
+ * prints, stay with the round trip, which hands its finding about each link in
12
+ * as the link's `encoding`, printed in the order the body always printed it.
13
+ */
14
+ import type { Verdicts } from '../harness.ts';
15
+ import type { LinkFacts } from '../facts/linked_meshes.ts';
16
+ import { SKIP_NO_LINKED_MESH } from '../reasons.ts';
17
+
18
+ export function a44LinkedMeshStatesNoGeometryOfItsOwn({ fail, skip }: Verdicts, { links }: LinkFacts): void {
19
+ if (links.length === 0) return skip('A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN', SKIP_NO_LINKED_MESH);
20
+ for (const link of links) {
21
+ for (const detail of link.encoding) fail('A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN', detail);
22
+ }
23
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A47, the body (issue #1025, step 4c of #380): an ik constraint muted for
3
+ * good — resting at mix 0 with no animation switching it on — is refused,
4
+ * unless the rig declares that the consumer drives its mix.
5
+ *
6
+ * The question A23, A36 and A37 ask of their own kinds, asked of the kind
7
+ * editor exports use most (issue #765): a constraint that rests muted and
8
+ * that no animation switches on parses, sits in the update cache and moves
9
+ * nothing. 🔑 Live is the runtime's own test, `!== 0`: `IkConstraint.update`
10
+ * returns on `mix === 0`, so a negative mix runs. Channel 0 of an ik frame is
11
+ * `mix`; the other four are softness, bend direction, compress and stretch.
12
+ * The argument with its measurements sits above the `check` call in
13
+ * `validate()`.
14
+ *
15
+ * Moved out of `src/validate.ts` whole: the bones, the target, the setup mix
16
+ * and the keyed ones are the rig's, the declaration the rig spec's.
17
+ */
18
+ import type { Verdicts } from '../harness.ts';
19
+ import type { ConstraintFacts } from '../facts/constraints.ts';
20
+ import { consumerDriven, consumerSkip, declaredButLive, declareIt, ikLive, noneKeysItsMixAbove0, REST_OR_KEY_ITS_MIX, switchedOn } from '../constraint_words.ts';
21
+ import type { RigInfo } from '../../types.ts';
22
+
23
+ export function a47IkConstraintNotMutedThroughout({ fail, skip, stats }: Verdicts, facts: ConstraintFacts, rig: RigInfo | undefined): void {
24
+ const NAME = 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT';
25
+ const constraints = facts.constraints.flatMap((c, index) => (c.ik === undefined ? [] : [{ name: c.name, ik: c.ik, index }]));
26
+ if (!constraints.length) return skip(NAME, 'the skeleton declares no ik constraint');
27
+ // Channel 0 is `mix`; the other four are softness, bend direction, compress and stretch.
28
+ const ikSwitchedOn = switchedOn(facts, (timeline) => timeline.kind === 'ik', 1, (_timeline, value, channel) => channel === 0 && ikLive(value));
29
+ const declared = consumerDriven(rig, 'ik');
30
+ const exempt: Array<[string, string]> = [];
31
+ for (const { name, ik: constraint, index } of constraints) {
32
+ const mix = constraint.mix;
33
+ const live = ikLive(mix) || ikSwitchedOn.has(index);
34
+ const why = declared.get(name);
35
+ if (why !== undefined) {
36
+ if (!live) exempt.push([name, why]);
37
+ else {
38
+ fail(
39
+ NAME,
40
+ declaredButLive(
41
+ `ik constraint "${name}"`,
42
+ ikLive(mix) ? `it rests at mix ${mix}` : 'an animation keys its mix above 0',
43
+ ),
44
+ );
45
+ }
46
+ continue;
47
+ }
48
+ if (live) continue;
49
+ fail(
50
+ NAME,
51
+ `ik constraint "${name}" has mix ${mix} at setup and ${noneKeysItsMixAbove0(facts.animations)}; ` +
52
+ `update() returns on mix 0, so ${constraint.bones.map((bone) => `"${bone}"`).join(' and ')} never ` +
53
+ `reach${constraint.bones.length === 1 ? 'es' : ''} for "${constraint.target}" — ${REST_OR_KEY_ITS_MIX}, ` +
54
+ declareIt('ik', name),
55
+ );
56
+ }
57
+ if (exempt.length) stats.ikConsumerDriven = exempt.map(([name]) => name).join(',');
58
+ if (exempt.length === constraints.length) return skip(NAME, consumerSkip('ik', exempt, facts.animations));
59
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A48, the body (issue #1025, step 4c of #380): a transform constraint muted
3
+ * for good on every mix it reads is refused, unless the rig declares that the
4
+ * consumer drives its mix.
5
+ *
6
+ * 🔑 **A transform mix is read only for a property the constraint drives.**
7
+ * The early return in `TransformConstraint.update` is over all six mixes, but
8
+ * it is not what decides whether anything moves: each `to` entry reads its own
9
+ * mix (`ToRotate.mix` is `mixRotate`, …). At setup the parser only reads a mix
10
+ * whose property is declared, so the two tests agree there — but a timeline
11
+ * key that omits a mix is read as 1 (`SkeletonJson.js`, every `getValue(…, 1)`),
12
+ * so a key of `mixRotate: 0` alone on a rotate-only constraint passes the
13
+ * six-mix test on five mixes nothing reads. [measured] that key poses every
14
+ * bone exactly where no constraint does, and so does one keying `mixX` 1 on
15
+ * the same constraint. So this reads the mixes of the declared `to` kinds,
16
+ * at setup and on every value a key poses — the fact's `mixes`, `null` where
17
+ * the constraint declares no `to` of that property. Live is `!== 0`
18
+ * (`ikLive`'s note).
19
+ *
20
+ * Moved out of `src/validate.ts` whole: the declared `to` kinds, the setup
21
+ * mixes and the keyed ones are the rig's, the declaration the rig spec's.
22
+ */
23
+ import type { Verdicts } from '../harness.ts';
24
+ import type { ConstraintFacts } from '../facts/constraints.ts';
25
+ import { consumerDriven, consumerSkip, declaredButLive, declareIt, noneKeysItsMixAbove0, switchedOn } from '../constraint_words.ts';
26
+ import type { RigInfo } from '../../types.ts';
27
+
28
+ export function a48TransformConstraintNotMutedThroughout({ fail, skip, stats }: Verdicts, facts: ConstraintFacts, rig: RigInfo | undefined): void {
29
+ const NAME = 'A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT';
30
+ const constraints = facts.constraints.flatMap((c, index) => (c.transform === undefined ? [] : [{ name: c.name, transform: c.transform, index }]));
31
+ if (!constraints.length) return skip(NAME, 'the skeleton declares no transform constraint');
32
+ /** Which of the six channels `constraint` reads at all: the ones whose `to` kind it declares. */
33
+ const transformReads = (index: number): boolean[] => (facts.constraints[index]?.transform?.mixes ?? []).map((mix) => mix !== null);
34
+ const transformSwitchedOn = switchedOn(facts, (timeline) => timeline.kind === 'transform', 6, (timeline, value, channel) => {
35
+ const constraint = facts.constraints[timeline.constraint];
36
+ return constraint?.transform !== undefined && transformReads(timeline.constraint)[channel] && value !== 0;
37
+ });
38
+ const declared = consumerDriven(rig, 'transform');
39
+ const exempt: Array<[string, string]> = [];
40
+ for (const { name, transform: constraint, index } of constraints) {
41
+ const where = `transform constraint "${name}"`;
42
+ const pose: Record<string, number> = {};
43
+ for (const mix of constraint.mixes) if (mix !== null) pose[mix.field] = mix.setup;
44
+ const read = constraint.mixes.flatMap((mix) => (mix === null ? [] : [mix.field]));
45
+ if (read.length === 0) {
46
+ // No `to` at all: no mix is ever read, so neither remedy below applies.
47
+ fail(
48
+ NAME,
49
+ `${where} drives no property — its \`properties\` name no \`to\` — so no mix it carries is ever read and it ` +
50
+ 'moves nothing; declare the property it should drive',
51
+ );
52
+ continue;
53
+ }
54
+ const resting = read.filter((field) => pose[field] !== 0);
55
+ const live = resting.length > 0 || transformSwitchedOn.has(index);
56
+ const why = declared.get(name);
57
+ if (why !== undefined) {
58
+ if (!live) exempt.push([name, why]);
59
+ else {
60
+ fail(
61
+ NAME,
62
+ declaredButLive(
63
+ where,
64
+ resting.length ? `it rests at ${resting.map((field) => `${field} ${pose[field]}`).join(', ')}` : 'an animation keys its mix above 0',
65
+ ),
66
+ );
67
+ }
68
+ continue;
69
+ }
70
+ if (live) continue;
71
+ fail(
72
+ NAME,
73
+ `${where} drives ${read.map((field) => field.slice(3).replace(/^./, (c) => c.toLowerCase())).join(', ')} and has ` +
74
+ `${read.map((field) => `${field} ${pose[field]}`).join(', ')} at setup, and ` +
75
+ `${noneKeysItsMixAbove0(facts.animations)}; a mix is read only for a property the constraint drives, and ` +
76
+ `update() skips each one at 0, so nothing ever moves ${constraint.bones.map((bone) => `"${bone}"`).join(', ')} — ` +
77
+ `rest ${read.length === 1 ? read[0] : `one of ${read.join(', ')}`} above 0, or key its mix above 0 in an animation, ` +
78
+ declareIt('transform', name),
79
+ );
80
+ }
81
+ if (exempt.length) stats.transformConsumerDriven = exempt.map(([name]) => name).join(',');
82
+ if (exempt.length === constraints.length) return skip(NAME, consumerSkip('transform', exempt, facts.animations));
83
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * What the constraint bodies share (issue #1025, step 4c of #380): the one
3
+ * reading of "does an animation switch this on", the one predicate a path or
4
+ * slider mix is judged by, the physics components, and the sentences A23,
5
+ * A36, A37, A47 and A48 say alike. Moved here from `src/validate.ts`, each
6
+ * unchanged but for what `switchedOn` reads — the facts
7
+ * (`./facts/constraints.ts`) rather than the loaded timelines — because the
8
+ * bodies that read them moved, and a second copy is how two rules come to
9
+ * disagree. `src/validate.ts` imports what A40 still reads.
10
+ *
11
+ * Links nothing from the runtime.
12
+ */
13
+ import type { ConstraintFacts, ConstraintTimeline } from './facts/constraints.ts';
14
+ import type { RigInfo } from '../types.ts';
15
+
16
+ /**
17
+ * Every component a physics constraint can drive (`PhysicsConstraintData`), and
18
+ * the subset the **Spine editor** models.
19
+ *
20
+ * ⭐ One list, read by both A23 and A41, because the relationship between those
21
+ * two rules is the thing most worth keeping true: A23 fires when the driven set
22
+ * is EMPTY, A41 when it contains something outside `EDITOR_PHYSICS_COMPONENTS`.
23
+ * Written against one array those conditions cannot both hold on one constraint,
24
+ * and a reader can see that they cannot. Two copies of the vocabulary could
25
+ * drift into overlapping, and a rig refused twice for one fact is a report that
26
+ * has stopped saying what is wrong with it.
27
+ *
28
+ * 📏 The subset is measured rather than read off a document, and it is the whole
29
+ * of issue #540: three rigs, twelve constraints, predictions recorded before the
30
+ * round trip and scored by the code that printed them. A lone `y` came back, `x`
31
+ * and `y` together came back — so the rule is membership and not arity — and a
32
+ * lone `rotate`, a lone `scaleX` and a lone `shearX` each came back driving no
33
+ * component at all, with neither `scaleY` mode rescuing `scaleX`. Every
34
+ * constraint carried a fixed-point `strength` and all twelve returned exactly,
35
+ * so the rows that reported nothing were reading live data. Measured on Spine
36
+ * 4.3.26 Professional.
37
+ */
38
+ export const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
39
+ export const EDITOR_PHYSICS_COMPONENTS: ReadonlySet<(typeof PHYSICS_COMPONENTS)[number]> = new Set(['x', 'y'] as const);
40
+
41
+ /**
42
+ * The half of a muted-at-rest refusal that says what was searched, and how
43
+ * widely — one text for `A23`, `A36` and `A37`, which ask one question of three
44
+ * constraint kinds (issues #743, #752). A rig with no animation at all says
45
+ * "none of the 0 animations" rather than implying somebody keyed something.
46
+ */
47
+ export function noneKeysItsMixAbove0(animations: number): string {
48
+ return `none of the ${animations} animation${animations === 1 ? '' : 's'} keys its mix above 0`;
49
+ }
50
+
51
+ /** The two repairs a muted-at-rest refusal names, since either one is a rig the runtime plays. */
52
+ export const REST_OR_KEY_ITS_MIX = 'rest it above 0, or key its mix above 0 in an animation';
53
+
54
+ /**
55
+ * The one predicate a path or slider mix is judged by, at setup and on every
56
+ * value a key poses: above 0, where `update()` does anything at all.
57
+ */
58
+ export const mixLive = (value: number): boolean => value > 0;
59
+
60
+ /**
61
+ * 🔑 **An ik or transform mix is live by the runtime's own test, `!== 0`, and
62
+ * not `mixLive`'s `> 0`.** `IkConstraint.update` returns on `mix === 0` and a
63
+ * transform's inner loop applies a property only when `to.mix(pose) !== 0`, so
64
+ * a negative mix runs. That is not a corner: [measured] five transform
65
+ * constraints across four of the editor's own example exports rest at mixX =
66
+ * mixY = −1, nothing keys them, and each moves its bones at setup against the
67
+ * same constraint with every mix 0. A `> 0` reading refuses all five.
68
+ * (`A36`/`A37` still read `> 0` — a path or slider resting negative is a
69
+ * question for their own card.)
70
+ */
71
+ export const ikLive = (value: number): boolean => value !== 0;
72
+
73
+ /**
74
+ * The constraints some animation keys to a value `live` accepts, on any of the
75
+ * first `channels` channels of a timeline `owns` claims — the one answer to
76
+ * "does anything switch this on" for `A23`, `A36`, `A37`, `A40`, `A47` and
77
+ * `A48` (issues #743, #752), as positions in `facts.constraints`.
78
+ *
79
+ * ⭐ It replaced `keyedBy`, which read the raw JSON and took a non-empty key
80
+ * array as the answer, and it did so rather than teaching that one to read
81
+ * values, because the raw file is the wrong place to read a value: a path
82
+ * `mix` key that omits `mixRotate` means 1 (`SkeletonJson.js:1011-1013`), a
83
+ * `mixY` it omits means that key's `mixX`, and a Bezier between two keys
84
+ * poses values neither key states. So this reads every value a channel poses
85
+ * — each key's own and every sample of each Bézier segment the parser built
86
+ * between two keys (`ConstraintTimeline.channelValues`): spine-core's loaded
87
+ * timelines on one side, the core's reading of the document on the other.
88
+ * [measured] the reading `keyedBy` gave was wrong in the accepting
89
+ * direction: a path constraint and a slider, both muted at rest and keyed to
90
+ * 0 only, pose every bone exactly where the same rig with no timeline does
91
+ * (max |Δ| 0.000000 over 60 steps at 60 fps) and both passed.
92
+ *
93
+ * A timeline naming no constraint is the physics family's global form, and
94
+ * its `reach` says who it writes; every other constraint timeline names its
95
+ * one constraint.
96
+ *
97
+ * `live` is handed the channel as well as the value, because not every
98
+ * channel of every constraint timeline is a mix (issue #765): an ik frame is
99
+ * mix, softness, bend direction, compress and stretch, so a bend direction of
100
+ * +1 is not a key that switches anything on, and a transform frame carries
101
+ * six mixes of which only the ones for a property the constraint drives are
102
+ * ever read. `channels` is how many leading channels the caller's `live` can
103
+ * accept a value on.
104
+ */
105
+ export function switchedOn(
106
+ facts: ConstraintFacts,
107
+ owns: (timeline: ConstraintTimeline) => boolean,
108
+ channels: number,
109
+ live: (timeline: ConstraintTimeline, value: number, channel: number) => boolean,
110
+ ): Set<number> {
111
+ const reached = new Set<number>();
112
+ for (const timeline of facts.timelines) {
113
+ if (!owns(timeline)) continue;
114
+ let keysLive = false;
115
+ for (let channel = 0; channel < channels && !keysLive; channel++) {
116
+ keysLive = timeline.channelValues(channel).some((value) => live(timeline, value, channel));
117
+ }
118
+ if (!keysLive) continue;
119
+ for (const one of timeline.reach) reached.add(one);
120
+ }
121
+ return reached;
122
+ }
123
+
124
+ // 🔑 **The third door: the consumer drives the mix (issue #784).** A muted
125
+ // constraint nothing in the file switches on is either a leftover or a dial
126
+ // a game turns from code, and the two export as the same bytes — so the rig
127
+ // spec says which, in `invariants.consumerDrivenMix`, and a declared
128
+ // constraint is not measured by `A47` / `A48`. What that buys is never a pass
129
+ // on it:
130
+ //
131
+ // * every constraint of the kind declared — nothing is left to measure, so
132
+ // the rule SKIPs, naming each constraint, the declaration and its `why`;
133
+ // * some declared and some not — the rest are measured, and the declared
134
+ // ones go on the stats line, which is `A39`'s shape for `deformMayFold`.
135
+ // A SKIP there would put "nothing measured" over a rule that measured,
136
+ // and the summary would count a measured rule as skipped (`reportLines`'
137
+ // four buckets partition the registry, one row per rule).
138
+ //
139
+ // ⛔ A declared constraint the file ALSO switches on — resting live, or keyed
140
+ // above 0 — is refused: the declaration exempts nothing there, which is the
141
+ // shape `deformMayFold` on a slot with no mesh is refused for. [measured]
142
+ // (`scratchpad/consumerdriven_runtime.ts`) an ik keyed at mix 0.5 with code
143
+ // writing 1: code before `state.apply` is overwritten (applied mix 0.5),
144
+ // code after it wins (1.0) — so which author holds a frame is the order of
145
+ // the consumer's own loop, a fact about the scene rather than the object.
146
+
147
+ /** The rig's consumer-driven declarations of one kind: constraint name -> why. */
148
+ export function consumerDriven(rig: RigInfo | undefined, type: 'ik' | 'transform'): Map<string, string> {
149
+ return new Map((rig?.consumerDrivenMix ?? []).filter((e) => e.type === type).map((e) => [e.constraint, e.why]));
150
+ }
151
+
152
+ /** The third repair a muted-throughout refusal names. */
153
+ export function declareIt(type: 'ik' | 'transform', name: string): string {
154
+ return (
155
+ `or declare that the consumer drives its mix, in the rig spec as invariants.consumerDrivenMix: ` +
156
+ `[{ "constraint": "${name}", "type": "${type}", "why": … }]`
157
+ );
158
+ }
159
+
160
+ /** The refusal of a declaration that exempts nothing. */
161
+ export function declaredButLive(where: string, how: string): string {
162
+ return (
163
+ `${where} is declared in the rig spec as invariants.consumerDrivenMix, and the file already switches it on — ` +
164
+ `${how} — so the declaration exempts nothing; drop the entry. Where code also sets that mix, which of the two ` +
165
+ 'holds on a frame is the order of the consumer\'s own loop: `state.apply` overwrites a mix written before it, ' +
166
+ 'and a mix written after it replaces the key'
167
+ );
168
+ }
169
+
170
+ /** The SKIP when every constraint of the kind is declared consumer-driven. */
171
+ export function consumerSkip(kind: string, exempt: Array<[string, string]>, animations: number): string {
172
+ return (
173
+ `every ${kind} constraint here is declared in the rig spec as invariants.consumerDrivenMix, so its mix is the ` +
174
+ `consumer's to set and nothing in this file shows it moving — ${exempt.map(([name, why]) => `"${name}" (why: ${why})`).join('; ')}: ` +
175
+ `${exempt.length === 1 ? 'it rests' : 'each rests'} muted and ${noneKeysItsMixAbove0(animations)}`
176
+ );
177
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Which bones an animation keys (issue #1025, step 4c of #380) — the roster
3
+ * half of the census's F18 that A15 reads: whether the skeleton holds an
4
+ * animation of a name, whether it keys any bone, and which bones, in the
5
+ * order the file keys them.
6
+ *
7
+ * ⭐ **Three states, because A15 says three different things.** No such
8
+ * animation, an animation that keys no bone, and the bones it keys are each a
9
+ * sentence of their own in A15 — one is a missing subject, the next a subject
10
+ * with nothing in it, the last the subject — so the fact keeps them apart
11
+ * rather than folding the first two into an empty list.
12
+ *
13
+ * The order is the file's: A15 prints one line per keyed bone in it. The file
14
+ * keys an animation's bones in the order the emitter writes the model's
15
+ * (`emitAnimation`), which is the document's own list.
16
+ *
17
+ * Links nothing from the runtime: `validate()` reads it off the skeleton JSON
18
+ * as A15 always did, and the model side off the document
19
+ * (`../model/animated_bones.ts`).
20
+ */
21
+ export interface AnimatedBoneFacts {
22
+ /**
23
+ * The bones `animation` keys, in the file's order — `undefined` when the
24
+ * skeleton holds no animation of that name, `null` when it holds one that
25
+ * carries no bone timeline at all.
26
+ */
27
+ bonesKeyedBy(animation: string): readonly string[] | null | undefined;
28
+ }