spine-rigc 1.7.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 (79) hide show
  1. package/cli.ts +88 -21
  2. package/docs/AUTHORING.md +18 -12
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a03.ts +27 -0
  5. package/src/assertions/bodies/a04.ts +40 -0
  6. package/src/assertions/bodies/a06.ts +195 -0
  7. package/src/assertions/bodies/a08.ts +76 -0
  8. package/src/assertions/bodies/a11.ts +15 -0
  9. package/src/assertions/bodies/a13.ts +51 -0
  10. package/src/assertions/bodies/a14.ts +35 -0
  11. package/src/assertions/bodies/a15.ts +97 -0
  12. package/src/assertions/bodies/a17.ts +26 -0
  13. package/src/assertions/bodies/a19.ts +295 -0
  14. package/src/assertions/bodies/a20.ts +122 -0
  15. package/src/assertions/bodies/a21.ts +190 -0
  16. package/src/assertions/bodies/a22.ts +39 -0
  17. package/src/assertions/bodies/a23.ts +255 -0
  18. package/src/assertions/bodies/a27.ts +33 -0
  19. package/src/assertions/bodies/a28.ts +70 -0
  20. package/src/assertions/bodies/a33.ts +110 -0
  21. package/src/assertions/bodies/a36.ts +81 -0
  22. package/src/assertions/bodies/a37.ts +77 -0
  23. package/src/assertions/bodies/a38.ts +73 -0
  24. package/src/assertions/bodies/a41.ts +83 -0
  25. package/src/assertions/bodies/a42.ts +97 -0
  26. package/src/assertions/bodies/a44.ts +23 -0
  27. package/src/assertions/bodies/a45.ts +172 -0
  28. package/src/assertions/bodies/a47.ts +59 -0
  29. package/src/assertions/bodies/a48.ts +83 -0
  30. package/src/assertions/constraint_words.ts +177 -0
  31. package/src/assertions/facts/animated_bones.ts +28 -0
  32. package/src/assertions/facts/atlas_pages.ts +19 -0
  33. package/src/assertions/facts/atlas_regions.ts +52 -0
  34. package/src/assertions/facts/constraints.ts +131 -0
  35. package/src/assertions/facts/linked_meshes.ts +38 -0
  36. package/src/assertions/facts/mesh_attachments.ts +71 -0
  37. package/src/assertions/facts/region_joins.ts +34 -0
  38. package/src/assertions/facts/skin_entries.ts +37 -0
  39. package/src/assertions/facts/skin_members.ts +53 -0
  40. package/src/assertions/facts/skin_meshes.ts +49 -0
  41. package/src/assertions/facts/slot_colour.ts +43 -0
  42. package/src/assertions/facts/stage.ts +24 -0
  43. package/src/assertions/facts/vertex_polygons.ts +53 -0
  44. package/src/assertions/harness.ts +109 -0
  45. package/src/assertions/kinds.ts +102 -0
  46. package/src/assertions/mesh_kinds.ts +56 -0
  47. package/src/assertions/model/animated_bones.ts +30 -0
  48. package/src/assertions/model/atlas_pages.ts +15 -0
  49. package/src/assertions/model/atlas_regions.ts +76 -0
  50. package/src/assertions/model/constraints.ts +207 -0
  51. package/src/assertions/model/given.ts +45 -0
  52. package/src/assertions/model/index.ts +247 -0
  53. package/src/assertions/model/linked_meshes.ts +24 -0
  54. package/src/assertions/model/mesh_attachments.ts +90 -0
  55. package/src/assertions/model/parse.ts +138 -0
  56. package/src/assertions/model/region_joins.ts +59 -0
  57. package/src/assertions/model/skin_entries.ts +59 -0
  58. package/src/assertions/model/skin_members.ts +62 -0
  59. package/src/assertions/model/skin_meshes.ts +90 -0
  60. package/src/assertions/model/slot_colour.ts +80 -0
  61. package/src/assertions/model/stage.ts +28 -0
  62. package/src/assertions/model/vertex_polygons.ts +71 -0
  63. package/src/assertions/reasons.ts +127 -0
  64. package/src/assertions/region_lookups.ts +61 -0
  65. package/src/assertions/values.ts +39 -0
  66. package/src/check.ts +36 -9
  67. package/src/compile.ts +204 -57
  68. package/src/core/index.ts +161 -29
  69. package/src/core/world.ts +21 -20
  70. package/src/deformmeasure.ts +377 -154
  71. package/src/deformreport.ts +3 -0
  72. package/src/deformstructure.ts +344 -0
  73. package/src/emit_spine.ts +21 -41
  74. package/src/keyorder.ts +40 -0
  75. package/src/model.ts +188 -22
  76. package/src/render.ts +243 -54
  77. package/src/render_core.ts +101 -13
  78. package/src/transform.ts +79 -30
  79. package/src/validate.ts +742 -2781
@@ -0,0 +1,172 @@
1
+ /**
2
+ * A45, the body (issue #1025, step 4c of #380): the separable colour timelines
3
+ * own their channels and pose as written.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads, which is
6
+ * three facts (`../facts/slot_colour.ts`) where it read the skeleton JSON and
7
+ * the loaded skeleton: the animations' slot timelines in the file's order, as
8
+ * the file spells them; whether an animation exists; and a slot's colour posed
9
+ * at a time on a fresh track, which `validate()` takes from spine-core and the
10
+ * model side from rigc's core. The `check` call stays in `validate()`.
11
+ */
12
+ import type { Verdicts } from '../harness.ts';
13
+ import type { SlotColourFacts } from '../facts/slot_colour.ts';
14
+ import { SKIP_NO_SEPARABLE_COLOR } from '../reasons.ts';
15
+ import { atStoredKey, isObj } from '../values.ts';
16
+ import { SLOT_COLOR_CHANNELS } from '../../timelines.ts';
17
+
18
+ // ⭐ **Why this is its own rule and not a clause on `A43`.** `rgb` and
19
+ // `alpha` pose no dark colour, so under A43's name a failure would be a
20
+ // verdict saying "two-colour tint" about a file that states one colour —
21
+ // the name would say something other than what it decides (the rule #712
22
+ // applied to A43 itself, against a clause on A10). And the SKIP is the
23
+ // sharper half: a rig with a `dark` and no separable timeline has A43's
24
+ // subject present, so a clause there could only PASS on it, which is a pass
25
+ // for an absent subject.
26
+ //
27
+ // 🚨 **What it decides is what makes `rgb` + `alpha` not `rgba`**, and both
28
+ // clauses are about the file against the runtime:
29
+ //
30
+ // 1. **One channel, one timeline.** Each colour timeline poses its
31
+ // channels at EVERY time — before its first key it writes the setup
32
+ // value — so when two of one slot share a channel, the one applied
33
+ // later (the one the file states later) overwrites the other
34
+ // everywhere and the first one's keys on it are read by nothing
35
+ // (`SLOT_COLOR_CHANNELS`). It is the shape a converter that writes a
36
+ // separable `rgb` as `rgba` leaves beside the `alpha` it kept, and it
37
+ // loads without a word.
38
+ // 2. **Posed as written.** Stepped to each key's own time, the channels
39
+ // the timeline writes are the key's: a colour that is not six hex
40
+ // digits loads as NaN, and a key whose time another key repeats is
41
+ // read by nothing, and both parse in silence.
42
+ //
43
+ // ⚠️ An `rgb` timeline alone that a converter wrote as `rgba` with the
44
+ // setup alpha is NOT this rule's to see, and nothing that reads only the
45
+ // file can see it: the result is a correct `rgba`, which is also what an
46
+ // author keying the light colour and holding its alpha would write. What
47
+ // differs is what happens UNDER another track that moves the alpha, which
48
+ // is the consumer's composing rather than the object (CLAUDE.md). That
49
+ // file SKIPs here — it keys no `rgb` or `alpha` — and says so.
50
+ //
51
+ // ⚠️ The required values are parsed HERE, as A43's are, and the channel
52
+ // table is `timelines.ts`'s rather than the loaded timelines' property ids:
53
+ // a check that asked the parser what a timeline writes would agree with it
54
+ // whatever it did. A selftest control holds that table to the runtime's ids.
55
+ export function a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten({ fail, skip, stats }: Verdicts, facts: SlotColourFacts): void {
56
+ const NAME = 'A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN';
57
+ /** `rrggbb`, or `rrggbbaa` whose alpha an `rgb` key cannot pose. Null for anything else. */
58
+ const readRgbHex = (hex: unknown): [number, number, number] | null => {
59
+ if (typeof hex !== 'string') return null;
60
+ const body = hex.startsWith('#') ? hex.slice(1) : hex;
61
+ if (!/^[\da-fA-F]{6}([\da-fA-F]{2})?$/.test(body)) return null;
62
+ return [0, 2, 4].map((i) => Number.parseInt(body.slice(i, i + 2), 16) / 255) as [number, number, number];
63
+ };
64
+ /** Half a byte step for a hex channel; a stored `Float32` for an alpha `value`. */
65
+ const HEX_STEP = 1 / 510;
66
+ const FLOAT_STEP = 1e-6;
67
+ const off = (found: number, want: number, step: number): boolean => !Number.isFinite(found) || Math.abs(found - want) > step;
68
+ const show = (c: readonly number[]): string => c.map((n) => (Number.isFinite(n) ? n.toFixed(4) : 'NaN')).join(', ');
69
+
70
+ // -- the subjects, read off the FILE ---------------------------------
71
+ const subjects: Array<{ anim: string; slot: string; timelines: Record<string, unknown> }> = [];
72
+ for (const { animation: animName, slot: slotName, timelines } of facts.slotTimelines) {
73
+ if (Array.isArray(timelines.rgb) || Array.isArray(timelines.alpha)) {
74
+ subjects.push({ anim: animName, slot: slotName, timelines });
75
+ }
76
+ }
77
+ if (subjects.length === 0) return skip(NAME, SKIP_NO_SEPARABLE_COLOR);
78
+ stats.separableColorTimelines = subjects.reduce(
79
+ (n, s) => n + (Array.isArray(s.timelines.rgb) ? 1 : 0) + (Array.isArray(s.timelines.alpha) ? 1 : 0),
80
+ 0,
81
+ );
82
+
83
+ for (const { anim: animName, slot: slotName, timelines } of subjects) {
84
+ const at = `animation "${animName}" slot "${slotName}"`;
85
+ // In FILE order, which is the order `readAnimation` pushes them and so
86
+ // the order they apply in.
87
+ const colour = Object.keys(timelines).filter((name) => name in SLOT_COLOR_CHANNELS && Array.isArray(timelines[name]));
88
+
89
+ // -- clause 1: one channel, one timeline ---------------------------
90
+ let shared = false;
91
+ for (const channel of ['rgb', 'alpha', 'dark'] as const) {
92
+ const writers = colour.filter((name) => SLOT_COLOR_CHANNELS[name].includes(channel));
93
+ if (writers.length < 2 || !writers.some((name) => name === 'rgb' || name === 'alpha')) continue;
94
+ shared = true;
95
+ const last = writers[writers.length - 1];
96
+ fail(
97
+ NAME,
98
+ `${at}: ${writers.map((name) => `"${name}"`).join(' and ')} ${writers.length === 2 ? 'both' : 'all'} key the ${channel === 'rgb' ? 'light rgb' : channel === 'alpha' ? 'alpha' : 'dark colour'} ` +
99
+ `— each poses it at every time, its setup value before its first key included, so "${last}", which the ` +
100
+ `file states last, overwrites ${writers.length === 2 ? `"${writers[0]}"` : 'the others'} everywhere and ` +
101
+ 'those keys are read by nothing. Key each channel once: "rgb" and "alpha" on their own key times, or one "rgba"',
102
+ );
103
+ }
104
+ if (shared) continue;
105
+
106
+ // -- clause 2: posed as written ------------------------------------
107
+ //
108
+ // ⚠️ Two things this does NOT do, both measured rather than skipped:
109
+ // it does not read the loaded timeline's CLASS back, and it does not
110
+ // hold a channel the timeline leaves alone to the setup pose. Against
111
+ // the linked parser neither can fail — every `rgb` / `alpha` array that
112
+ // loads at all loads as an `RGBTimeline` / `AlphaTimeline`, and the
113
+ // three other outcomes (an empty array, a slot the skeleton lacks, a
114
+ // name outside the switch) throw at `A00` — so either would be a clause
115
+ // nobody can see fire. The selftest reads both off spine-core directly
116
+ // (`S82`–`S84`), where they are measurements of the runtime rather than
117
+ // checks on a file.
118
+ if (!facts.hasAnimation(animName)) {
119
+ fail(NAME, `${at}: the loaded skeleton has no animation "${animName}"`);
120
+ continue;
121
+ }
122
+ for (const name of ['rgb', 'alpha'] as const) {
123
+ const keys = timelines[name];
124
+ if (!Array.isArray(keys)) continue;
125
+ const where = `${at} ${name}`;
126
+ for (const rawKey of keys) {
127
+ if (!isObj(rawKey)) continue;
128
+ const time = typeof rawKey.time === 'number' ? rawKey.time : 0;
129
+ // At the key as the runtime stores it, not one float step before
130
+ // it — see `atStoredKey` (issue #771).
131
+ const posed = facts.posedSlot(animName, slotName, atStoredKey(time));
132
+ if (!posed) {
133
+ fail(NAME, `${where} (t=${time}): the posed skeleton has no slot "${slotName}" to read`);
134
+ continue;
135
+ }
136
+ const light = [posed.color.r, posed.color.g, posed.color.b, posed.color.a];
137
+ if (name === 'rgb') {
138
+ const stated = readRgbHex(rawKey.color);
139
+ if (stated === null) {
140
+ fail(
141
+ NAME,
142
+ `${where} (t=${time}): the key states color ${JSON.stringify(rawKey.color)} — an rgb key is six hex ` +
143
+ `digits — and the runtime poses (${show(light.slice(0, 3))})`,
144
+ );
145
+ } else if (light.slice(0, 3).some((n, i) => off(n, stated[i], HEX_STEP))) {
146
+ fail(
147
+ NAME,
148
+ `${where} (t=${time}): rgb posed (${show(light.slice(0, 3))}), the key states ` +
149
+ `${JSON.stringify(rawKey.color)} = (${show(stated)})`,
150
+ );
151
+ }
152
+ } else {
153
+ // `readTimeline1(…, 0, 1)`: an absent `value` IS 0, and an editor
154
+ // omits it there, so absence is read the parser's way rather than
155
+ // as a malformed key.
156
+ const stated = rawKey.value === undefined ? 0 : rawKey.value;
157
+ if (typeof stated !== 'number' || off(light[3], stated, FLOAT_STEP)) {
158
+ fail(
159
+ NAME,
160
+ `${where} (t=${time}): alpha posed ${show([light[3]])}, the key states value ${JSON.stringify(rawKey.value)}` +
161
+ (typeof stated !== 'number'
162
+ ? ' — an alpha key\'s value is a number'
163
+ : stated < 0 || stated > 1
164
+ ? ' — the runtime clamps a posed alpha to 0..1'
165
+ : ''),
166
+ );
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ }
@@ -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
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The atlas's pages, as the bodies that read them see them (issue #1025, step
3
+ * 4c of #380) — the census's F20, without `pma`, which the model document does
4
+ * not hold and a later cut supplies from the caller.
5
+ *
6
+ * Links nothing from the runtime: a spine-core `TextureAtlas` satisfies the
7
+ * shape structurally, which is how `validate()` supplies it, and the model
8
+ * side supplies the document's `pages` section (`../model/atlas_pages.ts`).
9
+ */
10
+
11
+ /** One page: its name, a path relative to the directory the atlas sits in. */
12
+ export interface AtlasPage {
13
+ readonly name: string;
14
+ }
15
+
16
+ /** The atlas, or `null` when there is none to read — the round trip refused it, or the model side read no document. */
17
+ export interface AtlasPageFacts {
18
+ readonly atlas: { readonly pages: readonly AtlasPage[] } | null;
19
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The atlas as the page rules read it (issue #1025, step 4c of #380) — the
3
+ * census's F20 and F21 whole: every page's name, declared size and `pma`, every
4
+ * region's rectangle and the page it sits on, and the region a name resolves
5
+ * to. A06, A19 and A27 read it; A17, which reads only the page names, keeps the
6
+ * narrower `./atlas_pages.ts`.
7
+ *
8
+ * ⭐ **The order is a fact, and it is the file's.** A06 and A27 print one line
9
+ * per offending page or region, and A06's overlap clause prints a pair in the
10
+ * order it meets them, so `pages` and `regions` hold the atlas's own order —
11
+ * pages as the file lists them, regions page by page in the file's order — and
12
+ * `findRegion` answers with the FIRST region of exactly that name, the one the
13
+ * runtime's lookup returns.
14
+ *
15
+ * Links nothing from the runtime: spine-core's `TextureAtlas` satisfies the
16
+ * shape structurally, which is how `validate()` supplies it. The model side
17
+ * supplies the document's `pages` section, with each page's `pma` given by its
18
+ * caller until the document states it (issue #1026) — `../model/atlas_regions.ts`.
19
+ */
20
+
21
+ /** One page: its name (a path relative to the directory the atlas sits in), the size it declares, and whether it claims premultiplied alpha. */
22
+ export interface AtlasRegionPage {
23
+ readonly name: string;
24
+ readonly width: number;
25
+ readonly height: number;
26
+ readonly pma: boolean;
27
+ }
28
+
29
+ /** One region: its name exactly as the atlas spells it, the rectangle in the page's texels, and the page it sits on. */
30
+ export interface AtlasRegionEntry {
31
+ readonly name: string;
32
+ readonly page: AtlasRegionPage;
33
+ readonly x: number;
34
+ readonly y: number;
35
+ readonly width: number;
36
+ readonly height: number;
37
+ readonly degrees: number;
38
+ readonly offsetX: number;
39
+ readonly offsetY: number;
40
+ readonly originalWidth: number;
41
+ readonly originalHeight: number;
42
+ }
43
+
44
+ /** The atlas, or `null` when there is none to read — the round trip refused it, or the model side read no document. */
45
+ export interface AtlasRegionFacts {
46
+ readonly atlas: {
47
+ readonly pages: readonly AtlasRegionPage[];
48
+ readonly regions: readonly AtlasRegionEntry[];
49
+ /** The first region of exactly this name, pages in order — or `null`. */
50
+ findRegion(name: string): AtlasRegionEntry | null;
51
+ } | null;
52
+ }