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,255 @@
1
+ /**
2
+ * A23, the body (issue #1025, step 4c of #380): a physics constraint that
3
+ * does nothing, quietly — at its setup pose, and on every physics key.
4
+ *
5
+ * Moved out of `src/validate.ts` whole: every clause is about the rig — the
6
+ * components a constraint drives, its four bounded setup values, its `fps`,
7
+ * every value a physics timeline keys and what switches a muted constraint on
8
+ * — and a document's constraint and timeline records state each. Nothing it
9
+ * reads is the encoding's. The values are the ones the runtime holds after
10
+ * its parse, through the facts (`../facts/constraints.ts`): a field left out
11
+ * at the parser's value, `massInverse` = `1 / mass`, `step` = `1 / fps`, a
12
+ * key's time and value as float32, a key's value as the pose field it lands
13
+ * in (`posed`), and the constraints a timeline naming none reaches.
14
+ */
15
+ import type { Verdicts } from '../harness.ts';
16
+ import type { ConstraintFacts, PhysicsSetup } from '../facts/constraints.ts';
17
+ import type { MeshFacts } from '../facts/mesh_attachments.ts';
18
+ import { noneKeysItsMixAbove0, PHYSICS_COMPONENTS, REST_OR_KEY_ITS_MIX, switchedOn } from '../constraint_words.ts';
19
+ import { SKIP_NO_PHYSICS_CONSTRAINT } from '../reasons.ts';
20
+ import {
21
+ PHYSICS_POSE_RULES,
22
+ physicsBasisFor,
23
+ physicsBasisSays,
24
+ physicsKeyRefusal,
25
+ physicsOutsideSays,
26
+ physicsRuleFor,
27
+ type PhysicsPoseRule,
28
+ } from '../../timelines.ts';
29
+
30
+ /**
31
+ * What A23 says about a SETUP pose outside its bound, per property.
32
+ *
33
+ * The predicate lives in `PHYSICS_POSE_RULES` and the sentence lives here, and
34
+ * the split is deliberate: the predicate is the thing the compiler and this file
35
+ * must not disagree about, while the sentence names what happens to THIS rig —
36
+ * "it is muted", "nothing pulls it back" — which is what the author acts on and
37
+ * is worth nothing to a compiler refusing a key. `PHYSICS_POSE_RULES` is the
38
+ * index, so a rule added there with no sentence here fails to type-check rather
39
+ * than printing `undefined`.
40
+ *
41
+ * ⚠️ These are the SETUP pose's sentences and nothing else, which matters on the
42
+ * two rows whose keyed bound is wider than their resting one — `mix` since issue
43
+ * #610 and `strength` since #727. A key of 0 on either is accepted, so "it is
44
+ * muted" and "nothing pulls it back" are read here by a rig that states the
45
+ * number at rest, and what a key is refused with is the row's own `why`.
46
+ *
47
+ * `animations` is how many animations the skeleton declares, and only the `mix`
48
+ * sentence reads it: that bound is the one a key can satisfy instead of the
49
+ * setup pose (`inertAtSetup`, issue #743), so the refusal has to say that the
50
+ * other half was looked for and how wide the search was. A rig with no animation
51
+ * at all then says "none of the 0 animations" rather than implying somebody
52
+ * keyed something.
53
+ *
54
+ * `rule` is the row the value was judged by, and every sentence reads it: the
55
+ * reason each prints is the row's `basis` arm for the value (issue #798), the
56
+ * same object the key's sentence quotes, so what the setup pose and a key say
57
+ * about one number is one text — and whether that reason is the runtime's
58
+ * arithmetic or rigc's call is stated rather than implied.
59
+ */
60
+ const SETUP_POSE_SAYS: Record<string, (pose: PhysicsSetup, animations: number, rule: PhysicsPoseRule) => string> = {
61
+ // Every reason below is the row's `basis` arm for the value (issue #798): an
62
+ // arithmetic arm names its expression, a behavioural one says refusing it is
63
+ // rigc's call and what the value does. ⚠️ Until then a setup mix BELOW 0 was
64
+ // told "it is muted", which it is not — [measured] it moves the bone by
65
+ // exactly −1× what +0.5 does — so the two ways out print two sentences.
66
+ mix: (pose, animations, rule) =>
67
+ `has mix ${pose.mix} and ${noneKeysItsMixAbove0(animations)}; ${setupBasisSays(rule, pose.mix)} — ${REST_OR_KEY_ITS_MIX}`,
68
+ mass: (pose, _animations, rule) => `has massInverse ${pose.massInverse}; mass must be ${rule.states} — ${setupBasisSays(rule, pose.massInverse)}`,
69
+ // Two arms, read off the row (issue #748): 0 is a constraint nothing pulls
70
+ // back and below 0 one that is pushed away, and the row's `why` — the key's
71
+ // sentence — quotes the second from the same object. The basis sentence ends
72
+ // on the arm's own words, which `T100` holds.
73
+ strength: (pose, _animations, rule) => `has strength ${pose.strength}; ${setupBasisSays(rule, pose.strength)}`,
74
+ // The bound is read off the row, so the setup pose and a key cannot state two
75
+ // intervals; the reason is the arm the value took, since the ends are inside (#794).
76
+ damping: (pose, _animations, rule) =>
77
+ `has damping ${pose.damping}; must be ${rule.states} — the per-step decay is \`damping ** (60 * step)\`, and ` +
78
+ setupBasisSays(rule, pose.damping),
79
+ };
80
+
81
+ /**
82
+ * The basis sentence for a setup value, or the bound itself where no arm holds
83
+ * — a pose value the parser handed over that none of the row's arms is about
84
+ * (a NaN, say), which has no reason to give and must not borrow another's.
85
+ */
86
+ function setupBasisSays(rule: PhysicsPoseRule, poseValue: number): string {
87
+ const arm = physicsBasisFor(rule, poseValue);
88
+ return arm === undefined ? physicsOutsideSays(rule, poseValue) : physicsBasisSays(arm);
89
+ }
90
+
91
+ // Every failure mode here is silent. The five component fields default to
92
+ // 0, so a constraint can drive nothing at all; `mix` 0 mutes it; `mass` 0
93
+ // becomes an infinite massInverse; and `damping` above 1 never settles, which
94
+ // on a mesh-driving bone means the canvas re-rasterises forever. 1 itself is
95
+ // inside the bound since issue #794: it never decays, which is finite and a
96
+ // choice, and what it costs a consumer's frame is the consumer's to judge.
97
+ //
98
+ // 🔑 **Two arms, one criterion.** The second arm below reads every physics
99
+ // TIMELINE key, and it is written against `PHYSICS_POSE_RULES` — the table
100
+ // this one is also written against, and the table `compileValueTrack`
101
+ // refuses a spec's own out-of-range number with. Three readings of one rule
102
+ // is how two of them come to disagree, so there is one (issue #610).
103
+ export function a23PhysicsConstraintEffective({ fail, skip, stats }: Verdicts, facts: ConstraintFacts, meshFacts: MeshFacts): void {
104
+ // ⟨subject⟩_⟨property⟩, and its two siblings already read this way: A36
105
+ // skips on "the skeleton declares no path constraint" and A37 on "no
106
+ // slider constraint", while this one passed over an empty filter (#580).
107
+ // The stat is written before the guard so a reader of a SKIP still sees
108
+ // the count that produced it.
109
+ stats.physicsConstraints = facts.constraints.filter((c) => c.physics !== undefined).length;
110
+ if (stats.physicsConstraints === 0) return skip('A23_PHYSICS_CONSTRAINT_EFFECTIVE', SKIP_NO_PHYSICS_CONSTRAINT);
111
+ const meshBoneNames = new Set<string>();
112
+ for (const mesh of meshFacts.meshes) meshBoneNames.add(mesh.slotBone);
113
+ for (const mesh of meshFacts.meshes) {
114
+ if (!mesh.weights) continue;
115
+ for (const vertex of mesh.weights) {
116
+ for (const { bone: index } of vertex) {
117
+ const bone = meshFacts.bones[index];
118
+ if (bone !== undefined) meshBoneNames.add(bone);
119
+ }
120
+ }
121
+ }
122
+ // --- which constraints an animation switches ON (issue #743) ----------
123
+ //
124
+ // 🔑 The setup pose is the rig AT REST, and one of the four bounds is a
125
+ // state rather than a break there: `PHYSICS_POSE_RULES` marks `mix`
126
+ // `inertAtSetup`, because `update` opens with `if (mix === 0) return;`
127
+ // (`PhysicsConstraint.js:109-111`) and nothing else in the pose has such a
128
+ // branch. So a constraint that rests muted and is keyed above 0 by an
129
+ // animation is a rig the runtime plays as authored, and refusing it would
130
+ // refuse a design: physics off at rest, switched on by the animation that
131
+ // needs it.
132
+ //
133
+ // 📏 Measured on a generated physics fixture, 36 steps at 60 fps with the
134
+ // constraint's own bone swung by its parent: resting at `mix` 0 with an
135
+ // animation keying `mix` to 1 poses the bone IDENTICALLY to the same rig
136
+ // resting at 1 (max |dx| 0.000000) and up to 7.771177 away from the twin
137
+ // that keys nothing — which poses identically to one keyed to 0 only
138
+ // (max |dx| 0.000000). Two states, and the file says which.
139
+ //
140
+ // ⚠️ The escape is the rule's own field rather than the word "mix": a
141
+ // setup `mass` of 0 is `massInverse` Infinity BEFORE anything plays, and
142
+ // [measured] at rest it reads NaN on every frame although an animation
143
+ // keys `mass` to 1. A key cannot rescue a value that has already broken
144
+ // the rig it is resting in.
145
+ //
146
+ // The keys are read by `switchedOn`, the one reading A36 and A37 share
147
+ // (issue #752), through the runtime's own accessor: the pose field is
148
+ // where the integrator reads the number, and for `mass` that is not the
149
+ // number the key states. It counts the unnamed global form through the
150
+ // timeline's reach and a Bezier segment's samples as well as its keys.
151
+ const unmuted = new Map<number, Set<string>>();
152
+ for (const rule of PHYSICS_POSE_RULES) {
153
+ if (!rule.inertAtSetup) continue;
154
+ const reached = switchedOn(
155
+ facts,
156
+ (timeline) => timeline.kind === 'physics' && timeline.word === rule.timeline,
157
+ 1,
158
+ (timeline, value) => rule.poseOk(timeline.posed === undefined ? value : timeline.posed(value)),
159
+ );
160
+ facts.constraints.forEach((one, index) => {
161
+ if (one.physics === undefined || !reached.has(index)) return;
162
+ const by = unmuted.get(index) ?? new Set<string>();
163
+ by.add(rule.timeline);
164
+ unmuted.set(index, by);
165
+ });
166
+ }
167
+ let mutedUntilKeyed = 0;
168
+ facts.constraints.forEach((constraint, index) => {
169
+ const physics = constraint.physics;
170
+ if (physics === undefined) return;
171
+ const where = `physics "${constraint.name}"`;
172
+ const components = PHYSICS_COMPONENTS.filter((k) => physics.components[k] > 0);
173
+ if (!components.length) {
174
+ fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} drives no component; it parses and does nothing`);
175
+ }
176
+ const pose = physics.setup;
177
+ // The four bounded fields, each judged by its `PHYSICS_POSE_RULES` row.
178
+ // The wording is per-field and stays so: "it is muted" and "nothing
179
+ // pulls it back" say what happens to THIS rig, which is what the author
180
+ // needs, and the shared table supplies the predicate rather than the
181
+ // sentence.
182
+ for (const rule of PHYSICS_POSE_RULES) {
183
+ if (rule.poseOk(pose[rule.field])) continue;
184
+ if (rule.inertAtSetup && unmuted.get(index)?.has(rule.timeline)) {
185
+ mutedUntilKeyed++;
186
+ continue;
187
+ }
188
+ const drivesAMesh = rule.timeline === 'damping' && meshBoneNames.has(physics.bone);
189
+ fail(
190
+ 'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
191
+ `${where} ${SETUP_POSE_SAYS[rule.timeline](pose, facts.animations, rule)}` +
192
+ (drivesAMesh ? ' — and this bone drives a mesh, so the canvas never rests' : ''),
193
+ );
194
+ }
195
+ if (physics.step <= 0 || !Number.isFinite(physics.step)) {
196
+ fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has step ${physics.step} (fps must be > 0)`);
197
+ }
198
+ });
199
+ // What the PASS would otherwise not say: this rig rests with physics off
200
+ // on that many constraints and an animation is what switches them on. A
201
+ // pass carries no detail — `passed` is a list of names — so `stats` is the
202
+ // channel that exists, and a number is what belongs in a line printed as
203
+ // `k=v`: naming every such constraint and the animations that reach it
204
+ // would be a paragraph on one line, and the rig where nobody meant it is
205
+ // the rig where the NUMBER is the surprise. Absent rather than 0 when
206
+ // nothing rests muted, so it appears only where it says something.
207
+ if (mutedUntilKeyed) stats.physicsMutedUntilKeyed = mutedUntilKeyed;
208
+
209
+ // --- the same criterion, on every physics timeline key (issue #610) ----
210
+ //
211
+ // The arm above reads the setup pose and, until this one existed, nothing
212
+ // else — complete while a motion spec could key `mix` and `reset` only,
213
+ // and incomplete from #593 on, when it became able to key all seven.
214
+ //
215
+ // 📏 Measured on the tree before this landed, one keyed value at a time on
216
+ // a generated physics rig, 24 steps at 60 fps: `mass: 0` reached
217
+ // `massInverse` Infinity and every offset NaN — named by
218
+ // `A10_NO_NAN_AFTER_STEPPING`, from the BONE, with no word about the
219
+ // animation, the constraint, the timeline or the key; `damping: 2` ran the
220
+ // x offset to −26,634 and the velocity to −1.55e6 and still climbing, with
221
+ // **zero** gate failures; `strength: 0` drifted monotonically with zero gate
222
+ // failures; `mix: 1.5` produced zero gate failures and an integration
223
+ // identical to `mix: 1`, because mix multiplies the finished offset onto
224
+ // the bone and never enters the solve.
225
+ //
226
+ // ⭐ The value is judged where the runtime keeps it, not where the file
227
+ // writes it: `posed` is the number the runtime's own accessor writes, so a
228
+ // `mass` key lands as its reciprocal and is then held to exactly the
229
+ // predicate the setup arm holds `massInverse` to.
230
+ let keysRead = 0;
231
+ for (const timeline of facts.timelines) {
232
+ // A `reset` timeline keys no value, and was never one of the seven the pose holds.
233
+ if (timeline.kind !== 'physics' || timeline.word === 'reset') continue;
234
+ const name = timeline.word;
235
+ const rule = physicsRuleFor(name);
236
+ // -1 is the global form: the timeline drives every physics constraint
237
+ // whose matching `…Global` flag is set, so there is no one name to give.
238
+ const target =
239
+ timeline.constraint === -1
240
+ ? 'every physics constraint'
241
+ : `physics "${facts.constraints[timeline.constraint]?.name ?? `#${timeline.constraint}`}"`;
242
+ for (const frame of timeline.frames) {
243
+ keysRead++;
244
+ if (rule === undefined) continue;
245
+ const value = frame.value;
246
+ const refusal = physicsKeyRefusal(rule, value, timeline.posed === undefined ? undefined : timeline.posed(value));
247
+ if (refusal === null) continue;
248
+ fail(
249
+ 'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
250
+ `animation "${timeline.animation}" ${target} ${name} key at t=${frame.time.toFixed(6)}s is ${refusal}`,
251
+ );
252
+ }
253
+ }
254
+ stats.physicsTimelineKeys = keysRead;
255
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A27, the body (issue #1025, step 4c of #380): the only region on a page is
3
+ * named for the page's file.
4
+ *
5
+ * Moved out of `src/validate.ts` unchanged but for what it reads: the regions
6
+ * are a fact (`../facts/atlas_regions.ts`). Why the second link of the
7
+ * attachment -> region -> file chain needs holding stays above the `check`
8
+ * call in `validate()`.
9
+ */
10
+ import { basename } from 'node:path';
11
+ import type { Verdicts } from '../harness.ts';
12
+ import type { AtlasRegionFacts } from '../facts/atlas_regions.ts';
13
+ import { SKIP_NO_ATLAS, SKIP_NO_ATLAS_REGION } from '../reasons.ts';
14
+
15
+ export function a27RegionNameMatchesPageFilename({ fail, skip }: Verdicts, { atlas }: AtlasRegionFacts): void {
16
+ if (!atlas) return skip('A27_REGION_NAME_MATCHES_PAGE_FILENAME', SKIP_NO_ATLAS);
17
+ if (atlas.regions.length === 0) return skip('A27_REGION_NAME_MATCHES_PAGE_FILENAME', SKIP_NO_ATLAS_REGION);
18
+ const perPage = new Map<string, number>();
19
+ for (const region of atlas.regions) perPage.set(region.page.name, (perPage.get(region.page.name) ?? 0) + 1);
20
+ for (const region of atlas.regions) {
21
+ // A real packer puts many regions on one page and the names stop matching
22
+ // filenames by design. Then this check has nothing to say, so it says
23
+ // nothing rather than something wrong.
24
+ if ((perPage.get(region.page.name) ?? 0) !== 1) continue;
25
+ const expected = basename(region.page.name).replace(/\.png$/i, '');
26
+ if (region.name !== expected) {
27
+ fail(
28
+ 'A27_REGION_NAME_MATCHES_PAGE_FILENAME',
29
+ `region "${region.name}" is the only region on page "${region.page.name}", whose basename is "${expected}"`,
30
+ );
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A28, the body (issue #1025, step 4c of #380): a ribbon's rows share their
3
+ * weights. An `archetype` rule.
4
+ *
5
+ * This is what makes "length without width" a property of the file rather than
6
+ * a hope. Both vertices of a row carry the same bones at the same weights, so
7
+ * whatever the chain does to one it does to the other and their separation can
8
+ * only rotate — the strip curves and stretches, and never gets fatter. Give one
9
+ * side a different weight and the strip develops a taper that grows with its
10
+ * travel, which is the sort of thing that reads as bad art rather than as a bug.
11
+ *
12
+ * Moved out of `src/validate.ts` whole: both clauses are about the rig — a
13
+ * document's ribbon states every vertex's bindings — and the row's shape is
14
+ * printed as the file holds it, each bone by its index in the roster and each
15
+ * weight as the runtime's float32. The guard on a skeleton the round trip did
16
+ * not load stays in `validate()`, which has no facts to hand over then.
17
+ */
18
+ import type { Verdicts } from '../harness.ts';
19
+ import type { MeshFacts } from '../facts/mesh_attachments.ts';
20
+ import type { RigInfo } from '../../types.ts';
21
+
22
+ export function a28RibbonRowsShareWeights({ fail, skip }: Verdicts, facts: MeshFacts | null, rig: RigInfo | undefined): void {
23
+ if (facts === null) return skip('A28_RIBBON_ROWS_SHARE_WEIGHTS', 'the skeleton did not load (A00 owns that failure)');
24
+ if (!rig) return skip('A28_RIBBON_ROWS_SHARE_WEIGHTS', 'no rig info (validating a bare directory), so no mesh is known to be a ribbon');
25
+ const kinds = rig.meshKinds;
26
+ if (!Object.values(kinds).includes('ribbon')) {
27
+ // Say WHICH kind of "no ribbon" this is. "declares no ribbon" is true of a
28
+ // cut with no mesh at all, of one whose meshes are authored geometry, and
29
+ // of one whose meshes are contours — and the last two are cases where a
30
+ // reader should know that a mesh went unmeasured on purpose.
31
+ const unpaired = Object.entries(kinds)
32
+ .filter(([, kind]) => kind === 'authored' || kind === 'contour' || kind === 'segments')
33
+ .map(([slot, kind]) => `"${slot}" (${kind})`);
34
+ return skip(
35
+ 'A28_RIBBON_ROWS_SHARE_WEIGHTS',
36
+ unpaired.length
37
+ ? `the rig "${rig.archetype}" declares no ribbon mesh on this cut — its mesh slot(s) ${unpaired.join(', ')} have no rows to pair: authored geometry is somebody else's topology, a contour is one silhouette loop, and a segments lattice has cells rather than cross rows`
38
+ : `the rig "${rig.archetype}" declares no ribbon mesh on this cut`,
39
+ );
40
+ }
41
+ for (const mesh of facts.meshes) {
42
+ if (!mesh.weights) continue;
43
+ // 🔗 A LINKED mesh's rows are its source's and are paired there. Skipped
44
+ // for A21's reason and with A21's failure mode behind it: this reads
45
+ // `meshKinds` at the LINK's slot, which says nothing about the geometry
46
+ // the link borrowed, so a link sitting in a ribbon's slot from another
47
+ // skin would be measured twice and a link to a NON-ribbon in a ribbon's
48
+ // slot would be measured as a strip it was never built as (issue #691).
49
+ if (mesh.link !== null) continue;
50
+ if (rig.meshKinds[mesh.slot] !== 'ribbon') continue;
51
+ const perVertex = mesh.weights;
52
+ if (perVertex.length % 2 !== 0) {
53
+ fail('A28_RIBBON_ROWS_SHARE_WEIGHTS', `ribbon "${mesh.name}" has ${perVertex.length} vertices; a strip has an even count`);
54
+ continue;
55
+ }
56
+ // Perimeter order: left row i is index i, right row i is its mirror.
57
+ const rows = perVertex.length / 2;
58
+ for (let i = 0; i < rows; i++) {
59
+ const left = perVertex[i];
60
+ const right = perVertex[perVertex.length - 1 - i];
61
+ const shape = (v: typeof left) => v.map((w) => `${w.bone}:${w.weight.toFixed(6)}`).join(',');
62
+ if (shape(left) !== shape(right)) {
63
+ fail(
64
+ 'A28_RIBBON_ROWS_SHARE_WEIGHTS',
65
+ `ribbon "${mesh.name}" row ${i} has [${shape(left)}] on one side and [${shape(right)}] on the other; its width would change with the chain`,
66
+ );
67
+ }
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * A33, the body (issue #1025, step 4c of #380): bounding boxes, clipping
3
+ * polygons and paths hold a real polygon, a path's curve lengths strictly
4
+ * increase, and a clip ends at a slot of the skeleton.
5
+ *
6
+ * These types are the same shape — a polygon and nothing else — and they
7
+ * fail the same ways, all silent:
8
+ *
9
+ * 1. **A missing or wrong `vertexCount`.** The parser reads
10
+ * `map.vertexCount << 1` and hands it to `readVertices` as the length to
11
+ * expect (`:552`, `:632`). `undefined << 1` is 0, so an omission makes
12
+ * the coordinate array read as a WEIGHTED run: it decodes numbers as
13
+ * bone counts and weights, and the attachment ends up with no vertices
14
+ * at all. Nothing throws, and neither type draws a pixel, so nothing
15
+ * downstream notices either.
16
+ * 2. **A weighted run that does not decode to that many vertices.** Same
17
+ * trap as a mesh's (A04), minus the uvs that would have caught it.
18
+ * 3. **A clipping `end` naming a slot that is not there.**
19
+ * `skeletonData.findSlot` returns null on a miss and `:626-627` assigns
20
+ * the null, so the clip does not end where it was told to — it runs to
21
+ * the bottom of the draw order and takes every slot below it with it.
22
+ * Checked on what the file states, because a null `endSlot` and an `end`
23
+ * that was never written are the same loaded object.
24
+ *
25
+ * Moved out of `src/validate.ts` clause by clause. The path clauses, the
26
+ * clip's end and a polygon's vertex count are about the rig — a document's
27
+ * polygon states its count and its vertices, a path its `closed` and
28
+ * `lengths`, a clip its `end` — and moved. The decode of a weighted run and
29
+ * the length of an unweighted array (the second failure above) are about the
30
+ * encoding: the document states the weighted form outright and names bones.
31
+ * They stay with the round trip, which hands their findings in as each
32
+ * polygon's `encoding`, printed where the body always printed them.
33
+ */
34
+ import type { Verdicts } from '../harness.ts';
35
+ import type { PolygonFacts } from '../facts/vertex_polygons.ts';
36
+
37
+ export function a33VertexAttachmentGeometry({ fail, skip }: Verdicts, { polygons, clipEnds, slots }: PolygonFacts): void {
38
+ const paths = polygons.filter((p) => p.path !== null);
39
+ for (const path of paths) {
40
+ const what = path.what;
41
+ const vertexCount = path.worldVerticesLength / 2;
42
+ if (vertexCount % 3 !== 0) {
43
+ fail(
44
+ 'A33_VERTEX_ATTACHMENT_GEOMETRY',
45
+ `${what} has ${vertexCount} vertices, which is not a multiple of 3 — a path is knots and Bezier ` +
46
+ 'handles read in groups of three, and `Utils.newArray(vertexCount / 3, 0)` accepts the fractional ' +
47
+ 'size without a word, so the curves straddle the knots',
48
+ );
49
+ continue;
50
+ }
51
+ // Curves: 3K + 1 chain points. An open path drops the first and last
52
+ // vertex (the end knots' outer handles), a closed one repeats the first.
53
+ const curves = path.path?.closed ? vertexCount / 3 : vertexCount / 3 - 1;
54
+ if (curves < 1) {
55
+ fail('A33_VERTEX_ATTACHMENT_GEOMETRY', `${what} has ${vertexCount} vertices, which is not one whole curve`);
56
+ continue;
57
+ }
58
+ // `lengths` is what a `constantSpeed: false` traversal measures with:
59
+ // `lengths[curve]` bounds each curve and the last entry IS the path
60
+ // length. The parser sizes the array from `vertexCount / 3` and copies
61
+ // whatever the file gave, so a short array leaves trailing zeros — and a
62
+ // zero-length curve makes the parser divide the position by it.
63
+ const lengths = path.path?.lengths ?? [];
64
+ let previous = 0;
65
+ for (let c = 0; c < curves; c++) {
66
+ const value = lengths[c];
67
+ if (!Number.isFinite(value) || value <= previous) {
68
+ fail(
69
+ 'A33_VERTEX_ATTACHMENT_GEOMETRY',
70
+ `${what} lengths[${c}] is ${String(value)}, and the entry before it was ${previous}. The array is the ` +
71
+ 'CUMULATIVE arc length at the end of each of the ' +
72
+ `${curves} curve(s), so it strictly increases; a value that does not means either a zero-length ` +
73
+ 'curve (the position is divided by it) or an array shorter than the geometry (the tail reads as 0)',
74
+ );
75
+ break;
76
+ }
77
+ previous = value;
78
+ }
79
+ }
80
+ const slotNames = new Set(slots);
81
+ let endsChecked = 0;
82
+ for (const { placeholder, slot: slotName, end } of clipEnds) {
83
+ endsChecked++;
84
+ if (typeof end !== 'string' || !slotNames.has(end)) {
85
+ fail(
86
+ 'A33_VERTEX_ATTACHMENT_GEOMETRY',
87
+ `clipping attachment "${placeholder}" on slot "${slotName}" ends at ${JSON.stringify(end)}, ` +
88
+ 'which is not a slot of this skeleton — the clip would run to the bottom of the draw order',
89
+ );
90
+ }
91
+ }
92
+ if (polygons.length === 0 && endsChecked === 0) {
93
+ return skip(
94
+ 'A33_VERTEX_ATTACHMENT_GEOMETRY',
95
+ 'the skeleton carries no bounding box, clipping attachment or path',
96
+ );
97
+ }
98
+ for (const { what, worldVerticesLength: length, encoding } of polygons) {
99
+ if (!Number.isInteger(length) || length < 6 || length % 2 !== 0) {
100
+ fail(
101
+ 'A33_VERTEX_ATTACHMENT_GEOMETRY',
102
+ `${what} loaded worldVerticesLength ${length}; a polygon is an even count of at least 6 (3 vertices). ` +
103
+ 'A missing "vertexCount" reads as 0 and takes the polygon with it',
104
+ );
105
+ continue;
106
+ }
107
+ // The vertex run's own coherence is the encoding's, kept with the round trip (the header).
108
+ for (const detail of encoding) fail('A33_VERTEX_ATTACHMENT_GEOMETRY', detail);
109
+ }
110
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A36, the body (issue #1025, step 4c of #380): a path constraint that
3
+ * follows nothing, quietly.
4
+ *
5
+ * 🚨 The first failure here is the quietest in the whole constraint half of
6
+ * the format. `PathConstraint.update` opens with
7
+ *
8
+ * const attachment = this.slot.appliedPose.attachment;
9
+ * if (!(attachment instanceof PathAttachment)) return;
10
+ *
11
+ * so a path constraint aimed at a slot that never shows a path loads
12
+ * perfectly, reports every mix it was given, appears in the update cache —
13
+ * and moves nothing, forever. Nothing in the file is wrong on its face: the
14
+ * slot exists, the constraint resolves, the mixes are 1.
15
+ *
16
+ * The rest are the same shape as A23's: a constraint that parses and does
17
+ * nothing. All three mixes at 0 is only a finding when no animation keys one
18
+ * of them above 0 (`switchedOn` over the `mix` timelines, judged by
19
+ * `mixLive`), and a chain with no bones on it is one whether or not anything
20
+ * is keyed.
21
+ *
22
+ * Moved out of `src/validate.ts` whole: the bones, the slot and the skins'
23
+ * paths, the setup mixes and the keyed ones are all the rig's, and a
24
+ * document's records state each.
25
+ */
26
+ import type { Verdicts } from '../harness.ts';
27
+ import type { ConstraintFacts } from '../facts/constraints.ts';
28
+ import { mixLive, noneKeysItsMixAbove0, switchedOn } from '../constraint_words.ts';
29
+
30
+ export function a36PathConstraintEffective({ fail, skip, stats }: Verdicts, facts: ConstraintFacts): void {
31
+ const constraints = facts.constraints.flatMap((c, index) => (c.path === undefined ? [] : [{ name: c.name, path: c.path, index }]));
32
+ if (!constraints.length) return skip('A36_PATH_CONSTRAINT_EFFECTIVE', 'the skeleton declares no path constraint');
33
+ /**
34
+ * Which path constraints an animation switches ON — `switchedOn` over their
35
+ * `mix` timelines, judged by `mixLive`, the predicate their setup pose is
36
+ * judged by below.
37
+ *
38
+ * ⭐ The reason this is needed: a constraint whose mixes are all 0 at setup
39
+ * is **the idiom**, not a defect — spineboy's aim rig is exactly that, and
40
+ * issue #88 landed the timelines that turn one on. So "muted" is only a
41
+ * finding when nothing turns it on, and that question lives in the
42
+ * animations rather than in the constraint.
43
+ *
44
+ * 🔑 "Turns it on" is a VALUE question, and until issue #752 this asked a
45
+ * weaker one than `A23` — whether a `mix` key array was non-empty — so a
46
+ * timeline keying 0 only was a rescue for a constraint it leaves exactly as
47
+ * muted as no timeline does. Now a path constraint is switched on by a key
48
+ * posing any of its three mixes above 0, which is the runtime's own
49
+ * condition: `PathConstraint.update` returns when `mixRotate`, `mixX` and
50
+ * `mixY` are all 0 (`PathConstraint.js:73-75`).
51
+ */
52
+ const pathSwitchedOn = switchedOn(facts, (timeline) => timeline.kind === 'path' && timeline.word === 'mix', 3, (_timeline, value) => mixLive(value));
53
+ /** The slots some skin gives a path attachment. */
54
+ const pathsBySlot = new Set(facts.pathSlots);
55
+ for (const { name, path: constraint, index } of constraints) {
56
+ const where = `path constraint "${name}"`;
57
+ if (!constraint.bones.length) {
58
+ fail('A36_PATH_CONSTRAINT_EFFECTIVE', `${where} constrains no bone; it parses and does nothing`);
59
+ }
60
+ const slot = constraint.slot;
61
+ if (!pathsBySlot.has(slot)) {
62
+ fail(
63
+ 'A36_PATH_CONSTRAINT_EFFECTIVE',
64
+ `${where} follows slot "${slot}", and no skin gives that slot a path attachment — ` +
65
+ "PathConstraint.update returns immediately unless the slot's attachment is a path, so this " +
66
+ 'constraint reports its mixes and moves nothing',
67
+ );
68
+ }
69
+ const pose = constraint.setup;
70
+ const muted = ![pose.mixRotate, pose.mixX, pose.mixY].some(mixLive);
71
+ if (muted && !pathSwitchedOn.has(index)) {
72
+ fail(
73
+ 'A36_PATH_CONSTRAINT_EFFECTIVE',
74
+ `${where} has mixRotate ${pose.mixRotate}, mixX ${pose.mixX} and mixY ${pose.mixY} at setup and ` +
75
+ `${noneKeysItsMixAbove0(facts.animations)}; update() returns on all-zero mixes, so nothing ever ` +
76
+ 'puts a bone on the path — rest one of the three above 0, or key its mix above 0 in an animation',
77
+ );
78
+ }
79
+ }
80
+ stats.pathConstraints = constraints.length;
81
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A37, the body (issue #1025, step 4c of #380): a slider that applies
3
+ * nothing, quietly.
4
+ *
5
+ * A slider is the only constraint that applies an ANIMATION, so its failure
6
+ * modes are about that animation rather than about a transform:
7
+ *
8
+ * 1. **An animation with no timelines.** `animation.apply` walks an empty
9
+ * array. The slider is in the update cache, its time moves, and the
10
+ * skeleton never changes.
11
+ * 2. **`loop` on a zero-length animation.** `Slider.update` computes
12
+ * `animation.duration + (p.time % animation.duration)` when looping, so
13
+ * a duration of 0 makes the time **NaN** — and it then applies the
14
+ * animation at NaN, which is a pose nobody can predict and no error.
15
+ * Only reachable with a bone, because that is the branch the loop
16
+ * arithmetic lives in.
17
+ * 3. **`scale` 0 with a bone.** `time = offset + (value - offset) * 0`, so
18
+ * the dial turns and the slider holds one frame.
19
+ * 4. **`mix` 0** with nothing keying it — the same rule as A36's.
20
+ *
21
+ * Moved out of `src/validate.ts` whole: the animation it applies (its
22
+ * timeline count and duration), its dial, `loop`, `scale` and mix, and the
23
+ * keys that raise its mix are the rig's, and a document's records state each.
24
+ * ⚠️ `scale` is read behind `bone` only, and that is where the one known
25
+ * disagreement between the suppliers lives: on a bone-less slider spine-core
26
+ * leaves `scale` at 0 and the document states 1 (#1027). Clause 3 never reads
27
+ * a bone-less slider's scale, so no line can differ on it.
28
+ */
29
+ import type { Verdicts } from '../harness.ts';
30
+ import type { ConstraintFacts } from '../facts/constraints.ts';
31
+ import { mixLive, noneKeysItsMixAbove0, REST_OR_KEY_ITS_MIX, switchedOn } from '../constraint_words.ts';
32
+
33
+ export function a37SliderConstraintEffective({ fail, skip, stats }: Verdicts, facts: ConstraintFacts): void {
34
+ const sliders = facts.constraints.flatMap((c, index) => (c.slider === undefined ? [] : [{ name: c.name, slider: c.slider, index }]));
35
+ if (!sliders.length) return skip('A37_SLIDER_CONSTRAINT_EFFECTIVE', 'the skeleton declares no slider constraint');
36
+ /** Which sliders an animation switches ON — `switchedOn` over their `mix` timelines, judged by `mixLive` (A36's ⭐ and 🔑; `Slider.update` returns when `mix` is 0, `Slider.js:53-54`). */
37
+ const sliderSwitchedOn = switchedOn(facts, (timeline) => timeline.kind === 'slider' && timeline.word === 'mix', 1, (_timeline, value) => mixLive(value));
38
+ for (const { name, slider, index } of sliders) {
39
+ const where = `slider "${name}"`;
40
+ const animation = slider.animation;
41
+ if (!animation) {
42
+ // The parser's second pass throws on a miss, so this is only reachable
43
+ // on an artifact that never went through it.
44
+ fail('A37_SLIDER_CONSTRAINT_EFFECTIVE', `${where} applies no animation`);
45
+ continue;
46
+ }
47
+ if (animation.timelines === 0) {
48
+ fail(
49
+ 'A37_SLIDER_CONSTRAINT_EFFECTIVE',
50
+ `${where} applies animation "${animation.name}", which carries no timeline at all; the slider runs and ` +
51
+ 'the skeleton never changes',
52
+ );
53
+ }
54
+ if (slider.bone && slider.loop && !(animation.duration > 0)) {
55
+ fail(
56
+ 'A37_SLIDER_CONSTRAINT_EFFECTIVE',
57
+ `${where} loops animation "${animation.name}", whose duration is ${animation.duration} — ` +
58
+ 'Slider.update computes `duration + (time % duration)` when looping, so the applied time is NaN',
59
+ );
60
+ }
61
+ if (slider.bone && slider.scale === 0) {
62
+ fail(
63
+ 'A37_SLIDER_CONSTRAINT_EFFECTIVE',
64
+ `${where} drives off bone "${slider.bone}" with scale 0, so the property cannot move the slider's time`,
65
+ );
66
+ }
67
+ const mix = slider.mix;
68
+ if (!mixLive(mix) && !sliderSwitchedOn.has(index)) {
69
+ fail(
70
+ 'A37_SLIDER_CONSTRAINT_EFFECTIVE',
71
+ `${where} has mix ${mix} at setup and ${noneKeysItsMixAbove0(facts.animations)}; update() returns ` +
72
+ `on mix 0 — ${REST_OR_KEY_ITS_MIX}`,
73
+ );
74
+ }
75
+ }
76
+ stats.sliderConstraints = sliders.length;
77
+ }