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.
Files changed (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1191 -0
  185. package/src/meshquality.ts +2051 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1444 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. 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);