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,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
+ }
@@ -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
+ }