rig-c 0.0.0-stage → 2.21.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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1191 -0
- package/src/meshquality.ts +2051 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1444 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
package/src/keys.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The other half of "the compiler never invents a value that is not in the
|
|
3
|
+
* spec": **the compiler never discards a value that is in the spec.**
|
|
4
|
+
*
|
|
5
|
+
* `src/compile.ts` reads an input object by naming the fields it knows — a
|
|
6
|
+
* literal list walked by a `copy` helper, or a run of `if (spec.x !== undefined)`
|
|
7
|
+
* lines. Either spelling asks the same question, *what does the emitter want?*,
|
|
8
|
+
* and neither ever asks the opposite one, *what does this file actually say?* So
|
|
9
|
+
* a key outside the known set was never looked at, never mentioned and never
|
|
10
|
+
* emitted, and the build exited 0 with every assertion green (issue #545).
|
|
11
|
+
*
|
|
12
|
+
* That is the input-side twin of the silence the whole tool exists to remove.
|
|
13
|
+
* A missing number is already a `CompileError` naming the field; an extra one
|
|
14
|
+
* was nothing at all — and the extra one is the worse of the two, because an
|
|
15
|
+
* invented value at least appears in the output where somebody can see it.
|
|
16
|
+
*
|
|
17
|
+
* ## What this module is, and what it is not
|
|
18
|
+
*
|
|
19
|
+
* It is one refusal and the near-miss search that makes the refusal a repair. It
|
|
20
|
+
* is **not** a schema: the known-key sets live beside the shapes they describe
|
|
21
|
+
* (`RIG_KEYS` in [`rig.ts`](rig.ts), `MOTION_KEYS` in [`motion.ts`](motion.ts)),
|
|
22
|
+
* because a set of key names two files away from its interface is a set that
|
|
23
|
+
* drifts from it. `CUR17` in `selftest.ts` derives each set from the interface's
|
|
24
|
+
* own source text and compares, so the pair cannot drift in silence, and `CUR18`
|
|
25
|
+
* refuses a declared key that occurs nowhere else in the tree — which is the
|
|
26
|
+
* exact shape `scaleYMode` had.
|
|
27
|
+
*
|
|
28
|
+
* ## Why the refusal is at parse time rather than at emit time
|
|
29
|
+
*
|
|
30
|
+
* The alternative considered was to record the keys the emitter actually
|
|
31
|
+
* touches and subtract them afterwards — no table at all, and therefore no
|
|
32
|
+
* drift. It is rejected for two measured reasons:
|
|
33
|
+
*
|
|
34
|
+
* - **It answers a different question.** `buildRigMesh` returns at
|
|
35
|
+
* `if (att.generator)` before reading `width`, `hull` or `edges`, so a
|
|
36
|
+
* recorder would refuse `"width"` on a generated mesh and accept it on an
|
|
37
|
+
* authored one. That makes the accepted key set a property of the file's own
|
|
38
|
+
* values rather than of the format, and the format's key set is what an
|
|
39
|
+
* agent authoring against it has to be told.
|
|
40
|
+
* - **It cannot reach what the emitter never visits.** The emit loop walks the
|
|
41
|
+
* rig's slots and skips one with no attachments before it reads `setup` —
|
|
42
|
+
* the blind spot issue #293 was lost in for three weeks and `parseMotionSpec`
|
|
43
|
+
* was written to remove. A recorder rebuilds it.
|
|
44
|
+
*/
|
|
45
|
+
import { CompileError } from './errors.ts';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Levenshtein distance. Only ever called on the losing side of a refusal, so the
|
|
49
|
+
* O(n*m) table is free and a cheaper heuristic (shared prefix, substring) would
|
|
50
|
+
* miss the commonest real case — a transposition or one wrong character in a
|
|
51
|
+
* hand-typed name.
|
|
52
|
+
*/
|
|
53
|
+
export function nameDistance(a: string, b: string): number {
|
|
54
|
+
const rows = a.length + 1;
|
|
55
|
+
const cols = b.length + 1;
|
|
56
|
+
let previous = new Array<number>(cols);
|
|
57
|
+
for (let j = 0; j < cols; j++) previous[j] = j;
|
|
58
|
+
for (let i = 1; i < rows; i++) {
|
|
59
|
+
const current = new Array<number>(cols);
|
|
60
|
+
current[0] = i;
|
|
61
|
+
for (let j = 1; j < cols; j++) {
|
|
62
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
63
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
|
|
64
|
+
}
|
|
65
|
+
previous = current;
|
|
66
|
+
}
|
|
67
|
+
return previous[cols - 1];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Up to five known names closest to the one that was not found. */
|
|
71
|
+
export function nearMisses(wanted: string, known: Iterable<string>): string[] {
|
|
72
|
+
const scored: Array<{ name: string; d: number }> = [];
|
|
73
|
+
for (const name of known) {
|
|
74
|
+
const d = nameDistance(wanted.toLowerCase(), name.toLowerCase());
|
|
75
|
+
// Half the name's length, floored at 2: "leg" must not suggest "arm", and a
|
|
76
|
+
// long name may still be recognisable through several typos.
|
|
77
|
+
if (d <= Math.max(2, Math.floor(wanted.length / 2))) scored.push({ name, d });
|
|
78
|
+
}
|
|
79
|
+
scored.sort((x, y) => x.d - y.d || (x.name < y.name ? -1 : 1));
|
|
80
|
+
return scored.slice(0, 5).map((s) => s.name);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Refuse every key of `node` that `known` does not carry.
|
|
85
|
+
*
|
|
86
|
+
* ⭐ **Every** one of them, in one message, rather than the first. The four keys
|
|
87
|
+
* issue #545 planted into a single constraint were four separate mistakes an
|
|
88
|
+
* author had made in one place, and a refusal that names one of them buys three
|
|
89
|
+
* more round trips through a compile that is not cheap.
|
|
90
|
+
*
|
|
91
|
+
* ⭐ The near miss is what makes this a repair rather than a lecture. Both sides
|
|
92
|
+
* are lower-cased before the distance is taken, so `ROTATE` is distance 0 from
|
|
93
|
+
* `rotate` and comes back first — a real key in the wrong case is the commonest
|
|
94
|
+
* of these and the one an author is least likely to spot by re-reading.
|
|
95
|
+
*
|
|
96
|
+
* `known` may be empty (a shape with no fields at all); the message then says so
|
|
97
|
+
* rather than printing `known: ` with nothing after it.
|
|
98
|
+
*/
|
|
99
|
+
export function refuseUnknownKeys(
|
|
100
|
+
node: Record<string, unknown>,
|
|
101
|
+
known: readonly string[],
|
|
102
|
+
where: string,
|
|
103
|
+
what: string,
|
|
104
|
+
): void {
|
|
105
|
+
const strays = Object.keys(node).filter((key) => !known.includes(key));
|
|
106
|
+
if (strays.length === 0) return;
|
|
107
|
+
const named = strays.map((key) => {
|
|
108
|
+
const near = nearMisses(key, known);
|
|
109
|
+
return near.length === 0 ? `"${key}"` : `"${key}" (did you mean ${near.map((n) => `"${n}"`).join(', ')}?)`;
|
|
110
|
+
});
|
|
111
|
+
throw new CompileError(
|
|
112
|
+
`${where}: ${what} has ${strays.length === 1 ? 'a key' : `${strays.length} keys`} this compiler does not read: ` +
|
|
113
|
+
`${named.join(', ')}. Nothing reads such a key, so it would be dropped from the emitted skeleton in silence — ` +
|
|
114
|
+
'fix the spelling or remove it. ' +
|
|
115
|
+
(known.length === 0 ? 'This shape has no fields at all.' : `Known here: ${[...known].sort().join(', ')}.`),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The largest float32, `3.4028234663852886e38`. The skeleton file is written at
|
|
121
|
+
* float32 precision (`f32` in `src/compile.ts` rounds every emitted number), so
|
|
122
|
+
* this is the largest magnitude a stated number can have and still come out of
|
|
123
|
+
* the emitter as a number.
|
|
124
|
+
*/
|
|
125
|
+
export const FLOAT32_MAX = 3.4028234663852886e38;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Refuse the first number in an input file that the skeleton file cannot carry
|
|
129
|
+
* — one that is not finite, or that is finite only as a double (issue #881).
|
|
130
|
+
*
|
|
131
|
+
* 🚨 The defect this closes was a green build. `JSON.parse` reads `1e309` as
|
|
132
|
+
* `Infinity`, the emitter wrote it, and `JSON.stringify(Infinity)` is `null`:
|
|
133
|
+
* a bone stated at `x: 1e309` built green on the overlay probe as `"x": null`
|
|
134
|
+
* and was read as 0, a value the spec never stated. `1e308` did exactly the same
|
|
135
|
+
* — it is a finite double, but every emitted number is rounded to a float32 and
|
|
136
|
+
* `Math.fround(1e308)` is `Infinity` — so the line is float32's range and not
|
|
137
|
+
* the double's: `3.4028234663852886e38` built green and was emitted as itself,
|
|
138
|
+
* the next double up that rounds past it (`3.4028235677973366e38`) was emitted
|
|
139
|
+
* as `null`. The predicate is therefore `Number.isFinite(Math.fround(n))`,
|
|
140
|
+
* which is `NaN`, both infinities and exactly that overflow.
|
|
141
|
+
*
|
|
142
|
+
* ⭐ It walks the file, not the emitter's route — `checkRigSpecKeys`'s design
|
|
143
|
+
* and its reason. Before this, a handful of readers in `compile.ts` carried a
|
|
144
|
+
* finite check of their own (vertex arrays, a path's lengths, a segments
|
|
145
|
+
* falloff, a slider's mapping, an event's payload), each right about its own
|
|
146
|
+
* field, and none of them was a rule. Measured when this was written, planting
|
|
147
|
+
* `1e309` at every numeric leaf of nine rig specs — 610 plants over 57 kinds
|
|
148
|
+
* of field: 19 kinds were refused by a rule of their own (30 of those 70
|
|
149
|
+
* refusals printing the Infinity as `null`, and some only by a later symptom
|
|
150
|
+
* such as *hull Infinity disagrees with the triangles*), 1 was caught only by
|
|
151
|
+
* the gate, and 37 built green with a `null` in them on at least one rig. One
|
|
152
|
+
* pass over every number the document holds cannot miss a reader added next
|
|
153
|
+
* month, and it runs before any of them — so a range rule further down (a
|
|
154
|
+
* radius that is positive, a weight in 0..1) never has to print `NaN`.
|
|
155
|
+
*
|
|
156
|
+
* 🔒 The report order is fixed: `Object.keys` of parsed JSON is the
|
|
157
|
+
* document's key order (integer-like keys first, as the language orders them)
|
|
158
|
+
* and arrays are visited by index, so the refusal names the same number on
|
|
159
|
+
* every run.
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ What it cannot see is a number spelled as something else. `"x": "NaN"`
|
|
162
|
+
* is a string; there is no number here to refuse, and that is
|
|
163
|
+
* `refuseValuesOfTheWrongType`'s question (issue #890), which the parsers ask
|
|
164
|
+
* before this walk on the rig spec and the manifest, and after the motion
|
|
165
|
+
* spec's own field checks.
|
|
166
|
+
*
|
|
167
|
+
* `place` turns a path into the words the refusal uses for it.
|
|
168
|
+
*/
|
|
169
|
+
export function refuseNumbersTheFileCannotCarry(
|
|
170
|
+
raw: unknown,
|
|
171
|
+
where: string,
|
|
172
|
+
place: (path: ReadonlyArray<string | number>) => string,
|
|
173
|
+
): void {
|
|
174
|
+
const visit = (node: unknown, path: Array<string | number>): void => {
|
|
175
|
+
if (typeof node === 'number') {
|
|
176
|
+
if (Number.isFinite(Math.fround(node))) return;
|
|
177
|
+
const why = Number.isNaN(node)
|
|
178
|
+
? ''
|
|
179
|
+
: Number.isFinite(node)
|
|
180
|
+
? ' This one is finite as a double and has no float32 but Infinity.'
|
|
181
|
+
: ' JSON has no spelling for Infinity: a literal past ±1.7976931348623157e308, such as 1e309, parses to it.';
|
|
182
|
+
throw new CompileError(
|
|
183
|
+
`${where}: ${place(path)} is ${String(node)}; a number in this file is finite at float32 precision, at most ` +
|
|
184
|
+
`±${String(FLOAT32_MAX)}, because the skeleton is written as float32 — past that it would be emitted as ` +
|
|
185
|
+
`Infinity, which JSON writes as null, a value the file never stated.${why}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (Array.isArray(node)) {
|
|
189
|
+
node.forEach((child, i) => visit(child, [...path, i]));
|
|
190
|
+
} else if (node !== null && typeof node === 'object') {
|
|
191
|
+
const record = node as Record<string, unknown>;
|
|
192
|
+
for (const key of Object.keys(record)) visit(record[key], [...path, key]);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
visit(raw, []);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A path as the file spells it: `.key`, `."odd key"`, `[3]`. */
|
|
199
|
+
export function dottedPath(path: ReadonlyArray<string | number>): string {
|
|
200
|
+
let out = '';
|
|
201
|
+
for (const step of path) {
|
|
202
|
+
if (typeof step === 'number') out += `[${step}]`;
|
|
203
|
+
else out += /^[A-Za-z_][A-Za-z0-9_]*$/.test(step) ? `${out === '' ? '' : '.'}${step}` : `${out === '' ? '' : '.'}${JSON.stringify(step)}`;
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The type a field of an input file holds — the vocabulary of the per-shape
|
|
210
|
+
* type tables (`RIG_TYPES` in [`rig.ts`](rig.ts), `MOTION_TYPES` in
|
|
211
|
+
* [`motion.ts`](motion.ts), `MANIFEST_TYPES` in [`types.ts`](types.ts)).
|
|
212
|
+
*
|
|
213
|
+
* The first twelve are **checked** by `refuseValuesOfTheWrongType`. The last
|
|
214
|
+
* six are named so that every key has a type, and are deliberately not checked
|
|
215
|
+
* here, because each already has an owner that says more than a type could:
|
|
216
|
+
*
|
|
217
|
+
* - `object`, `object[]`, `object[][]`, `map of object` — a nested shape. Its
|
|
218
|
+
* own keys are typed in its own row, and whether the container is the right
|
|
219
|
+
* kind of container is the refusal of the reader that walks it (a rig spec's
|
|
220
|
+
* `"bones"` that is not an array is *a rig spec needs a non-empty "bones"
|
|
221
|
+
* array*);
|
|
222
|
+
* - `enum` — a closed set of names. Every `enum` row has an entry in the
|
|
223
|
+
* shape's enum table (`RIG_ENUMS`, `MOTION_ENUMS`, `MANIFEST_ENUMS`, typed by
|
|
224
|
+
* `SpecEnumTable`), which either states the set — and
|
|
225
|
+
* `refuseValuesOutsideTheirSet` refuses a value outside it — or names the
|
|
226
|
+
* reader that refuses it where it reads it, with the names that exist listed
|
|
227
|
+
* (a slot's `blend`, a constraint's `type`);
|
|
228
|
+
* - `mixed` — a union of kinds whose owner decides by the value (`MotionKey.v`
|
|
229
|
+
* is numbers on `rotate`, a name on `attachment`, a member map on a group).
|
|
230
|
+
*/
|
|
231
|
+
export const SPEC_VALUE_TYPES = [
|
|
232
|
+
'number',
|
|
233
|
+
'string',
|
|
234
|
+
'boolean',
|
|
235
|
+
'number | null',
|
|
236
|
+
'string | null',
|
|
237
|
+
'number[]',
|
|
238
|
+
'number[] | null',
|
|
239
|
+
'string[]',
|
|
240
|
+
'number[][]',
|
|
241
|
+
'map of number[]',
|
|
242
|
+
'map of string[]',
|
|
243
|
+
'map of string | null',
|
|
244
|
+
'object',
|
|
245
|
+
'object[]',
|
|
246
|
+
'object[][]',
|
|
247
|
+
'map of object',
|
|
248
|
+
'enum',
|
|
249
|
+
'mixed',
|
|
250
|
+
] as const;
|
|
251
|
+
|
|
252
|
+
export type SpecValueType = (typeof SPEC_VALUE_TYPES)[number];
|
|
253
|
+
|
|
254
|
+
/** A shape's keys and the type each one holds. */
|
|
255
|
+
export type SpecTypeRow = Readonly<Record<string, SpecValueType>>;
|
|
256
|
+
|
|
257
|
+
/** One node of an input file that a key scan visited, as the type walk needs it. */
|
|
258
|
+
export interface ShapeVisit {
|
|
259
|
+
node: Record<string, unknown>;
|
|
260
|
+
/** The row of the type table this node's keys are typed by. */
|
|
261
|
+
shape: string;
|
|
262
|
+
/** The words a refusal uses for a path below this node — `x`, `weights[3][1]`, `color[2]`. */
|
|
263
|
+
name: (tail: ReadonlyArray<string | number>) => string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** What a JSON value is, in the words the type refusal uses: `a string "5"`, `an array [1]`, `null`. */
|
|
267
|
+
function typeFound(value: unknown): string {
|
|
268
|
+
if (value === null) return 'null';
|
|
269
|
+
const kind = Array.isArray(value) ? 'array' : typeof value === 'object' ? 'object' : typeof value;
|
|
270
|
+
const shown = JSON.stringify(value) ?? String(value);
|
|
271
|
+
return `${/^[aeiou]/.test(kind) ? 'an' : 'a'} ${kind} ${shown.length > 60 ? `${shown.slice(0, 57)}...` : shown}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A checked type as the refusal requires it: `a number`, `an array of numbers`, `a string or null`. */
|
|
275
|
+
const REQUIRED: Record<string, string> = {
|
|
276
|
+
number: 'a number',
|
|
277
|
+
string: 'a string',
|
|
278
|
+
boolean: 'a boolean (true or false)',
|
|
279
|
+
'number | null': 'a number or null',
|
|
280
|
+
'string | null': 'a string or null',
|
|
281
|
+
'number[]': 'an array of numbers',
|
|
282
|
+
'number[] | null': 'an array of numbers, or null',
|
|
283
|
+
'string[]': 'an array of strings',
|
|
284
|
+
'number[][]': 'an array of arrays of numbers',
|
|
285
|
+
'map of number[]': 'an object whose every value is an array of numbers',
|
|
286
|
+
'map of string[]': 'an object whose every value is an array of strings',
|
|
287
|
+
'map of string | null': 'an object whose every value is a string or null',
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/** The types `refuseValuesOfTheWrongType` checks — every one `REQUIRED` can word. */
|
|
291
|
+
export const CHECKED_SPEC_VALUE_TYPES: readonly SpecValueType[] = SPEC_VALUE_TYPES.filter((t) => REQUIRED[t] !== undefined);
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The first place under `value` that is not of `type`, and what was required
|
|
295
|
+
* there — or `null` when it is. An element fault names its own index, so an
|
|
296
|
+
* array of numbers with a string in slot 3 is refused at `[3]` rather than as
|
|
297
|
+
* a whole array.
|
|
298
|
+
*/
|
|
299
|
+
function wrongTypeAt(
|
|
300
|
+
value: unknown,
|
|
301
|
+
type: SpecValueType,
|
|
302
|
+
): { tail: Array<string | number>; value: unknown; required: string } | null {
|
|
303
|
+
const is = (v: unknown, t: SpecValueType): boolean => {
|
|
304
|
+
switch (t) {
|
|
305
|
+
case 'number':
|
|
306
|
+
return typeof v === 'number';
|
|
307
|
+
case 'string':
|
|
308
|
+
return typeof v === 'string';
|
|
309
|
+
case 'boolean':
|
|
310
|
+
return typeof v === 'boolean';
|
|
311
|
+
case 'number | null':
|
|
312
|
+
return v === null || typeof v === 'number';
|
|
313
|
+
case 'string | null':
|
|
314
|
+
return v === null || typeof v === 'string';
|
|
315
|
+
default:
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
const fault = (tail: Array<string | number>, v: unknown, t: SpecValueType) => ({ tail, value: v, required: REQUIRED[t] });
|
|
320
|
+
const list = (v: unknown, element: SpecValueType, whole: SpecValueType, tail: Array<string | number>) => {
|
|
321
|
+
if (!Array.isArray(v)) return fault(tail, v, whole);
|
|
322
|
+
for (const [i, item] of v.entries()) if (!is(item, element)) return fault([...tail, i], item, element);
|
|
323
|
+
return null;
|
|
324
|
+
};
|
|
325
|
+
const map = (v: unknown, each: SpecValueType, whole: SpecValueType) => {
|
|
326
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return fault([], v, whole);
|
|
327
|
+
const record = v as Record<string, unknown>;
|
|
328
|
+
for (const key of Object.keys(record)) {
|
|
329
|
+
const inner = wrongTypeAt(record[key], each);
|
|
330
|
+
if (inner !== null) return { ...inner, tail: [key, ...inner.tail] };
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
};
|
|
334
|
+
switch (type) {
|
|
335
|
+
case 'number':
|
|
336
|
+
case 'string':
|
|
337
|
+
case 'boolean':
|
|
338
|
+
case 'number | null':
|
|
339
|
+
case 'string | null':
|
|
340
|
+
return is(value, type) ? null : fault([], value, type);
|
|
341
|
+
case 'number[]':
|
|
342
|
+
return list(value, 'number', type, []);
|
|
343
|
+
case 'number[] | null':
|
|
344
|
+
return value === null ? null : list(value, 'number', type, []);
|
|
345
|
+
case 'string[]':
|
|
346
|
+
return list(value, 'string', type, []);
|
|
347
|
+
case 'number[][]': {
|
|
348
|
+
if (!Array.isArray(value)) return fault([], value, type);
|
|
349
|
+
for (const [i, row] of value.entries()) {
|
|
350
|
+
const inner = list(row, 'number', 'number[]', [i]);
|
|
351
|
+
if (inner !== null) return inner;
|
|
352
|
+
}
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
case 'map of number[]':
|
|
356
|
+
return map(value, 'number[]', type);
|
|
357
|
+
case 'map of string[]':
|
|
358
|
+
return map(value, 'string[]', type);
|
|
359
|
+
case 'map of string | null':
|
|
360
|
+
return map(value, 'string | null', type);
|
|
361
|
+
default:
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Refuse the first value in an input file whose JSON type is not the one its
|
|
368
|
+
* field holds (issue #890).
|
|
369
|
+
*
|
|
370
|
+
* 🚨 The defect this closes was a green build, and it sat directly under the
|
|
371
|
+
* rule #881 wrote. A number that is not a number is coerced by the arithmetic
|
|
372
|
+
* that reads it before any rule sees a number at all: measured on the root bone
|
|
373
|
+
* of the generated probes, `"x": "5"` built as `5`, `"rotation": "90"` as `90`,
|
|
374
|
+
* `"x": true` as `1`, `"x": [1]` as `1` and `"x": "NaN"` as `null`, every one
|
|
375
|
+
* green. The compiler was writing a value the spec never stated, or one it
|
|
376
|
+
* guessed.
|
|
377
|
+
*
|
|
378
|
+
* ⭐ It walks the nodes the key scan visited, not the emitter's route, and it
|
|
379
|
+
* reads the type table that sits beside the key table — so a key cannot be
|
|
380
|
+
* admitted by the scan and untyped here (a selftest control holds the two
|
|
381
|
+
* tables equal, and so does `satisfies` over the key table's own type). The
|
|
382
|
+
* refusal names the field, the value found and the type required:
|
|
383
|
+
* `bone "hip" x is a string "5"; a number is required`.
|
|
384
|
+
*
|
|
385
|
+
* 🔒 The report order is fixed — visits in the scan's order, keys in the
|
|
386
|
+
* document's order, arrays by index — so the refusal names the same value on
|
|
387
|
+
* every run.
|
|
388
|
+
*
|
|
389
|
+
* ⚠️ A JSON `null` is a value, not an absence. It is accepted only where the
|
|
390
|
+
* field's type says `null` (a slot's setup `attachment`, a stage stated absent);
|
|
391
|
+
* everywhere else it is refused as `null`, because the readers downstream
|
|
392
|
+
* treated it as whatever `null` coerces to in the arithmetic they happened to do
|
|
393
|
+
* — measured, a bone's `"rotation": null` built green with its bytes moved.
|
|
394
|
+
*/
|
|
395
|
+
export function refuseValuesOfTheWrongType(
|
|
396
|
+
visits: readonly ShapeVisit[],
|
|
397
|
+
types: Readonly<Record<string, SpecTypeRow>>,
|
|
398
|
+
where: string,
|
|
399
|
+
): void {
|
|
400
|
+
for (const visit of visits) {
|
|
401
|
+
const row = types[visit.shape];
|
|
402
|
+
if (row === undefined) throw new Error(`no type row for shape ${visit.shape} (a key scan visited a shape the type table does not have)`);
|
|
403
|
+
for (const key of Object.keys(visit.node)) {
|
|
404
|
+
const type = row[key];
|
|
405
|
+
// An unknown key is the key scan's refusal and it has already run; a
|
|
406
|
+
// shape without a scan (the cut manifest) tolerates keys it does not
|
|
407
|
+
// read, and this walk does not change what a shape accepts.
|
|
408
|
+
if (type === undefined) continue;
|
|
409
|
+
const wrong = wrongTypeAt(visit.node[key], type);
|
|
410
|
+
if (wrong === null) continue;
|
|
411
|
+
throw new CompileError(
|
|
412
|
+
`${where}: ${visit.name([key, ...wrong.tail])} is ${typeFound(wrong.value)}; ${wrong.required} is required`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Who refuses a value outside an `enum` row's closed set (issue #900).
|
|
420
|
+
*
|
|
421
|
+
* - `set` — the names the row accepts, refused from one place by
|
|
422
|
+
* `refuseValuesOutsideTheirSet`; `readAs` is what the readers did with any
|
|
423
|
+
* other value before the set was stated, which the refusal says, because
|
|
424
|
+
* "one of" alone does not tell an author that the build they had was wrong;
|
|
425
|
+
* - `owner` — the function that reads the value and refuses one outside its
|
|
426
|
+
* set by name there, listing the names that exist. It keeps its own sentence,
|
|
427
|
+
* which often says more than a set can (a first letter whose case is free, a
|
|
428
|
+
* row chosen by the value itself).
|
|
429
|
+
*/
|
|
430
|
+
export type SpecEnumRule = { readonly set: readonly string[]; readonly readAs: string } | { readonly owner: string };
|
|
431
|
+
|
|
432
|
+
/** The keys of a type row whose type is `enum`. */
|
|
433
|
+
type EnumKeysOf<R> = { [K in keyof R]: R[K] extends 'enum' ? K : never }[keyof R];
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* A type table's enum table: one entry per `enum` row, and nothing else.
|
|
437
|
+
*
|
|
438
|
+
* 🔒 `satisfies SpecEnumTable<typeof X_TYPES>` is what makes a fourth unowned
|
|
439
|
+
* enum a type error rather than a row nobody holds: a shape with an `enum` row
|
|
440
|
+
* must appear, with exactly its `enum` keys, and a key of another type cannot.
|
|
441
|
+
* A selftest control holds the same claim at runtime and reads each `owner`'s
|
|
442
|
+
* body for the key it is said to own.
|
|
443
|
+
*/
|
|
444
|
+
export type SpecEnumTable<T extends Readonly<Record<string, SpecTypeRow>>> = {
|
|
445
|
+
readonly [S in keyof T as [EnumKeysOf<T[S]>] extends [never] ? never : S]: { readonly [K in EnumKeysOf<T[S]>]: SpecEnumRule };
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Refuse the first value of an `enum` row whose enum table states a set and
|
|
450
|
+
* that is not in it (issue #900).
|
|
451
|
+
*
|
|
452
|
+
* 🚨 The defect this closes was a green build again, one step past #890's:
|
|
453
|
+
* the type walk does not check `enum` (a name's type says nothing about which
|
|
454
|
+
* names exist), and three rows had nobody holding the name — `boneIndexing`,
|
|
455
|
+
* a bone's `from.rotation` and the cut manifest's `mesh.kind`. `"foo"` and `5`
|
|
456
|
+
* were each read as something the spec never said, in silence.
|
|
457
|
+
*
|
|
458
|
+
* It runs after the type walk and over the same visits, so a key the scan
|
|
459
|
+
* refused is never judged here, and before every reader. A row whose table
|
|
460
|
+
* entry names an `owner` is passed over: that reader refuses it with its own
|
|
461
|
+
* sentence. The order is the type walk's — visits in the scan's order, keys in
|
|
462
|
+
* the document's — so the refusal names the same value on every run.
|
|
463
|
+
*/
|
|
464
|
+
export function refuseValuesOutsideTheirSet(
|
|
465
|
+
visits: readonly ShapeVisit[],
|
|
466
|
+
types: Readonly<Record<string, SpecTypeRow>>,
|
|
467
|
+
enums: Readonly<Record<string, Readonly<Record<string, SpecEnumRule>>>>,
|
|
468
|
+
where: string,
|
|
469
|
+
): void {
|
|
470
|
+
for (const visit of visits) {
|
|
471
|
+
const row = types[visit.shape];
|
|
472
|
+
if (row === undefined) throw new Error(`no type row for shape ${visit.shape} (a key scan visited a shape the type table does not have)`);
|
|
473
|
+
for (const key of Object.keys(visit.node)) {
|
|
474
|
+
if (row[key] !== 'enum') continue;
|
|
475
|
+
const rule = enums[visit.shape]?.[key];
|
|
476
|
+
if (rule === undefined) throw new Error(`${visit.shape}.${key} is an enum row with no entry in its enum table`);
|
|
477
|
+
if (!('set' in rule)) continue;
|
|
478
|
+
const value = visit.node[key];
|
|
479
|
+
if (typeof value === 'string' && rule.set.includes(value)) continue;
|
|
480
|
+
throw new CompileError(
|
|
481
|
+
`${where}: ${visit.name([key])} is ${JSON.stringify(value) ?? String(value)}; one of ` +
|
|
482
|
+
`${rule.set.map((name) => JSON.stringify(name)).join(', ')} — ${rule.readAs}`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
package/src/ladder.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The benchmark ladder: which official Spine example is which rung, and which
|
|
3
|
+
* file in it is the reference.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ This is a table and not a naming rule, because there is no naming rule.
|
|
6
|
+
* The obvious one — `examples/<name>/export/<name>-ess.json` — is wrong on four
|
|
7
|
+
* of the nine examples: `6-arcs` ships only a `-pro` export, `7-anticipation`'s
|
|
8
|
+
* skeleton is called `sack-pro` after its subject rather than its directory,
|
|
9
|
+
* and `1-weight-and-mass` and `8-follow-through` ship two skeletons each. A
|
|
10
|
+
* rule that is right five times out of nine is worse than a table, because the
|
|
11
|
+
* four failures look like missing files rather than like a wrong assumption.
|
|
12
|
+
*
|
|
13
|
+
* The rung ORDER is not the numeric order of the directories: rung 3 is the
|
|
14
|
+
* smallest skeleton in the corpus (3 bones, 2 slots, 2 animations) and is the
|
|
15
|
+
* first one attempted. `docs/LADDER.md` carries the order, the per-rung gating
|
|
16
|
+
* features and the status; this file carries only what `bench` has to resolve.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** `rung` counts towards the rung; `stretch` is reported and does not. */
|
|
20
|
+
export type RungRole = 'rung' | 'stretch';
|
|
21
|
+
|
|
22
|
+
export interface RungSkeleton {
|
|
23
|
+
/** Short label for the report, unique within the rung. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** File name inside the example's `export/` directory. */
|
|
26
|
+
file: string;
|
|
27
|
+
/** Atlas file name inside the same directory. */
|
|
28
|
+
atlas: string;
|
|
29
|
+
role: RungRole;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Rung {
|
|
33
|
+
/** What `bench <rung>` is spelled as. */
|
|
34
|
+
id: string;
|
|
35
|
+
/** Directory under `examples/`. */
|
|
36
|
+
example: string;
|
|
37
|
+
/** One line on what this rung is testing, from docs/SURVEY_2026-08-22.md part 4-1. */
|
|
38
|
+
gates: string;
|
|
39
|
+
skeletons: RungSkeleton[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const LADDER: readonly Rung[] = [
|
|
43
|
+
{
|
|
44
|
+
id: '1',
|
|
45
|
+
example: '1-weight-and-mass',
|
|
46
|
+
gates: 'translatex/translatey/shear bone timelines; bone setup length; a skeleton with zero animations (drop)',
|
|
47
|
+
skeletons: [
|
|
48
|
+
{ label: 'balls', file: '1-weight-and-mass-balls-ess.json', atlas: '1-weight-and-mass.atlas', role: 'rung' },
|
|
49
|
+
{ label: 'drop', file: '1-weight-and-mass-drop-ess.json', atlas: '1-weight-and-mass.atlas', role: 'rung' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: '2',
|
|
54
|
+
example: '2-the-12-principles',
|
|
55
|
+
gates: 'slot blend modes (4 additive + 4 multiply); bone inherit ≠ Normal',
|
|
56
|
+
skeletons: [
|
|
57
|
+
{ label: 'ess', file: '2-the-12-principles-ess.json', atlas: '2-the-12-principles.atlas', role: 'rung' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: '3',
|
|
62
|
+
example: '3-timing-and-spacing',
|
|
63
|
+
gates: 'nothing new — the smallest skeleton in the corpus, and the first rung to attempt',
|
|
64
|
+
skeletons: [
|
|
65
|
+
{ label: 'ess', file: '3-timing-and-spacing-ess.json', atlas: '3-timing-and-spacing.atlas', role: 'rung' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: '4',
|
|
70
|
+
example: '4-wave-principle',
|
|
71
|
+
gates: 'nothing structurally new — a volume test (9 bones, 9 slots, 3 animations, 470 bezier keys)',
|
|
72
|
+
skeletons: [{ label: 'ess', file: '4-wave-principle-ess.json', atlas: '4-wave-principle.atlas', role: 'rung' }],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: '5',
|
|
76
|
+
example: '5-squash-and-stretch',
|
|
77
|
+
gates: 'drawOrder timeline (first appearance); inherit: onlyTranslation; non-unit setup scale',
|
|
78
|
+
skeletons: [
|
|
79
|
+
{ label: 'ess', file: '5-squash-and-stretch-ess.json', atlas: '5-squash-and-stretch.atlas', role: 'rung' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: '6',
|
|
84
|
+
example: '6-arcs',
|
|
85
|
+
gates: 'transform constraints (first appearance, static); weighted meshes from authored geometry; mesh edges',
|
|
86
|
+
skeletons: [{ label: 'pro', file: '6-arcs-pro.json', atlas: '6-arcs.atlas', role: 'rung' }],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: '7',
|
|
90
|
+
example: '7-anticipation',
|
|
91
|
+
gates: 'physics timelines; a KEYED transform timeline; deform (first appearance); 20 physics constraints',
|
|
92
|
+
skeletons: [{ label: 'sack-pro', file: 'sack-pro.json', atlas: '7-anticipation.atlas', role: 'rung' }],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: '8',
|
|
96
|
+
example: '8-follow-through',
|
|
97
|
+
gates: 'nothing new — transform constraints and weighted meshes both arrived at rung 6',
|
|
98
|
+
skeletons: [
|
|
99
|
+
{ label: 'ball', file: '8-follow-through-pro-ball.json', atlas: '8-follow-through.atlas', role: 'rung' },
|
|
100
|
+
{ label: 'pendulum', file: '8-follow-through-pro-pendulum.json', atlas: '8-follow-through.atlas', role: 'rung' },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'spineboy',
|
|
105
|
+
example: 'spineboy',
|
|
106
|
+
gates: 'IK, events, bounding box, clipping, unweighted meshes — and scale: ess 18 bones/20 slots/8 animations, pro 67 bones/52 slots/11 animations',
|
|
107
|
+
skeletons: [
|
|
108
|
+
{ label: 'ess', file: 'spineboy-ess.json', atlas: 'spineboy.atlas', role: 'rung' },
|
|
109
|
+
// `-pro` is reported and does not count. It is a harder rig than the
|
|
110
|
+
// graduation exam itself, and folding it in would make the exam
|
|
111
|
+
// unpassable for a reason that has nothing to do with passing it.
|
|
112
|
+
{ label: 'pro', file: 'spineboy-pro.json', atlas: 'spineboy.atlas', role: 'stretch' },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
export function findRung(id: string): Rung | undefined {
|
|
118
|
+
return LADDER.find((r) => r.id === id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const RUNG_IDS: readonly string[] = LADDER.map((r) => r.id);
|