rig-c 0.0.0-stage → 2.20.4

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 +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -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/rig.ts ADDED
@@ -0,0 +1,2941 @@
1
+ /**
2
+ * The rig spec — `"spec": "rigc-rig/1"`. The skeleton as **data**.
3
+ *
4
+ * Until this file existed the bone tree and the slot table were code: three
5
+ * hard-coded formations in `src/archetype.ts`, a slot outside their tables a
6
+ * compile error, and therefore **no skeleton anybody else owns could be stated
7
+ * at all**. That was blocker B1 of [docs/LADDER.md](../docs/LADDER.md), and it
8
+ * gated every rung of the benchmark ladder.
9
+ *
10
+ * ## The vocabulary is Spine's
11
+ *
12
+ * ⭐ Wherever rigc has no better abstraction, this format uses **Spine 4.3's own
13
+ * concept and its own field name, with Spine's own default**, so that an agent
14
+ * that has read Spine's documentation can author a rig here without learning a
15
+ * second vocabulary. `bones[]` is Spine's bone list; `slots[]` is Spine's slot
16
+ * list and its array order is the draw order; `skins` holds Spine's placeholder
17
+ * → attachment maps; `constraints[]` is 4.3's single typed constraint array.
18
+ * Field lists below cite `SkeletonJson.ts` line numbers, which
19
+ * [docs/SPEC_COVERAGE.md](../docs/SPEC_COVERAGE.md) part 1 enumerates in full.
20
+ *
21
+ * Everything rigc adds sits **on top** of that vocabulary and is namespaced so a
22
+ * reader can see where Spine stops:
23
+ *
24
+ * - `from` on a bone — take this bone's setup position from the cut manifest
25
+ * (an anchor, a part window, a mesh centre) instead of writing a literal that
26
+ * would drift away from the measured art.
27
+ * - `generator` on a mesh attachment — build the geometry with one of the
28
+ * builders in `src/mesh.ts` instead of authoring vertex arrays by hand.
29
+ * - `image` on an attachment — name a PNG and let rigc **measure** it, rather
30
+ * than restating a `width`/`height` that can silently disagree with the file
31
+ * (SPEC_COVERAGE part 1-6: a missing `width` loads as `NaN`, with no error).
32
+ * - `invariants` — the structural facts skeleton JSON cannot state about itself,
33
+ * which the validator's archetype assertions read. Nothing in the file says
34
+ * "this bone carries the cut's axis" or "this parentage is forbidden".
35
+ *
36
+ * ## What a field's PRESENCE means
37
+ *
38
+ * 🔑 **A field is emitted exactly when the spec declares it.** Not "when it
39
+ * differs from the default" — Spine's own exporter omits defaults, but rigc
40
+ * cannot, because a rig may need to say `x: 0` out loud (the overlay formation's
41
+ * handle bone does) and because deciding emission from the *value* makes the
42
+ * emitted file depend on arithmetic rather than on what the author wrote. Omit a
43
+ * field and Spine's default stands; write it and it is in the file. A bone whose
44
+ * position comes `from` the manifest counts as declaring `x` and `y`, because
45
+ * the manifest declared them.
46
+ *
47
+ * ## What this format does NOT own
48
+ *
49
+ * rigc joins three files and each owns a domain:
50
+ *
51
+ * - the **cut manifest** owns measured geometry — crop, part offsets and sizes,
52
+ * mask polygons, the state machine, anchors, the axis, the measured ceilings;
53
+ * - the **rig spec** (this file) owns skeleton structure — bones, slots, skins,
54
+ * constraints, and the invariants;
55
+ * - the **motion spec** owns time — named easings, groups, setup pose, the
56
+ * physics tuning table, and the animations.
57
+ *
58
+ * A cut compiled from all three declares its attachments in the manifest (see
59
+ * `slots` below for the join rule) and leaves `skins` empty. A foreign skeleton
60
+ * with no manifest at all declares them here.
61
+ */
62
+ import { ikShapeFault } from './assertions/bodies/a47.ts';
63
+ import { CompileError, NotImplementedError } from './errors.ts';
64
+ import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
65
+ import type { ShapeVisit, SpecEnumTable, SpecValueType } from './keys.ts';
66
+
67
+ export { CompileError, NotImplementedError };
68
+
69
+ /** The only version this compiler reads. */
70
+ export const RIG_SPEC_VERSION = 'rigc-rig/1';
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // skeleton header — `root.skeleton` (SkeletonJson.ts:75-87)
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * The stage and the runtime hints, all optional.
78
+ *
79
+ * 🔁 The four box fields here are rigc's **stage** — the working area the art
80
+ * was painted in, the frame the coordinate transform and `A14`/`A19` read —
81
+ * and not the Spine header's box of the same names, which the format defines
82
+ * as the setup-pose bounding box. Since issue #907 `build` writes that box
83
+ * into the header, computed from the rig (`headerBoundsOf` in
84
+ * `src/compile.ts`), and states the stage in `skeleton.model.json`.
85
+ *
86
+ * `x`/`y` default to 0 and `width`/`height` fall back to the cut manifest's crop
87
+ * when there is one. With neither a manifest nor a declaration here the compile
88
+ * fails by name: `width`/`height` are what `A14_NO_FULL_FRAME_MESH` and
89
+ * `A19_OVERLAY_PNGS_HAVE_ALPHA` measure against, and a guessed stage is a gate
90
+ * that measures against a number nobody wrote down.
91
+ *
92
+ * ⭐ **`width: null, height: null` is the third state: this skeleton declares no
93
+ * stage** (issue #578). Omitting them is silence and stays a refusal by name;
94
+ * stating them `null` is a claim, and the emitted header then carries none of
95
+ * `x`/`y`/`width`/`height` — which is what an editor export of a skeleton whose
96
+ * bounds were never set looks like, and what a transcriber of one has to be able
97
+ * to write down. `null` is this spec's spelling for a stated absence everywhere
98
+ * else it has one (`RigSlot.attachment` = "show nothing", the cut manifest's
99
+ * `image` = "this cut does not carry the part"), so it is the spelling here too
100
+ * and no new key is introduced: the pair already exists, and only a third value
101
+ * of it is new.
102
+ *
103
+ * Two shapes are refused rather than interpreted, both in `parseRigSpec`:
104
+ * stating one of the pair `null` and the other a number (a stage with one
105
+ * extent is not a stage, and guessing which half was meant is inventing), and
106
+ * stating `x` or `y` alongside the absence (an origin for a box that is not
107
+ * there). ⚠️ A stated absence also beats a cut manifest's `crop`, for the reason
108
+ * a stated `width` already does: the rig spec is where a claim about the
109
+ * skeleton is made, and the manifest is a record of what the art measured.
110
+ *
111
+ * `spine` is not here: rigc emits its own version label and `A16` re-checks it.
112
+ * `hash` is not here either — it is the editor's change-detection token and
113
+ * inventing one would be claiming an export this file did not come from.
114
+ */
115
+ export interface RigSkeletonHeader {
116
+ x?: number;
117
+ y?: number;
118
+ /** A number, or `null` with `height` for "this skeleton declares no stage". */
119
+ width?: number | null;
120
+ /** A number, or `null` with `width` for "this skeleton declares no stage". */
121
+ height?: number | null;
122
+ /** Nonessential; `SkeletonData.fps` stays 30 when absent. */
123
+ fps?: number;
124
+ /** 4.2+; the runtime's physics/scale reference. Parser default 100. */
125
+ referenceScale?: number;
126
+ /**
127
+ * Nonessential: where the editor's import looks for the part images, as a path
128
+ * from the skeleton file. Declared here it is carried through verbatim; absent,
129
+ * rigc writes the path from `--out` to the one directory the spec names every
130
+ * part PNG in — `--out` itself, spelled `../<its basename>/`, under
131
+ * `--copy-images`, which moved them beside the skeleton and overrides a
132
+ * declaration for the same reason it rewrites the atlas's page names (the
133
+ * editor drops a literal `./` on import; a named directory it keeps). Parts
134
+ * spread over several directories have no single true path, so nothing is
135
+ * written (issue #370).
136
+ */
137
+ images?: string;
138
+ /**
139
+ * Nonessential: where the editor looks for the skeleton's audio files, as a
140
+ * path from the skeleton file — or `null`, which is what an editor export
141
+ * writes when no audio folder is set. Carried verbatim, `null` included, and
142
+ * written only when stated: rigc has no audio to point at, so this is a value
143
+ * a spec states or does not (issue #716 — every one of the twelve editor
144
+ * exports under `examples/` writes `"audio": null`, and `ingest` carries it).
145
+ */
146
+ audio?: string | null;
147
+ /**
148
+ * Carry the stage in the Spine files as a bounding-box attachment — opt-in,
149
+ * and absent changes no emitted byte (issue #1168). See `RigStageBox`.
150
+ */
151
+ stageBox?: RigStageBox;
152
+ }
153
+
154
+ /**
155
+ * Where the stage travels in the shipped Spine files: one bounding-box
156
+ * attachment `build` writes from the stage, never from numbers typed here
157
+ * (issue #1168).
158
+ *
159
+ * ⭐ **Why it is needed.** Since issue #907 the header's `x`, `y`, `width`,
160
+ * `height` are the setup-pose bounding box — what the format says they are —
161
+ * and the stage is stated in `skeleton.model.json`. A consumer that ships
162
+ * `skeleton.json`, the atlas and its pages and nothing else has no stage to
163
+ * fit the rig to. A key the format does not define would be read by no
164
+ * runtime and dropped by the editor without a word; a bounding box is returned
165
+ * by every runtime by slot and name, in JSON and in binary.
166
+ *
167
+ * The box's four vertices are the stage's corners in Spine world — `(x, y)`,
168
+ * `(x + width, y)`, `(x + width, y + height)`, `(x, y + height)`, the
169
+ * bottom-left first and counter-clockwise, y up — written unweighted in the
170
+ * slot bone's space. Its numbers come from the stage alone, so they cannot
171
+ * drift from the frame the coordinate transform reads.
172
+ *
173
+ * 🔒 **The rules, each refused by name in `compile`:**
174
+ *
175
+ * - `slot` is a slot `slots` declares, and nothing else fills it — no skin
176
+ * entry and no manifest part. The box is the slot's one attachment, filed
177
+ * in the `default` skin under `attachment`. Its setup pose is the slot's
178
+ * own, stated the way any slot's is (the rig slot's `attachment`, or the
179
+ * motion spec's `setup`) — name the box there for a slot that shows it at
180
+ * setup, which is what a reader of the slot's current attachment sees.
181
+ * - The slot hangs on the **root**, and the root states no setup
182
+ * transform — no `x`, `y`, `rotation`, scale or shear. The vertices are
183
+ * then the stage's numbers themselves, which a reader of the attachment
184
+ * data gets without posing anything. A bone below the root is refused
185
+ * even when it states nothing: the runtime spells an unrotated frame's
186
+ * `b` as cos 90° at its pi (−2.3e-8), once per level, so the posed box
187
+ * lands on the stage through the root's frame — the residue the header's
188
+ * own box already carries — and drifts further at every level below it.
189
+ * A constraint that moves the root at setup is not seen by `compile`,
190
+ * which poses none; `A50_STAGE_BOX_IS_THE_STAGE` names it.
191
+ * - The rig declares a stage. Asking for its box with `"width": null,
192
+ * "height": null` asks for a box around nothing.
193
+ *
194
+ * ⚠️ The claim is the setup pose's. An animation that keys the root moves the
195
+ * box with it, as it moves anything else on the root; the stage is the
196
+ * attachment's vertices, read at setup or straight off the data.
197
+ */
198
+ export interface RigStageBox {
199
+ /** The slot the box goes in, declared in `slots`; its `bone` is the box's bone. */
200
+ slot: string;
201
+ /** The box's attachment name — its placeholder in the `default` skin and its `Attachment.name`. */
202
+ attachment: string;
203
+ }
204
+
205
+ /**
206
+ * Does this header state that the skeleton has no stage?
207
+ *
208
+ * One reading of the spelling, exported so that the compiler, the emitter and
209
+ * anything that grows a third opinion later read it the same way. `parseRigSpec`
210
+ * has already refused the half-stated shapes by the time this is asked, so the
211
+ * two `null`s travel together.
212
+ */
213
+ export function declaresNoStage(header: RigSkeletonHeader | undefined): boolean {
214
+ return header !== undefined && header.width === null && header.height === null;
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // bones — `root.bones[]` (SkeletonJson.ts:90-118)
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * `BoneData.ts:80`. Resolved by `Utils.enumValue`, which upper-cases the first
223
+ * letter, so `"noScale"` and `"NoScale"` both load; rigc accepts either and
224
+ * emits the lower-camel spelling the editor writes.
225
+ *
226
+ * ⚠️ 4.0/4.1 called this field `transform`. That name still *loads* in 4.3 and
227
+ * the inheritance silently falls back to Normal — assertion `A02`.
228
+ */
229
+ export type RigBoneInherit = 'normal' | 'onlyTranslation' | 'noRotationOrReflection' | 'noScale' | 'noScaleOrReflection';
230
+
231
+ export const RIG_BONE_INHERIT: readonly RigBoneInherit[] = [
232
+ 'normal',
233
+ 'onlyTranslation',
234
+ 'noRotationOrReflection',
235
+ 'noScale',
236
+ 'noScaleOrReflection',
237
+ ];
238
+
239
+ /**
240
+ * The mode a spelling of `inherit` resolves to, in the table's own spelling —
241
+ * or `undefined` for one the runtime cannot resolve.
242
+ *
243
+ * ⭐ **One rule for both places the format spells a mode**: a bone's setup
244
+ * `inherit` and an `inherit` timeline key are read by the same call,
245
+ * `Utils.enumValue(Inherit, name)`, which is `Inherit[name[0].toUpperCase() +
246
+ * name.slice(1)]` — the FIRST letter is folded and nothing else. So `noScale`
247
+ * and `NoScale` resolve and `NOSCALE` or `noscale` do not, and a spelling that
248
+ * misses loads as `undefined`: the setup pose holds no mode at all and a
249
+ * timeline frame holds NaN, and in both cases `updateWorldTransform`'s switch
250
+ * matches no case and leaves the world matrix where it was. Nothing throws.
251
+ *
252
+ * 🚨 The setup check was **case-insensitive** until issue #733, which is wider
253
+ * than the runtime's rule by exactly that silence: `"inherit": "NOSCALE"`
254
+ * compiled, gated green on all 45 assertions, and loaded `setupPose.inherit ===
255
+ * undefined`. Measured, not argued — and a key read through the same wide rule
256
+ * would have shipped the same spelling into a timeline.
257
+ */
258
+ export function resolveBoneInherit(value: unknown): RigBoneInherit | undefined {
259
+ if (typeof value !== 'string' || value.length === 0) return undefined;
260
+ const folded = value[0].toLowerCase() + value.slice(1);
261
+ return RIG_BONE_INHERIT.find((mode) => mode === folded);
262
+ }
263
+
264
+ /**
265
+ * The value REQUIRED, as both refusals of an unresolvable mode print it: the
266
+ * five, and the one liberty the runtime's lookup allows.
267
+ */
268
+ export const BONE_INHERIT_KNOWN =
269
+ `known: ${RIG_BONE_INHERIT.join(', ')} — the runtime folds the case of the first letter and of nothing else`;
270
+
271
+ /**
272
+ * Take a bone's setup transform from the cut manifest rather than from a literal.
273
+ *
274
+ * ⭐ This is the one place the rig spec deliberately does not mirror Spine, and
275
+ * the reason is the oldest rule in this project: **the compiler never re-measures
276
+ * art, and a measured number lives in exactly one file.** A rig that wrote
277
+ * `x: 456.5` would be a second copy of a part offset the manifest already holds,
278
+ * and the two would drift the first time the art moved — silently, because both
279
+ * files would still be valid.
280
+ *
281
+ * Exactly one of `anchor` / `slotWindow` / `meshCenter` may be given, and it
282
+ * supplies the bone's `x` and `y`. All three name a point in **crop pixels, y
283
+ * down**; the compiler converts it to Spine world (y up, origin at the crop's
284
+ * bottom-left) and then into the parent bone's local space, so a rotated parent
285
+ * is handled by the same inverse the mesh binder uses.
286
+ */
287
+ export interface RigBoneFrom {
288
+ /** A key of the manifest's `anchors` block: `[x, y]` or `[x, y, facing_deg]`. */
289
+ anchor?: string;
290
+ /** The centre of a manifest part's window, named by the rig slot it fills. */
291
+ slotWindow?: string;
292
+ /** A manifest part's `mesh.center` — the aperture a ring deforms about. */
293
+ meshCenter?: string;
294
+ /**
295
+ * Where the setup rotation comes from. Omit and no rotation is emitted.
296
+ *
297
+ * `axis` — the manifest's `axis.deg`, negated into Spine's y-up CCW. This
298
+ * is the keystone of an articulated cut: the stroke is a
299
+ * translateX along this bone, so a sibling cut at another camera
300
+ * angle changes one number instead of every key.
301
+ * `anchor` — the third element of the named anchor, a screen-space facing
302
+ * angle. A grip whose local +X points radially outward turns
303
+ * "expand the ring" into one shared translate key.
304
+ */
305
+ rotation?: 'axis' | 'anchor';
306
+ }
307
+
308
+ /**
309
+ * One bone. Spine's field set, Spine's defaults (`SkeletonJson.ts:90-118`).
310
+ *
311
+ * `parent` is resolved by name and **must be declared earlier in the array** —
312
+ * the parser resolves against the bones it has already read, so a forward
313
+ * reference is not a rigc restriction.
314
+ */
315
+ export interface RigBone {
316
+ name: string;
317
+ /** Omitted only by the skeleton's single root bone. */
318
+ parent?: string;
319
+ /**
320
+ * Default 0. Drawing reads it nowhere, but a physics constraint driving
321
+ * `rotate`, `shearX` or `scaleX` on this bone steps off its tip — `length`
322
+ * along the bone's x axis is the solver's lever — so a length of 0 there is
323
+ * refused by `A23_PHYSICS_CONSTRAINT_EFFECTIVE` (issue #1195).
324
+ */
325
+ length?: number;
326
+ /** Local to the parent. Default 0. Supplied by `from` when that is given. */
327
+ x?: number;
328
+ y?: number;
329
+ /** Degrees, CCW, y up. Default 0. Supplied by `from.rotation` when given. */
330
+ rotation?: number;
331
+ /** Default 1. */
332
+ scaleX?: number;
333
+ scaleY?: number;
334
+ /** Default 0. */
335
+ shearX?: number;
336
+ shearY?: number;
337
+ /** Default `normal`. 4.2+ name; 4.0/4.1 called it `transform` — see A02. */
338
+ inherit?: RigBoneInherit;
339
+ /**
340
+ * Default false → `BoneData.skinRequired`: this bone is **inactive** unless the
341
+ * applied skin names it in its `bones` list (see `RigSkinEntry`). Half a switch
342
+ * on its own, so rigc refuses the flag without a skin that activates it.
343
+ */
344
+ skin?: boolean;
345
+ /** `rrggbbaa`. Editor affordance; no rendering effect. */
346
+ color?: string;
347
+ /**
348
+ * The editor's icon for this bone. Editor affordance; no rendering effect,
349
+ * and no assertion checks the name — the icon vocabulary belongs to the
350
+ * editor, so an unknown one is not rigc's error to raise.
351
+ */
352
+ icon?: string;
353
+ /** rigc extension — see `RigBoneFrom`. */
354
+ from?: RigBoneFrom;
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // slots — `root.slots[]` (SkeletonJson.ts:121-141)
359
+ // ---------------------------------------------------------------------------
360
+
361
+ /**
362
+ * `SlotData.ts:64`, read by `SkeletonJson.js:124` through `Utils.enumValue`, so
363
+ * only the first letter's case is free: `additive` and `Additive` read
364
+ * `Additive`, while `ADDITIVE`, `mUlTiPlY` and `foo` read as no mode with no
365
+ * error (issue #946, measured with `tools/pose_oracle.ts dump` on spine-core
366
+ * 4.3.13). The parser refuses those by name and the emitter writes the
367
+ * spelling as stated, which is one the runtime resolves.
368
+ */
369
+ export type RigSlotBlend =
370
+ | 'normal'
371
+ | 'additive'
372
+ | 'multiply'
373
+ | 'screen'
374
+ | 'Normal'
375
+ | 'Additive'
376
+ | 'Multiply'
377
+ | 'Screen';
378
+
379
+ /** The four modes, as a refusal names them; each also reads with its first letter upper-cased. */
380
+ export const RIG_SLOT_BLEND: readonly RigSlotBlend[] = ['normal', 'additive', 'multiply', 'screen'];
381
+
382
+ /** Whether the runtime resolves a stated blend to a mode: its first letter folded, the rest exactly one of the four. */
383
+ export function isRigSlotBlend(value: unknown): value is RigSlotBlend {
384
+ return typeof value === 'string' && (RIG_SLOT_BLEND as readonly string[]).includes(value.charAt(0).toLowerCase() + value.slice(1));
385
+ }
386
+
387
+ /**
388
+ * One slot. **The array order IS the draw order** — there is no separate setup
389
+ * draw-order field anywhere in the format.
390
+ *
391
+ * The rig's slot list is the CANONICAL table and **every slot in it is emitted**,
392
+ * in this order, whether or not anything fills it. A slot no skin and no manifest
393
+ * part fills is emitted with no setup attachment — the shape an editor export
394
+ * carries for a slot that shows nothing (the slot reader above takes `attachment`
395
+ * with a `null` default) — so the emitted array and this one are the same array.
396
+ * `A26_SLOT_DRAW_ORDER` checks both halves of that: nothing out of order, and
397
+ * nothing missing. Declaring a slot no cut fills is therefore legitimate, and it
398
+ * fixes where that slot sits whether or not this cut has art for it.
399
+ *
400
+ * ⚠️ Until issue #575 such a slot was **dropped**, and the gate licensed it: the
401
+ * emitted array was allowed to be any *subsequence* of this one. What that
402
+ * bought was the format's own silence. Nothing said which slot had gone, and
403
+ * every slot below it moved up one index — the index a `drawOrder` key's offsets
404
+ * are counted against, and the one an index-keyed consumer splits on. Two
405
+ * production exports declaring 53 and 61 slots built green at 51 and 57 and read
406
+ * 0.962 and 0.934 under `diff` against the file they were transcribed from.
407
+ */
408
+ export interface RigSlot {
409
+ name: string;
410
+ /** Required. A miss throws in the parser: `Couldn't find bone … for slot …`. */
411
+ bone: string;
412
+ /**
413
+ * The setup-pose attachment name, or `null` for "show nothing".
414
+ *
415
+ * ⚠️ For a cut compiled with a motion spec this is **not** where the setup pose
416
+ * comes from: `motion.setup` owns it, because which of the two overlay
417
+ * mechanisms a slot uses (attachment + alpha 0, or attachment swapping) is a
418
+ * decision about time. Declaring it in both is a compile error.
419
+ *
420
+ * Required for a slot something fills — the compiler will not guess which of
421
+ * the slot's attachments the setup pose shows — and **optional for a slot
422
+ * nothing fills**, where it can only be `null` and saying so changes no
423
+ * emitted byte. Naming an attachment on a slot nothing fills is refused: the
424
+ * name resolves to nothing, which is the shape of a half-finished wiring-up.
425
+ */
426
+ attachment?: string | null;
427
+ /** `rrggbbaa`. Default opaque white. */
428
+ color?: string;
429
+ /** Two-colour tint, `rrggbb`. 🚫 `A12_NO_DARK_COLOR` under `spine-html`. */
430
+ dark?: string;
431
+ /** Default `normal`. Only the first letter's case is free (`RigSlotBlend`). */
432
+ blend?: RigSlotBlend;
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // attachments — `readAttachment` (SkeletonJson.ts:535-654)
437
+ // ---------------------------------------------------------------------------
438
+
439
+ /**
440
+ * A greyscale sheet, in a part's own pixel grid, giving each vertex a depth —
441
+ * what `yaw` and `pitch` otherwise derive from one cylinder radius.
442
+ * [`src/depth.ts`](depth.ts) is the model and the order of operations;
443
+ * `docs/FACE.md` §2.1 is when to reach for it.
444
+ *
445
+ * ⚠️ Naming it changes no emitted byte on its own. It puts a `z` on every
446
+ * vertex, which a `yaw` or `pitch` key then reads by saying `"depth": true`
447
+ * instead of a `radius`. A map that nothing reads is reported and otherwise
448
+ * inert — deliberately, so that adding the input and adopting it are two
449
+ * reviewable steps rather than one.
450
+ */
451
+ export interface RigDepthMap {
452
+ /**
453
+ * The sheet, relative to the rig's `images` directory, and the same pixel
454
+ * size as this attachment's own `image`.
455
+ *
456
+ * It is NOT packed into the atlas: it is a measurement rigc reads at compile
457
+ * time, not art anything draws. A sheet that reached the atlas would be a
458
+ * page the runtime loads and never samples.
459
+ */
460
+ image: string;
461
+ /**
462
+ * Which end of the range is closest to the viewer. Stated rather than
463
+ * defaulted, because both conventions are in use and a sheet that means the
464
+ * opposite of what the spec assumes produces a part that turns inside out —
465
+ * with every gate still green, since the arithmetic is correct and only the
466
+ * input was backwards.
467
+ */
468
+ near: 'white' | 'black';
469
+ /**
470
+ * How many world units the map's full 0..1 range spans, in the attachment's
471
+ * own units — the number `radius` used to carry.
472
+ *
473
+ * Authored, never measured: 8 bits of level say nothing about scale, so a
474
+ * compiler that picked one would be inventing the depth of the art.
475
+ */
476
+ zScale: number;
477
+ /** Tone curve applied to the nearness. Defaults 1 / 1 / 0, a straight line. */
478
+ gamma?: number;
479
+ contrast?: number;
480
+ bias?: number;
481
+ }
482
+
483
+ /**
484
+ * Which part of a mesh is **soft**, and which bone carries it — so a physics
485
+ * constraint on that bone answers an impact over exactly that region.
486
+ *
487
+ * ## Why this is a painted mask and not a depth threshold
488
+ *
489
+ * 🚨 It was a depth threshold for one day (2026-09-05) and that was wrong.
490
+ * Softness and prominence are different properties of the art: on a face the
491
+ * most prominent thing is the **nose**, and a nose does not wobble. A threshold
492
+ * over the depth map produced a region that was plausible, gated green and
493
+ * carried the wrong pixels — the exact shape of failure this compiler exists to
494
+ * refuse, arrived at by reaching for a number that was already in the manifest.
495
+ *
496
+ * It also claimed something untrue. "No mask painted" was the selling line, and
497
+ * a consumer rendering the same effect had a hand-painted spring mask all
498
+ * along. rigc does not get to delete an input by guessing it.
499
+ *
500
+ * ⇒ The mask is authored, like `zScale` and like every other number here that
501
+ * describes a decision about the art rather than a measurement of it.
502
+ */
503
+ export interface RigSoftRegion {
504
+ /**
505
+ * The bone the soft region is carried by. It must already exist — a bone a
506
+ * physics constraint targets is part of the skeleton, not a side effect of a
507
+ * mesh.
508
+ */
509
+ bone: string;
510
+ /**
511
+ * A greyscale sheet in the part's own pixel grid: the level IS the weight,
512
+ * black still and white fully carried, sampled at each vertex.
513
+ *
514
+ * ⭐ The ramp is painted rather than parameterised. A `feather` would be this
515
+ * file guessing the shape of a falloff somebody can simply draw, and a hard
516
+ * edge — which a threshold gives you by default — puts the whole difference
517
+ * between carried and still into one triangle.
518
+ *
519
+ * Alpha is not read: a transparent pixel is black, which is weight 0.
520
+ */
521
+ mask: string;
522
+ }
523
+
524
+ /**
525
+ * Directional authority across an axis — see `sideWeight` in
526
+ * [`mesh.ts`](mesh.ts).
527
+ *
528
+ * ⭐ It was an inline object type until issue #545. The four generator kinds
529
+ * were too: the union is spelled as four **named** interfaces now because
530
+ * `RIG_KEYS` pairs a key set with an interface by name, and a shape with no name
531
+ * is a shape the pairing cannot reach — so an anonymous corner of this file
532
+ * would have been a corner whose key set nothing checked.
533
+ */
534
+ export interface RigMeshBias {
535
+ /** The axis, in SCREEN degrees, y down — a manifest's own convention. */
536
+ axis_deg: number;
537
+ /** Signed distance across that axis over which authority goes 0 -> 1. */
538
+ ramp: [number, number];
539
+ }
540
+
541
+ /** A ring: a seam contour, an aperture inside it, and the bones that open it. */
542
+ export interface RigRingGenerator {
543
+ kind: 'ring';
544
+ /** The seam contour, in part-local pixels, y down. At least 6 points. */
545
+ hull: Array<[number, number]>;
546
+ /** Aperture centre, part-local pixels, y down. */
547
+ center: [number, number];
548
+ /** Inner ring position between the centre (0) and the hull (1). */
549
+ inner: number;
550
+ /** Part window size, for UVs. */
551
+ size: [number, number];
552
+ /** Directional authority across an axis — see `sideWeight` in mesh.ts. */
553
+ bias?: RigMeshBias;
554
+ /**
555
+ * Control bones, by name. More than one splits the ring by angle, and the
556
+ * angle of each is measured from where the rig put that bone relative to
557
+ * `center` — never stated here, so the split cannot drift from the skeleton.
558
+ */
559
+ controls: string[];
560
+ }
561
+
562
+ /** A ribbon: a strip of cross rows riding a bone chain. */
563
+ export interface RigRibbonGenerator {
564
+ kind: 'ribbon';
565
+ /** Part window size in pixels. The strip spans it. */
566
+ size: [number, number];
567
+ /** Cross rows, entry first. Triangles = 2 * (rows - 1). */
568
+ rows: number;
569
+ /** The bone chain the strip rides, root first. */
570
+ chain: string[];
571
+ }
572
+
573
+ /**
574
+ * A mesh cut to the part's own alpha silhouette: trace the mask, simplify
575
+ * the outline, push it out by a margin, ear-clip it (`buildContourMesh`).
576
+ *
577
+ * ⭐ It takes no `size` and no geometry. The shape is MEASURED off the
578
+ * attachment's own `image` — the same rule a region attachment's
579
+ * `width`/`height` follow (R5) — so there is no number here that can
580
+ * disagree with the pixels, and no polygon to keep in step with the art.
581
+ *
582
+ * 🚨 It is geometry, not a deformation model: every vertex is pinned to
583
+ * the slot bone at weight 1, so an undeformed contour mesh draws exactly
584
+ * what the region drew and no bone can bend it. See the section header in
585
+ * [`src/mesh.ts`](mesh.ts) for what it buys instead, and reach for `ring`
586
+ * or authored `weights` when a bone has to move the art.
587
+ */
588
+ export interface RigContourGenerator {
589
+ kind: 'contour';
590
+ /**
591
+ * Douglas-Peucker tolerance in the drawing's pixels — the unit every other
592
+ * size here is in, on a packed page that declares a `scale:` as on loose
593
+ * parts: there the trace runs on the page's texels and this is applied as
594
+ * `tolerance × scale` of them (issue #779). Bigger spends fewer vertices
595
+ * and cuts more corners; the builder measures how much of the art the
596
+ * result still covers and refuses a mesh that clips it.
597
+ */
598
+ tolerance: number;
599
+ /**
600
+ * How far the outline is pushed out past the traced silhouette, in the
601
+ * drawing's pixels (`margin × scale` texels on a `scale:` page). Default 1. Simplification may bite `tolerance` pixels INTO the art, so
602
+ * `margin >= tolerance` is the setting that survives the coverage check.
603
+ */
604
+ margin?: number;
605
+ /** Refuse rather than emit more outline vertices than this. Default 64. */
606
+ maxVertices?: number;
607
+ /** Alpha at or above which a pixel counts as art, 1..255. Default 1. */
608
+ alpha?: number;
609
+ /** A depth map for this part — see `RigDepthMap`. */
610
+ depth?: RigDepthMap;
611
+ /** A soft region carried by its own bone — see `RigSoftRegion`. */
612
+ soft?: RigSoftRegion;
613
+ }
614
+
615
+ /**
616
+ * A lattice over the part window — the topology `docs/FACE.md` §4 turns a
617
+ * plate into so a turn has columns to move.
618
+ *
619
+ * ⭐ It takes no `size`: like a `contour`, the window is the attachment's
620
+ * own `image`, so there is no number here that can disagree with the
621
+ * pixels. Every vertex is pinned to the slot bone at weight 1, which
622
+ * makes the lattice geometry to DEFORM rather than an authority split —
623
+ * reach for `ring` when bones have to move it.
624
+ */
625
+ export interface RigGridGenerator {
626
+ kind: 'grid';
627
+ /**
628
+ * Column positions across the window, 0..1, ascending. At least 2.
629
+ *
630
+ * ⚠️ Positions, not a count, and that is deliberate: FACE §4.1 places
631
+ * columns where the drawing needs them, and the worked example's are
632
+ * dense at the silhouette and sparse across the middle. They need not
633
+ * reach the window edge — that example's run 0.0235 to 0.9765.
634
+ */
635
+ us?: number[];
636
+ /** Row positions down the window, 0..1, ascending. At least 2. */
637
+ vs?: number[];
638
+ /**
639
+ * Even division instead: `cols` columns and `rows` rows spanning the
640
+ * whole window. A convenience for a plate with no shape to follow, and
641
+ * refused beside `us`/`vs`, which say the same thing more precisely.
642
+ */
643
+ cols?: number;
644
+ rows?: number;
645
+ /** A depth map for this part — see `RigDepthMap`. */
646
+ depth?: RigDepthMap;
647
+ /** A soft region carried by its own bone — see `RigSoftRegion`. */
648
+ soft?: RigSoftRegion;
649
+ }
650
+
651
+ /**
652
+ * One segment stated outright, for a pull that no bone's own span describes.
653
+ *
654
+ * `from` and `to` are in the part's own pixels, y down — the frame every other
655
+ * point a generator takes is in (`ring.center`, `ring.hull`). The bone is
656
+ * resolved by name like everything else; the two points belong to this mesh
657
+ * alone, which is the case a bone's `length` cannot cover: two meshes over one
658
+ * bone that each want it to pull along a different line.
659
+ */
660
+ export interface RigSegmentSpan {
661
+ bone: string;
662
+ from: [number, number];
663
+ to: [number, number];
664
+ }
665
+
666
+ /**
667
+ * How distance to a segment becomes a weight: `w = 1 / (d + radius)^power`
668
+ * per candidate bone, the strongest `maxBones` kept and normalised, any share
669
+ * under `minWeight` dropped, the rest normalised again.
670
+ */
671
+ export interface RigSegmentsFalloff {
672
+ /** The exponent. Default 2. */
673
+ power?: number;
674
+ /**
675
+ * **Required.** Added to every distance, in the part's pixels, so a vertex
676
+ * ON a segment has a finite weight and the blend between two segments is as
677
+ * wide as this says. No default: it is a length on this part's art.
678
+ */
679
+ radius: number;
680
+ /** At most this many bones pull one vertex. Default 4. */
681
+ maxBones?: number;
682
+ /** A normalised share under this is dropped. Default 0.03. */
683
+ minWeight?: number;
684
+ }
685
+
686
+ /**
687
+ * A lattice over the part's alpha, weighted by distance to named bone segments
688
+ * (`buildSegmentsLattice` and `segmentWeights` in [`mesh.ts`](mesh.ts)).
689
+ *
690
+ * ⭐ The one authoring decision is `bones` — which segments may pull this part.
691
+ * The geometry comes off the attachment's own `image`, the segments off the
692
+ * skeleton's setup pose, and the weights off the distance between the two, so
693
+ * nothing else here is a judgement about the art.
694
+ */
695
+ export interface RigSegmentsGenerator {
696
+ kind: 'segments';
697
+ /** **Required.** The lattice's cell, in the part's pixels, a whole number of at least 1. */
698
+ cell: number;
699
+ /**
700
+ * **Required, at least one entry.** Each is a bone name (origin to its
701
+ * `length` tip), a chain — a list of bone names, root first, each link
702
+ * running from its origin to the next link's (the last to its `length` tip) —
703
+ * or a `RigSegmentSpan` stated outright.
704
+ */
705
+ bones: Array<string | string[] | RigSegmentSpan>;
706
+ /** The falloff — see `RigSegmentsFalloff`. `radius` is required, so the block is too. */
707
+ falloff: RigSegmentsFalloff;
708
+ /** Alpha at or above which a pixel counts as art, 1..255. Default 1 — `contour`'s own. */
709
+ alpha?: number;
710
+ /**
711
+ * Where the slot bone sits in the part's pixels, y down. Default the window's
712
+ * centre — the placement every generator on this route uses.
713
+ */
714
+ anchor?: [number, number];
715
+ }
716
+
717
+ /**
718
+ * Which builder in `src/mesh.ts` makes this mesh's geometry, and its parameters.
719
+ *
720
+ * The builders stay **code** and are invoked by **data**: they encode a
721
+ * deformation model (what is pinned, what may move, how authority falls off),
722
+ * and a model is not a table of numbers.
723
+ *
724
+ * ⚠️ A cut with a manifest does not use this. There the generator is invoked
725
+ * through the manifest's `mesh` block, because everything a generator needs —
726
+ * the mask contour, the aperture centre, the part window — is *measured art*,
727
+ * and measured art lives in the manifest. `generator` is for a skeleton with no
728
+ * manifest behind it.
729
+ */
730
+ export type RigMeshGenerator =
731
+ | RigRingGenerator
732
+ | RigRibbonGenerator
733
+ | RigContourGenerator
734
+ | RigGridGenerator
735
+ | RigSegmentsGenerator;
736
+
737
+ /** The five `kind` names a generator may carry, and the order `RIG_KEYS` takes them in. */
738
+ export const RIG_GENERATOR_KINDS = ['ring', 'ribbon', 'contour', 'grid', 'segments'] as const;
739
+
740
+ /**
741
+ * The attachment's own NAME, as distinct from the placeholder key it is filed
742
+ * under — one field, the same on every attachment type (issue #796).
743
+ *
744
+ * `readAttachment` reads `name = getValue(map, "name", placeholder)`
745
+ * (`SkeletonJson.js:526`) for every type, so this is the runtime's
746
+ * `Attachment.name` — what a consumer reads off `slot.attachment.name` — and,
747
+ * on the three types that draw, the default of `path` (`:529`, `:560`). Absent,
748
+ * the runtime names the attachment by its placeholder.
749
+ *
750
+ * 🔑 Nothing in the format resolves BY it. A skin's table, a slot's setup
751
+ * `attachment`, an attachment or deform timeline and a linked mesh's `source`
752
+ * are all keyed by the placeholder (`:415-418`, `:433`, `:1140`) — measured: a
753
+ * link whose `source` spells its source's name rather than its key throws
754
+ * `Source mesh not found`. So two skins may give one placeholder two
755
+ * attachments of one name, and the runtime keeps both.
756
+ *
757
+ * ⭐ rigc writes this field exactly when the spec states it and never derives
758
+ * one. Until #796 it composed `<skin>/<placeholder>` for a placeholder several
759
+ * skins fill, and a transcribed name had no field to live in — so a rebuild of
760
+ * an editor export answered to other names than its source did, which a
761
+ * consumer reading `attachment.name` can see and no gate could.
762
+ */
763
+ export type RigAttachmentName = string;
764
+
765
+ /** `SkeletonJson.ts:540-559`. `type` defaults to `region` (`:539`). */
766
+ export interface RigRegionAttachment {
767
+ type?: 'region';
768
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
769
+ name?: RigAttachmentName;
770
+ /** The atlas region to resolve. Defaults to the attachment's own name. */
771
+ path?: string;
772
+ /**
773
+ * rigc extension: a PNG, relative to the rig's `images` directory.
774
+ *
775
+ * ⭐ Naming a file instead of a size is the point. `width`/`height` have **no
776
+ * parser default** — an omission loads as `NaN` and every UV collapses with no
777
+ * error — so a spec that restates them by hand carries a number that can
778
+ * disagree with the pixels. Give an `image` and rigc reads the PNG header and
779
+ * fills both in; the atlas page it emits is that same file, so the size in the
780
+ * skeleton and the size in the atlas cannot drift apart.
781
+ */
782
+ image?: string;
783
+ x?: number;
784
+ y?: number;
785
+ /** Degrees. Cancels a rotated bone for a plate authored in screen space. */
786
+ rotation?: number;
787
+ scaleX?: number;
788
+ scaleY?: number;
789
+ /** Required by the format; may be omitted here when `image` is given. */
790
+ width?: number;
791
+ height?: number;
792
+ /** `rrggbbaa`. */
793
+ color?: string;
794
+ /** A numbered image series in place of one region — see `RigSequence`. */
795
+ sequence?: RigSequence;
796
+ }
797
+
798
+ /**
799
+ * A numbered image series drawn by ONE attachment — `readSequence`
800
+ * (`SkeletonJson.js:641-649`), on a region, a mesh or a linked mesh.
801
+ *
802
+ * The attachment's `path` (or, with none, its placeholder) is the series'
803
+ * **stem**, and frame `i` is the atlas region `stem + (start + i)` left-padded
804
+ * with zeros to `digits` — `Sequence.getPath` (`Sequence.js:124-132`), which
805
+ * `AtlasAttachmentLoader.findRegions` walks for every `i` below `count`. A
806
+ * `sequence` timeline (the motion spec's `sequence` family) chooses which frame
807
+ * shows; without one, the frame is `setup`.
808
+ *
809
+ * ⭐ **The frames resolve by name and a missing one is refused by name.** On the
810
+ * loose route frame `i` is the PNG `<images>/<region>.png`; under `--atlas-in`
811
+ * it is the pack's region of that name. The compiler looks each one up and names
812
+ * the frame number and the region it looked for when one is absent — it never
813
+ * stands one frame in for another, and the loader's own miss
814
+ * (`Region not found in atlas`) names neither the frame nor the series.
815
+ *
816
+ * ⚠️ An attachment carrying a sequence states no `image`: an image names one
817
+ * region and a sequence names `count` of them, so the pair would be two claims
818
+ * about what the attachment draws. And no `generator`: a generator traces one
819
+ * plate, and which frame it should trace is not something the spec says.
820
+ */
821
+ export interface RigSequence {
822
+ /**
823
+ * How many frames. **Required** — the parser's default is 0
824
+ * (`new Sequence(getValue(map, "count", 0), true)`), which loads an attachment
825
+ * holding no region at all and draws nothing, with no error.
826
+ */
827
+ count: number;
828
+ /** The number the first frame's name carries. Parser default 1. */
829
+ start?: number;
830
+ /** Zero-pad the frame number to at least this many digits. Parser default 0 (no padding). */
831
+ digits?: number;
832
+ /**
833
+ * The frame the setup pose shows, 0-based. Parser default 0. Spelled as the
834
+ * FILE spells it (`getValue(map, "setup", 0)`); `setupIndex` is the runtime's
835
+ * field name and is not a key the format has.
836
+ */
837
+ setup?: number;
838
+ }
839
+
840
+ /**
841
+ * `SkeletonJson.ts:568-605`. Either authored geometry or a `generator`, never
842
+ * both.
843
+ *
844
+ * ⚠️ `vertices` has no encoding flag anywhere in the format. If its length
845
+ * equals `uvs.length` the parser reads unweighted x/y pairs; otherwise it reads
846
+ * the weighted run `boneCount, (boneIndex, bindX, bindY, weight) × n, …`. A
847
+ * coincidental length match reads weight data as coordinates, silently — which
848
+ * is `A04_MESH_TRIANGLES_AND_ENCODING`.
849
+ */
850
+ /**
851
+ * One bone's pull on one vertex of an authored mesh, **named**.
852
+ *
853
+ * `x`/`y` are the vertex's position in that bone's own setup space — the same
854
+ * pair Spine's weighted run carries after the bone index. `weight` is its share;
855
+ * a vertex's weights sum to 1.
856
+ */
857
+ export interface RigMeshBinding {
858
+ /** Resolved against the rig's bone list at emit. An unknown name is refused. */
859
+ bone: string;
860
+ x: number;
861
+ y: number;
862
+ weight: number;
863
+ }
864
+
865
+ export interface RigMeshAttachment {
866
+ type: 'mesh';
867
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
868
+ name?: RigAttachmentName;
869
+ path?: string;
870
+ image?: string;
871
+ /** Its length defines `worldVerticesLength`; required with authored geometry. */
872
+ uvs?: number[];
873
+ triangles?: number[];
874
+ /**
875
+ * Geometry, in one of two forms.
876
+ *
877
+ * **Unweighted** — one `x, y` pair per uv pair, and `vertices.length` equals
878
+ * `uvs.length`. Nothing here names a bone, so nothing here can be rebound.
879
+ *
880
+ * **Weighted, raw** — Spine's own encoding,
881
+ * `boneCount, (boneIndex, bindX, bindY, weight) x n` per vertex, where
882
+ * `boneIndex` is a position in the EMITTED bone array. 🚨 That array is not
883
+ * something a rig spec writes or can see, so those indices shift under any
884
+ * edit to the bone list and every vertex silently rebinds — the mesh still
885
+ * loads, every weight still sums to 1, and nothing in the file objects. rigc
886
+ * therefore refuses this form unless the attachment says `boneIndexing: "raw"`
887
+ * out loud. Use `weights` instead.
888
+ */
889
+ vertices?: number[];
890
+ /**
891
+ * Weighted geometry that binds **by name**: one entry per vertex, each a list
892
+ * of `{ bone, x, y, weight }`. This is the default form and the one everything
893
+ * else in a rig spec already uses — a bone's `parent`, a slot's `bone`, a
894
+ * constraint's `bones` and `target` all resolve by name and refuse a miss by
895
+ * name. The compiler resolves these to indices on emit, so inserting a bone
896
+ * moves the indices and changes nothing about what the mesh is bound to.
897
+ *
898
+ * Mutually exclusive with `vertices`.
899
+ */
900
+ weights?: RigMeshBinding[][];
901
+ /**
902
+ * How a weighted `vertices` run names its bones. Default `"name"`, which means
903
+ * "there is no weighted run here — use `weights`". `"raw"` opts into the index
904
+ * encoding above, for a spec transcribed from an export that has not been
905
+ * migrated yet. It is an opt-in because the cost of it is silence.
906
+ */
907
+ boneIndexing?: 'name' | 'raw';
908
+ /** Hull vertex count. The loader stores it doubled. */
909
+ hull?: number;
910
+ /** Edge index pairs; nonessential, editor-drawn. */
911
+ edges?: number[];
912
+ width?: number;
913
+ height?: number;
914
+ color?: string;
915
+ /** Build the geometry instead of authoring it — see `RigMeshGenerator`. */
916
+ generator?: RigMeshGenerator;
917
+ /** A numbered image series over this one triangulation — see `RigSequence`. */
918
+ sequence?: RigSequence;
919
+ }
920
+
921
+ /**
922
+ * A mesh that borrows another mesh's geometry — Spine's `linkedmesh`, the type
923
+ * a skin variant uses to draw its own art over one triangulation.
924
+ *
925
+ * 🔑 **`source` is the source attachment's PLACEHOLDER — the key it is filed
926
+ * under in its skin — and not its `name`.** The resolution is
927
+ * `skin.getAttachment(sourceSlotIndex, source)` (`SkeletonJson.js:433`), and a
928
+ * skin's table is keyed by the JSON key `readSkin` iterated (`:415-418`), so a
929
+ * source that states a `name` of its own is still found under its placeholder
930
+ * alone — and a `source` spelling that name throws `Source mesh not found`.
931
+ * Two skins each filling the source's placeholder are told apart by `skin`,
932
+ * never by a name (issue #796, which retired #541's reading that a link
933
+ * resolves its source by name).
934
+ *
935
+ * 🚨 **A linked mesh states no geometry of its own, and the parser is silent
936
+ * about one that does.** The `source` branch returns before `readVertices`
937
+ * (`:582-586`), so `uvs`, `triangles`, `vertices`, `weights`, `hull` and `edges`
938
+ * on a link are read by nothing at all. Measured on a forged skeleton: a link
939
+ * declaring 5 uvs, 3 triangles, `hull: 5` and `edges: [0, 2]` beside a 4-vertex
940
+ * source loaded with the SOURCE's 8-long `worldVerticesLength`, 6 triangles,
941
+ * `hullLength` 8 and 10 edges — the numbers the author wrote reached nothing and
942
+ * nothing said so. rigc refuses them by name.
943
+ *
944
+ * ⚠️ `width`/`height` are the link's own art, and the RUNTIME overwrites both
945
+ * with the source's at resolution time (`MeshAttachment.setSourceMesh`,
946
+ * `:102-103`; measured: a link stating 99x77 beside a 32x32 source loads as
947
+ * 32x32). They are emitted because the editor reads them off the file and
948
+ * because the spec stated them, and the gate cannot see them — which is the
949
+ * reason this note exists rather than an assertion.
950
+ */
951
+ export interface RigLinkedMeshAttachment {
952
+ type: 'linkedmesh';
953
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
954
+ name?: RigAttachmentName;
955
+ /** The art this link draws, exactly as a mesh's: its own region. */
956
+ path?: string;
957
+ image?: string;
958
+ /**
959
+ * The placeholder of the mesh whose geometry this one borrows. Required — and
960
+ * required in the strong sense: `getValue(map, "source", null)` is FALSY-tested
961
+ * (`:582`), so an absent or empty `source` is not a link at all and the parser
962
+ * falls through to `map.uvs`, which a link does not have, and throws.
963
+ */
964
+ source: string;
965
+ /**
966
+ * The slot the source lives in. Default: **the link's own slot**
967
+ * (`sourceIndex = slotIndex`, `:571-580`). Resolved by name; a slot the rig
968
+ * does not declare is refused.
969
+ */
970
+ slot?: string;
971
+ /**
972
+ * The skin the source lives in. Default: **the default skin**
973
+ * (`!linkedMesh.skin ? skeletonData.defaultSkin : findSkin(...)`, `:429`).
974
+ * Resolved by name; a skin the rig does not declare is refused.
975
+ */
976
+ skin?: string;
977
+ /**
978
+ * Whether the link plays the source's deform keys. Default **true**, which
979
+ * also sets `timelineAttachment` to the source and adds this link's slot to
980
+ * the source's `timelineSlots` when the two differ (`:437-448`). `false` makes
981
+ * the link its own `timelineAttachment`, so only keys written against the link
982
+ * itself move it.
983
+ */
984
+ timelines?: boolean;
985
+ width?: number;
986
+ height?: number;
987
+ color?: string;
988
+ /** The link's OWN numbered series — see `RigSequence`. */
989
+ sequence?: RigSequence;
990
+ /**
991
+ * 🚫 Every geometry field a mesh may state, refused by name on a link. They
992
+ * are declared so the refusal can name them: a key the shape
993
+ * does not hold at all comes back as *keys this compiler does not read … fix
994
+ * the spelling or remove it*, and the remedy sentence is wrong here — the
995
+ * fault is not a typo, it is that the parser reads none of them on a link.
996
+ */
997
+ uvs?: number[];
998
+ triangles?: number[];
999
+ vertices?: number[];
1000
+ weights?: RigMeshBinding[][];
1001
+ boneIndexing?: 'name' | 'raw';
1002
+ hull?: number;
1003
+ edges?: number[];
1004
+ generator?: RigMeshGenerator;
1005
+ }
1006
+
1007
+ /**
1008
+ * The geometry every non-region attachment shares: a polygon, either pinned to
1009
+ * one bone or weighted across several.
1010
+ *
1011
+ * ⭐ `vertexCount` is REQUIRED and cross-checked, and that is the whole design of
1012
+ * these two types. A mesh gets its vertex count from `uvs.length`, so there is
1013
+ * nothing to state; a bounding box and a clipping polygon have no uvs, and the
1014
+ * parser reads `map.vertexCount << 1` — with the field absent that is
1015
+ * `undefined << 1` = **0**, so `readVertices` takes the weighted branch,
1016
+ * decodes coordinates as a weight run, and hands back an attachment with no
1017
+ * vertices at all. Nothing throws. So the count is declared here and checked
1018
+ * against whichever encoding the spec used.
1019
+ *
1020
+ * The two encodings are the mesh's, unchanged, and for the same reason:
1021
+ * `weights` binds by NAME and is the default; `vertices` is either an unweighted
1022
+ * `x, y` run (one pair per vertex) or Spine's index-encoded weighted run, and
1023
+ * the second of those needs `boneIndexing: "raw"` said out loud because a bone
1024
+ * inserted anywhere above shifts every index in silence (issue #45).
1025
+ */
1026
+ export interface RigVertexGeometry {
1027
+ /** Required. No parser default: absent reads as 0 and the polygon vanishes. */
1028
+ vertexCount: number;
1029
+ /**
1030
+ * Unweighted `x, y` pairs (`vertices.length === vertexCount * 2`), or Spine's
1031
+ * weighted run behind `boneIndexing: "raw"`. Mutually exclusive with `weights`.
1032
+ */
1033
+ vertices?: number[];
1034
+ /** Weighted geometry bound by name — one entry per vertex. The default form. */
1035
+ weights?: RigMeshBinding[][];
1036
+ /** `"raw"` opts a `vertices` weighted run into the index encoding. */
1037
+ boneIndexing?: 'name' | 'raw';
1038
+ /** `rrggbbaa`. Editor affordance: the colour the box is drawn in. */
1039
+ color?: string;
1040
+ }
1041
+
1042
+ /**
1043
+ * `type: "boundingbox"` (`SkeletonJson.ts:560-567`).
1044
+ *
1045
+ * **When you need one:** a polygon the game can hit-test against — a hurt box, a
1046
+ * pick region, a trigger volume — that moves with the skeleton and draws
1047
+ * nothing. It is the only attachment type whose entire purpose is outside the
1048
+ * renderer, which is why it has no `path`, no size and no uvs.
1049
+ */
1050
+ export interface RigBoundingBoxAttachment extends RigVertexGeometry {
1051
+ type: 'boundingbox';
1052
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
1053
+ name?: RigAttachmentName;
1054
+ }
1055
+
1056
+ /**
1057
+ * `type: "clipping"` (`SkeletonJson.ts:635-651`).
1058
+ *
1059
+ * **When you need one:** a mask. The polygon clips every slot drawn from the one
1060
+ * carrying it up to and including `end`, so a window, a portal or a wipe is one
1061
+ * attachment rather than a second set of art.
1062
+ *
1063
+ * ⚠️ `end` is resolved with `skeletonData.findSlot(end)`, which returns **null**
1064
+ * on a miss and assigns that null without complaint (`:626-627`). The clip then
1065
+ * never ends — it runs to the bottom of the draw order and takes every slot
1066
+ * below it with it. rigc refuses a name the rig does not declare.
1067
+ */
1068
+ export interface RigClippingAttachment extends RigVertexGeometry {
1069
+ type: 'clipping';
1070
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
1071
+ name?: RigAttachmentName;
1072
+ /**
1073
+ * The last slot this clip applies to, by name. Absent leaves `endSlot` null,
1074
+ * which is the parser's own encoding for "clip everything after this one".
1075
+ */
1076
+ end?: string;
1077
+ /** 4.3. Default false. */
1078
+ convex?: boolean;
1079
+ /** 4.3. Default false. */
1080
+ inverse?: boolean;
1081
+ }
1082
+
1083
+ /**
1084
+ * `type: "path"` (`SkeletonJson.ts:606-623`) — a composite cubic Bezier the
1085
+ * skeleton carries as an attachment.
1086
+ *
1087
+ * **When you need one:** a path constraint has nowhere to aim without it. The
1088
+ * polygon here is not drawn (no runtime renders a path); it is the curve
1089
+ * `RigPathConstraint` slides bones along, and it deforms with the slot's bone
1090
+ * like any other vertex attachment.
1091
+ *
1092
+ * 🚨 **`vertexCount` is knots AND handles, and it must be a multiple of 3.**
1093
+ * The parser hands `vertexCount << 1` to `readVertices` and then walks the
1094
+ * result in groups of six (`PathConstraint.computeWorldPositions`): the first
1095
+ * and last points are the outer control handles of the end knots and are
1096
+ * dropped, leaving a `3K + 1` chain — so an OPEN path of K curves has
1097
+ * `vertexCount = 3(K + 1)` (minimum 6) and a CLOSED one has `3K` (minimum 3,
1098
+ * because the chain wraps). A count that is not a multiple of 3 does not throw:
1099
+ * `Utils.newArray(vertexCount / 3, 0)` accepts a fractional size, the groups of
1100
+ * six then straddle the knots, and the constraint slides bones along a curve
1101
+ * nobody drew.
1102
+ *
1103
+ * ⭐ `lengths` is **stated or measured** (issue #804). It is the cumulative
1104
+ * length at the end of each curve, in world units, and `vertexCount / 3` entries
1105
+ * on an open path and a closed one alike — the parser's allocation, one more
1106
+ * than an open path's curves, the last being the wrap-around curve's cumulative,
1107
+ * which nothing reads. Stated, it is emitted as stated: that is what `ingest`
1108
+ * writes from an export, because the editor measured it on a pose rigc does not
1109
+ * reproduce (below). Left out, rigc measures it off the geometry on the
1110
+ * unconstrained setup pose. Only `constantSpeed: false` reads it.
1111
+ *
1112
+ * ⚠️ Why a stated one is not re-measured: the editor's numbers are
1113
+ * `PathConstraint`'s own measurement of the pose the first update gives it —
1114
+ * with every constraint ordered before the path constraint applied. rigc does
1115
+ * not pose, so an authored path whose bones a constraint moves at rest gets the
1116
+ * unconstrained figure, and an export's own array is the only way to carry the
1117
+ * constrained one. Re-deriving it moved 232 of a production rig's 259 bones in
1118
+ * issue #804's pose comparison.
1119
+ *
1120
+ * 🔸 *Which* length, exactly, is `SpinePathAttachment`'s subject in
1121
+ * [`types.ts`](types.ts) and it is not the arc: it is `PathConstraint`'s own
1122
+ * four-sample forward difference, about 0.5 % below the arc, which is what the
1123
+ * Spine editor writes back too (issue #560). This comment said "arc length"
1124
+ * until then.
1125
+ */
1126
+ export interface RigPathAttachment extends RigVertexGeometry {
1127
+ type: 'path';
1128
+ /** The runtime's attachment name — see `RigAttachmentName`. Absent, it is the placeholder. */
1129
+ name?: RigAttachmentName;
1130
+ /** Default false. When true the last knot joins the first. */
1131
+ closed?: boolean;
1132
+ /**
1133
+ * Default **true** (`:610`) — note the direction: leaving it out asks for the
1134
+ * expensive-and-correct traversal, in which the runtime re-measures the path
1135
+ * every frame and `lengths` is never read. `false` makes the runtime trust the
1136
+ * emitted `lengths` instead: cheaper, exact only while the path holds its setup
1137
+ * shape, and the reason a deformed path wants the default.
1138
+ */
1139
+ constantSpeed?: boolean;
1140
+ /**
1141
+ * Stated: exactly `vertexCount / 3` finite entries, none below the one before
1142
+ * it, emitted as stated. Absent: measured — see the note above.
1143
+ */
1144
+ lengths?: number[];
1145
+ }
1146
+
1147
+ /**
1148
+ * The two types the format holds and rigc's emitter does not cover. They are
1149
+ * in the type so a spec can *say* them and get a named `NotImplementedError`;
1150
+ * the alternative is the parser's own behaviour, which is to return `null` for
1151
+ * an unknown `type` and drop the attachment without a word
1152
+ * (`SkeletonJson.ts:653`).
1153
+ *
1154
+ * 🚧 It appears nowhere in the benchmark corpus, so it is not on the ladder's
1155
+ * critical path — which is the reason it is deferred rather than an oversight.
1156
+ * `linkedmesh` stood here beside it until issue #691; `RigLinkedMeshAttachment`
1157
+ * is the shape that replaced it.
1158
+ */
1159
+ export interface RigUnimplementedAttachment {
1160
+ type: 'point';
1161
+ [field: string]: unknown;
1162
+ }
1163
+
1164
+ export type RigAttachment =
1165
+ | RigRegionAttachment
1166
+ | RigLinkedMeshAttachment
1167
+ | RigMeshAttachment
1168
+ | RigBoundingBoxAttachment
1169
+ | RigClippingAttachment
1170
+ | RigPathAttachment
1171
+ | RigUnimplementedAttachment;
1172
+
1173
+ /** `slotName -> placeholderName -> attachment` (`SkeletonJson.ts:431-439`). */
1174
+ export type RigSkinAttachments = Record<string, Record<string, RigAttachment>>;
1175
+
1176
+ /** The five per-type constraint lists a skin entry can carry (`:386-429`). */
1177
+ export const RIG_SKIN_CONSTRAINT_KEYS = ['ik', 'transform', 'path', 'physics', 'slider'] as const;
1178
+
1179
+ export type RigSkinConstraintKey = (typeof RIG_SKIN_CONSTRAINT_KEYS)[number];
1180
+
1181
+ /**
1182
+ * Every key the long form of a skin entry owns.
1183
+ *
1184
+ * ⚠️ Which is exactly the set of names a SLOT may not have, because these are
1185
+ * the keys that tell the two forms apart — see `splitRigSkin`. `parseRigSpec`
1186
+ * refuses such a slot by name rather than letting one form be read as the other.
1187
+ */
1188
+ export const RIG_SKIN_KEYS = ['attachments', 'bones', ...RIG_SKIN_CONSTRAINT_KEYS] as const;
1189
+
1190
+ /**
1191
+ * A skin's full 4.3 shape: attachments, plus the bones and constraints this skin
1192
+ * **activates** (`SkeletonJson.ts:377-429`).
1193
+ *
1194
+ * ⭐ The lists are not a second way to declare a bone or a constraint. They are
1195
+ * the other half of a switch whose first half already existed: a bone's
1196
+ * `skin: true` and a constraint's `skin: true` set `skinRequired`, and
1197
+ * `Skeleton.updateCache` starts every `skinRequired` object **inactive**, turning
1198
+ * it on only for the skin that names it here (`Skeleton.ts:191-217`; a listed
1199
+ * bone activates its whole ancestor chain). So either half alone is dead data,
1200
+ * in opposite directions and both in silence — `skin: true` with no list is a
1201
+ * bone that never poses, a list without `skin: true` is a list that changes
1202
+ * nothing — which is why rigc refuses both halves by name and
1203
+ * `A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED` checks the artifact for them.
1204
+ */
1205
+ export interface RigSkinEntry {
1206
+ /** `slotName -> placeholderName -> attachment`. */
1207
+ attachments?: RigSkinAttachments;
1208
+ /** Bone names this skin activates. Each one must declare `skin: true`. */
1209
+ bones?: string[];
1210
+ /** `ik` constraint names this skin activates. Each must declare `skin: true`. */
1211
+ ik?: string[];
1212
+ transform?: string[];
1213
+ path?: string[];
1214
+ physics?: string[];
1215
+ slider?: string[];
1216
+ }
1217
+
1218
+ /**
1219
+ * One skin, in either of two spellings.
1220
+ *
1221
+ * The short one — `slotName -> placeholderName -> attachment` — is the shape
1222
+ * every rig spec in this repository already uses and it stays exactly that. The
1223
+ * long one carries the 4.3 lists beside the attachments and is recognised by its
1224
+ * own keys (`RIG_SKIN_KEYS`); see `splitRigSkin` for the one ambiguity that
1225
+ * creates and how it is refused rather than guessed.
1226
+ */
1227
+ export type RigSkin = RigSkinAttachments | RigSkinEntry;
1228
+
1229
+ /** The two halves of a skin entry, whichever spelling the spec used. */
1230
+ export interface RigSkinParts {
1231
+ attachments: RigSkinAttachments;
1232
+ bones: string[];
1233
+ constraints: Record<RigSkinConstraintKey, string[]>;
1234
+ /** True when the spec used the long form. Only the messages care. */
1235
+ explicit: boolean;
1236
+ }
1237
+
1238
+ /**
1239
+ * Normalise one skin entry.
1240
+ *
1241
+ * ⚠️ The two spellings are told apart by the keys in `RIG_SKIN_KEYS`: a skin
1242
+ * that uses any of them is the long form. That is the one thing here that could
1243
+ * ever be ambiguous, and it is ambiguous in exactly one case — a rig with a SLOT
1244
+ * of one of those names — so rigc does not guess: `parseRigSpec` refuses such a
1245
+ * slot by name, because the alternative is a member list read as a slot's
1246
+ * placeholder table or the other way round.
1247
+ *
1248
+ * In the long form EVERY key must be one of them. A key outside the set is
1249
+ * almost always a slot name left behind by a half-finished conversion from the
1250
+ * short form, so it is refused with that as the message rather than ignored — an
1251
+ * ignored slot is an attachment that vanishes.
1252
+ */
1253
+ export function splitRigSkin(skin: RigSkin, where: string): RigSkinParts {
1254
+ const empty = (): Record<RigSkinConstraintKey, string[]> => ({
1255
+ ik: [],
1256
+ transform: [],
1257
+ path: [],
1258
+ physics: [],
1259
+ slider: [],
1260
+ });
1261
+ if (!isObj(skin)) throw new CompileError(`${where}: a skin must be an object`);
1262
+ const known = new Set<string>(RIG_SKIN_KEYS);
1263
+ const keys = Object.keys(skin);
1264
+ if (!keys.some((key) => known.has(key))) {
1265
+ return { attachments: skin as RigSkinAttachments, bones: [], constraints: empty(), explicit: false };
1266
+ }
1267
+ const entry = skin as RigSkinEntry;
1268
+ if (entry.attachments !== undefined && !isObj(entry.attachments)) {
1269
+ throw new CompileError(`${where}: "attachments" is \`slotName -> placeholderName -> attachment\`, not ${JSON.stringify(entry.attachments)}`);
1270
+ }
1271
+ for (const key of keys) {
1272
+ if (known.has(key)) continue;
1273
+ throw new CompileError(
1274
+ `${where}: uses the long form (it declares ${keys.filter((k) => known.has(k)).map((k) => `"${k}"`).join(', ')}) ` +
1275
+ `and also has a key "${key}". In that form every key is one of ${RIG_SKIN_KEYS.join(', ')} — so "${key}" reads ` +
1276
+ 'as neither a member list nor a slot, and a slot left outside the block is an attachment that vanishes. ' +
1277
+ 'Move it inside "attachments".',
1278
+ );
1279
+ }
1280
+ const nameList = (value: unknown, field: string): string[] => {
1281
+ if (value === undefined) return [];
1282
+ if (!Array.isArray(value)) throw new CompileError(`${where}: "${field}" must be an array of names, not ${JSON.stringify(value)}`);
1283
+ return value.map((name, i) => {
1284
+ if (typeof name !== 'string' || name.length === 0) {
1285
+ throw new CompileError(`${where}: ${field}[${i}] is ${JSON.stringify(name)}; a skin lists bones and constraints BY NAME`);
1286
+ }
1287
+ return name;
1288
+ });
1289
+ };
1290
+ const constraints = empty();
1291
+ for (const key of RIG_SKIN_CONSTRAINT_KEYS) constraints[key] = nameList(entry[key], key);
1292
+ return {
1293
+ attachments: entry.attachments ?? {},
1294
+ bones: nameList(entry.bones, 'bones'),
1295
+ constraints,
1296
+ explicit: true,
1297
+ };
1298
+ }
1299
+
1300
+ // ---------------------------------------------------------------------------
1301
+ // constraints — `root.constraints[]` (SkeletonJson.ts:144-369), the 4.3 shape
1302
+ // ---------------------------------------------------------------------------
1303
+
1304
+ /**
1305
+ * A constraint's identity in this format: its KIND and its name, never the name
1306
+ * alone (issue #692).
1307
+ *
1308
+ * `SkeletonData.findConstraint(name, type)` tests `constraint instanceof type`
1309
+ * before it compares the name, so `ik` `leg` and `transform` `leg` are two
1310
+ * objects and every resolution in the format — a timeline group, a skin's member
1311
+ * list, a slider's animation pass — reaches exactly one of them. It doubles as
1312
+ * the phrase a refusal uses, so the key a lookup misses on and the words the
1313
+ * message says it missed on cannot drift apart.
1314
+ *
1315
+ * @internal
1316
+ */
1317
+ export function constraintAt(type: string, name: string): string {
1318
+ return `${type} constraint "${name}"`;
1319
+ }
1320
+
1321
+ /**
1322
+ * 4.3 folds every constraint into ONE array with a `type` discriminator. The
1323
+ * 4.1/4.2 shape — top-level `ik`/`transform`/`path`/`physics` arrays — still
1324
+ * loads clean and the constraints simply vanish, which is `A01`.
1325
+ *
1326
+ * 🚨 An entry whose `type` matches no case is dropped with no error and no
1327
+ * `default:` branch (`:148-367`). rigc therefore refuses an unknown `type` by
1328
+ * name rather than passing it through.
1329
+ */
1330
+ export interface RigConstraintCommon {
1331
+ name: string;
1332
+ /**
1333
+ * Default false → `skinRequired` (`:147`): the constraint does not run unless
1334
+ * the applied skin lists it under its own type (see `RigSkinEntry`). Half a
1335
+ * switch on its own, so rigc refuses the flag without a skin that activates it.
1336
+ */
1337
+ skin?: boolean;
1338
+ }
1339
+
1340
+ /**
1341
+ * `ScaleYMode` (`ConstraintData.ts:37-45`), which an **ik** and a **physics**
1342
+ * constraint both carry under the JSON key `scaleY`.
1343
+ *
1344
+ * ⚠️ Resolved by `Utils.enumValue`, so only the first letter's case is free and
1345
+ * an unresolved name is assigned as `undefined` without a word — the hazard
1346
+ * `RIG_PATH_POSITION_MODES` is checked for, at a field that had no check at all.
1347
+ */
1348
+ export const RIG_SCALE_Y_MODES = ['None', 'Uniform', 'Volume'] as const;
1349
+
1350
+ /** The three names, as a rig spec writes them. */
1351
+ export type RigScaleYMode = 'none' | 'uniform' | 'volume' | 'None' | 'Uniform' | 'Volume';
1352
+
1353
+ /** `type: "ik"` (`:149-176`). `scaleY` is 4.3's replacement for 4.2's `uniform`. */
1354
+ export interface RigIkConstraint extends RigConstraintCommon {
1355
+ type: 'ik';
1356
+ /**
1357
+ * One bone, or two where the second's parent is the first; resolved by
1358
+ * name, and a miss throws in the parser. Any other shape is refused by name
1359
+ * (issue #1205): the runtime applies nothing for three or more, and solves
1360
+ * a pair through the first bone's matrix from the second's local offset, so
1361
+ * a bone standing between them is left out of the triangle it solves.
1362
+ */
1363
+ bones: string[];
1364
+ target: string;
1365
+ /** `ConstraintData.ts:50`. Absent → `None`. */
1366
+ scaleY?: RigScaleYMode;
1367
+ /** Default 1. */
1368
+ mix?: number;
1369
+ /** Default 0. */
1370
+ softness?: number;
1371
+ /** Default true → `bendDirection = ±1`. */
1372
+ bendPositive?: boolean;
1373
+ /** Default false. */
1374
+ compress?: boolean;
1375
+ stretch?: boolean;
1376
+ }
1377
+
1378
+ /**
1379
+ * One entry of a transform constraint's `properties` map: which source property
1380
+ * drives which target properties, and by how much (`:241`, `:521`).
1381
+ *
1382
+ * The `from` and `to` names are drawn from a fixed six — `rotate`, `x`, `y`,
1383
+ * `scaleX`, `scaleY`, `shearY` — and **anything else throws in the parser**.
1384
+ */
1385
+ export interface RigTransformProperty {
1386
+ offset?: number;
1387
+ to: Record<string, RigTransformTo>;
1388
+ }
1389
+
1390
+ /** One driven property of a `RigTransformProperty.to` map. */
1391
+ export interface RigTransformTo {
1392
+ offset?: number;
1393
+ max?: number;
1394
+ scale?: number;
1395
+ }
1396
+
1397
+ /** `type: "transform"` (`:177-268`) — rebuilt from scratch in 4.3. */
1398
+ export interface RigTransformConstraint extends RigConstraintCommon {
1399
+ type: 'transform';
1400
+ bones: string[];
1401
+ /** 4.2 called this `target`. */
1402
+ source: string;
1403
+ localSource?: boolean;
1404
+ localTarget?: boolean;
1405
+ additive?: boolean;
1406
+ clamp?: boolean;
1407
+ /** `fromName -> { offset, to: { toName -> { offset, max, scale } } }`. */
1408
+ properties?: Record<string, RigTransformProperty>;
1409
+ /** The offsets array. Default 0 each. */
1410
+ rotation?: number;
1411
+ x?: number;
1412
+ y?: number;
1413
+ scaleX?: number;
1414
+ scaleY?: number;
1415
+ shearY?: number;
1416
+ /**
1417
+ * Default 1. ⚠️ Each mix is read **only if the matching `to` property was
1418
+ * declared** (`:259-264`), so a mix without its property is dead data.
1419
+ */
1420
+ mixRotate?: number;
1421
+ mixX?: number;
1422
+ /** Defaults to `mixX`. */
1423
+ mixY?: number;
1424
+ mixScaleX?: number;
1425
+ /** Defaults to `mixScaleX`. */
1426
+ mixScaleY?: number;
1427
+ mixShearY?: number;
1428
+ }
1429
+
1430
+ /**
1431
+ * `type: "physics"` (`:301-339`), 4.2+.
1432
+ *
1433
+ * ⚠️ The five components all default to 0, so a constraint that names none of
1434
+ * them parses cleanly and does absolutely nothing — `A23`.
1435
+ */
1436
+ export interface RigPhysicsConstraint extends RigConstraintCommon {
1437
+ type: 'physics';
1438
+ bone: string;
1439
+ /** The components. All zero = a constraint that parses and does nothing. */
1440
+ x?: number;
1441
+ y?: number;
1442
+ rotate?: number;
1443
+ scaleX?: number;
1444
+ shearX?: number;
1445
+ /**
1446
+ * 4.3; absent → `ScaleYMode.None`. Same field, same enum and same spelling as
1447
+ * `RigIkConstraint.scaleY`.
1448
+ *
1449
+ * 🚨 It was called `scaleYMode` from this file's first commit (c0e9944,
1450
+ * 2026-08-22) until issue #545, and the name was not a synonym — it was the
1451
+ * one key in this file that no code anywhere read.
1452
+ * `SkeletonJson.js:299` is `getValue(constraintMap, "scaleY", null)`, so the
1453
+ * emitter copied `scaleY`, an author writing TypeScript against this interface
1454
+ * got a type error on the key that works and silence on the key that does
1455
+ * nothing, and `scaleYMode` occurred exactly once in the whole tree: here.
1456
+ *
1457
+ * ⭐ The rename rather than teaching the emitter to read `scaleYMode`, and the
1458
+ * argument is not "the format's name wins" in the abstract — the tree had
1459
+ * already answered it four hundred lines above. `RigIkConstraint.scaleY`
1460
+ * carries the *same* `ScaleYMode` enum through the *same* `Utils.enumValue`
1461
+ * call under the *same* JSON key, and spells it `scaleY`. Teaching the emitter
1462
+ * the other name would have left one Spine enum with two rig-spec spellings
1463
+ * chosen by constraint type, which is a worse format than either name alone.
1464
+ */
1465
+ scaleY?: RigScaleYMode;
1466
+ /** Default 5000. */
1467
+ limit?: number;
1468
+ /** Default 60 → `step = 1/fps`. */
1469
+ fps?: number;
1470
+ /** Defaults: 0.5 / 100 / 0.85 / 1 / 0 / 0 / 1. */
1471
+ inertia?: number;
1472
+ strength?: number;
1473
+ damping?: number;
1474
+ /** Stored as `massInverse = 1/mass`, so 0 becomes Infinity — `A23`. */
1475
+ mass?: number;
1476
+ wind?: number;
1477
+ gravity?: number;
1478
+ mix?: number;
1479
+ inertiaGlobal?: boolean;
1480
+ strengthGlobal?: boolean;
1481
+ dampingGlobal?: boolean;
1482
+ massGlobal?: boolean;
1483
+ windGlobal?: boolean;
1484
+ gravityGlobal?: boolean;
1485
+ mixGlobal?: boolean;
1486
+ }
1487
+
1488
+ /**
1489
+ * The three enums a path constraint chooses its model with, spelled as the
1490
+ * runtime's own enum members (`PathConstraintData.ts:77-87`).
1491
+ *
1492
+ * 🚨 They are checked, and this is one of the places where checking matters most:
1493
+ * `Utils.enumValue` is `type[name[0].toUpperCase() + name.slice(1)]`, so a name
1494
+ * outside the set resolves to **`undefined`** and is assigned without complaint.
1495
+ * The constraint then behaves as some *other* mode — an unknown `spacingMode`
1496
+ * fails the `=== Length` test and spaces bones as though `Fixed` had been asked
1497
+ * for; an unknown `rotateMode` is neither `Tangent` nor `ChainScale`, so bones
1498
+ * follow the path and never turn along it. Both load, both animate, neither is
1499
+ * what was written. Only the first letter's case is free, because that is exactly
1500
+ * what `enumValue` normalises.
1501
+ */
1502
+ export const RIG_PATH_POSITION_MODES = ['Fixed', 'Percent'] as const;
1503
+ export const RIG_PATH_SPACING_MODES = ['Length', 'Fixed', 'Percent', 'Proportional'] as const;
1504
+ export const RIG_PATH_ROTATE_MODES = ['Tangent', 'Chain', 'ChainScale'] as const;
1505
+
1506
+ /** The six property names a slider or a transform constraint may read (`:241`, `:521`). */
1507
+ export const RIG_FROM_PROPERTIES = ['rotate', 'x', 'y', 'scaleX', 'scaleY', 'shearY'] as const;
1508
+
1509
+ /**
1510
+ * `type: "path"` (`:269-300`).
1511
+ *
1512
+ * **When you need one:** anything that travels — a cart along a track, a fish
1513
+ * along a current, a chain of links wrapping a pulley. The constraint takes a
1514
+ * `RigPathAttachment` off `slot` and slides `bones` along it, so one `position`
1515
+ * key moves the whole train and the shape of the motion lives in the curve
1516
+ * rather than in the keys.
1517
+ *
1518
+ * ⚠️ `slot` must be a slot that carries a path attachment. `PathConstraint.update`
1519
+ * begins `if (!(attachment instanceof PathAttachment)) return` — so a constraint
1520
+ * aimed at a slot showing a region does nothing at all, with no error anywhere.
1521
+ * rigc refuses a slot that has no path attachment in any skin.
1522
+ */
1523
+ export interface RigPathConstraint extends RigConstraintCommon {
1524
+ type: 'path';
1525
+ /** At least one, in the order they ride the path. Resolved by name. */
1526
+ bones: string[];
1527
+ /** The slot whose path attachment the bones follow. Required by the parser. */
1528
+ slot: string;
1529
+ /** Default `"Percent"`: `position` 0..1 along the path rather than in world units. */
1530
+ positionMode?: (typeof RIG_PATH_POSITION_MODES)[number] | 'fixed' | 'percent';
1531
+ /** Default `"Length"`: spacing measured in each bone's own length. */
1532
+ spacingMode?: (typeof RIG_PATH_SPACING_MODES)[number] | 'length' | 'fixed' | 'percent' | 'proportional';
1533
+ /** Default `"Tangent"`: each bone turns to the path's tangent where it sits. */
1534
+ rotateMode?: (typeof RIG_PATH_ROTATE_MODES)[number] | 'tangent' | 'chain' | 'chainScale';
1535
+ /** Default 0 → `offsetRotation`, degrees added after the path's own rotation. */
1536
+ rotation?: number;
1537
+ /** Default 0. Where the first bone sits: 0..1 under `Percent`, world units under `Fixed`. */
1538
+ position?: number;
1539
+ /** Default 0. Gap between bones, in the unit `spacingMode` chooses. */
1540
+ spacing?: number;
1541
+ /** Default 1. */
1542
+ mixRotate?: number;
1543
+ mixX?: number;
1544
+ /** Defaults to `mixX` (`:283`). */
1545
+ mixY?: number;
1546
+ }
1547
+
1548
+ /**
1549
+ * `type: "slider"` (`:340-366`) — 4.3's own constraint, and the only one that
1550
+ * applies an **animation** rather than a transform.
1551
+ *
1552
+ * **When you need one:** a pose that has to be driven by a value instead of by
1553
+ * time — a dial that opens a door, a blend shape on a face, a suspension that
1554
+ * compresses as the wheel rises. `animation` is applied at a time the slider
1555
+ * chooses, `mix` is its authority, and everything that animation keys is under
1556
+ * its control while it is on.
1557
+ *
1558
+ * The time comes from one of two models and `bone` is the switch (`:350`):
1559
+ *
1560
+ * - **property-driven** — with a `bone`, the slider reads one transform
1561
+ * `property` off it and maps it to a time: `time = to + (value - from) * scale`.
1562
+ * This is the dial.
1563
+ * - **time-driven** — with no `bone`, `time` is the slider's own setup value and
1564
+ * an `animations.<a>.slider.<name>.time` timeline keys it.
1565
+ *
1566
+ * ⚠️ `animation` is resolved in a **second pass** over the constraints array,
1567
+ * after the animations are read (`:495-507`), and a miss **throws**
1568
+ * `Slider animation not found`. It names an animation in the MOTION spec — the
1569
+ * one place a rig spec points across the file boundary, and the mirror of
1570
+ * `events`, where the rig declares a name the motion spec fires.
1571
+ */
1572
+ export interface RigSliderConstraint extends RigConstraintCommon {
1573
+ type: 'slider';
1574
+ /** An animation the motion spec declares. Required: a miss throws in the parser. */
1575
+ animation: string;
1576
+ /** Default 1. The slider's authority over what its animation keys. */
1577
+ mix?: number;
1578
+ /** Default false. Add the animation to the current pose instead of overwriting it. */
1579
+ additive?: boolean;
1580
+ /**
1581
+ * Default false. Repeat past the animation's duration instead of holding the
1582
+ * last frame. ⚠️ With a `bone`, `loop` divides by the animation's duration
1583
+ * (`Slider.ts:63-64`), so looping a zero-length animation yields a NaN time.
1584
+ */
1585
+ loop?: boolean;
1586
+ /** The driving bone. Its presence switches the whole model — see above. */
1587
+ bone?: string;
1588
+ /** Which of the six transform properties to read. Required when `bone` is set. */
1589
+ property?: (typeof RIG_FROM_PROPERTIES)[number];
1590
+ /** Default 0. The property value that maps to time `to`. */
1591
+ from?: number;
1592
+ /** Default 0. The time `from` maps to. */
1593
+ to?: number;
1594
+ /** Default 1. Seconds of animation per unit of the property. */
1595
+ scale?: number;
1596
+ /** Default 0. Nonessential: the editor's top of the slider's range. */
1597
+ max?: number;
1598
+ /** Default false. Read the bone's local transform instead of its world one. */
1599
+ local?: boolean;
1600
+ /** The setup time, for the time-driven model. Read only when `bone` is absent. */
1601
+ time?: number;
1602
+ }
1603
+
1604
+ export type RigConstraint =
1605
+ | RigIkConstraint
1606
+ | RigTransformConstraint
1607
+ | RigPathConstraint
1608
+ | RigPhysicsConstraint
1609
+ | RigSliderConstraint;
1610
+
1611
+ // ---------------------------------------------------------------------------
1612
+ // events — `root.events` (SkeletonJson.ts:469-484), an OBJECT, not an array
1613
+ // ---------------------------------------------------------------------------
1614
+
1615
+ /**
1616
+ * One event **definition**: a name the skeleton owns, plus the payload a firing
1617
+ * carries when the animation does not override it.
1618
+ *
1619
+ * ⭐ The declaration lives in the rig spec and the firings live in the motion
1620
+ * spec, for the same reason slots live here and their colour keys live there:
1621
+ * the name is structure — the runtime looks it up, the game listens for it —
1622
+ * and *when* it fires is time. `skeletonData.findEvent` resolves an animation's
1623
+ * key against this table and **throws** on a miss (`:1244`), so an animation
1624
+ * that names an event nobody declared does not load at all. rigc refuses it at
1625
+ * compile instead, where the message can name the file that has to change.
1626
+ *
1627
+ * ⚠️ `volume` and `balance` are read **only when `audio` is set** (`:478-481`).
1628
+ * Declared without one they are dropped in silence, so rigc refuses that pairing
1629
+ * rather than emitting two numbers the runtime will never look at.
1630
+ */
1631
+ export interface RigEvent {
1632
+ /** Default 0. The `int` payload every firing inherits unless it overrides it. */
1633
+ int?: number;
1634
+ /** Default 0. */
1635
+ float?: number;
1636
+ /** Default `""`. */
1637
+ string?: string;
1638
+ /**
1639
+ * Audio path the editor recorded for this event. Nonessential to playback —
1640
+ * no runtime here loads it — but it is what makes `volume`/`balance` legible.
1641
+ */
1642
+ audio?: string;
1643
+ /** Only read when `audio` is set. */
1644
+ volume?: number;
1645
+ balance?: number;
1646
+ }
1647
+
1648
+ // ---------------------------------------------------------------------------
1649
+ // invariants — what skeleton JSON cannot say about itself
1650
+ // ---------------------------------------------------------------------------
1651
+
1652
+ /**
1653
+ * Structural facts the emitted artifact does not record, handed to the validator
1654
+ * so its archetype assertions have something to check instead of a guess.
1655
+ *
1656
+ * These are the fields that used to be properties of a hard-coded formation.
1657
+ * They are optional, and an assertion whose field is absent reports **SKIP** —
1658
+ * never a pass, because an assertion with nothing to look at has not looked.
1659
+ */
1660
+ export interface RigInvariants {
1661
+ /**
1662
+ * How many slots of this rig may carry a mesh. A budget, not a Spine rule:
1663
+ * every mesh is a canvas that re-rasterises whenever a bone driving it moves.
1664
+ */
1665
+ meshSlots?: number;
1666
+ /**
1667
+ * How many triangles one of those meshes may carry. Also a budget, and also
1668
+ * not a Spine rule — the editor's own example projects ship meshes several
1669
+ * times this size and they are perfectly valid.
1670
+ *
1671
+ * ⚠️ Declare it or `A13_MESH_BUDGET` has nothing to measure against and SKIPs.
1672
+ * A number baked into the validator would be one project's frame time
1673
+ * masquerading as a property of the format, and would fail every foreign
1674
+ * skeleton that is merely denser than that project can afford.
1675
+ */
1676
+ meshTriangles?: number;
1677
+ /**
1678
+ * The bone whose setup rotation carries the cut's insertion axis. Its subtree
1679
+ * is authored in **axis space** — translateX only — which is what lets one set
1680
+ * of keys move to a cut at another camera angle (`A24`).
1681
+ */
1682
+ axisBone?: string;
1683
+ /**
1684
+ * The bone carrying the inserting mass. Its own inward keys spend the same
1685
+ * clearance the stroke does, so `A29`/`A30` add them together.
1686
+ */
1687
+ massBone?: string;
1688
+ /** Parentage that must never happen, with the reason it is tempting (`A25`). */
1689
+ detached?: RigDetachedRule[];
1690
+ /**
1691
+ * Mesh slots whose `deform` timelines are allowed to turn a triangle inside
1692
+ * out, exempting them from `A39_DEFORM_KEEPS_TRIANGLE_WINDING`.
1693
+ *
1694
+ * 🚨 **This is an opt-OUT, and the default is gated.** A39's whole value is
1695
+ * that a fold is caught without anybody suspecting one, so an author who has
1696
+ * not thought about folding gets the check. What the field exists for is the
1697
+ * art that folds on purpose — a page turning over, a cloth creasing back on
1698
+ * itself — where the reversed winding IS the drawing and refusing it would be
1699
+ * refusing correct work.
1700
+ *
1701
+ * ⚠️ `why` is **required**, and empty is refused by name. An exemption with no
1702
+ * reason is the failure mode this field would otherwise introduce: somebody
1703
+ * declares a slot to get a green build, and the next reader cannot tell a
1704
+ * deliberate page turn from a defect that was waved through. The one exemption
1705
+ * in this repository (`gallery/flex`'s leaf) says outright that it is the
1706
+ * second kind, and names the issue.
1707
+ */
1708
+ deformMayFold?: RigDeformFoldExemption[];
1709
+ /**
1710
+ * Ik and transform constraints whose mix **the consumer sets**, from code,
1711
+ * rather than any animation in this file — so `A47` / `A48` do not refuse
1712
+ * them for resting muted with nothing keying them up (issue #784).
1713
+ *
1714
+ * 🔑 **The file cannot say this about itself.** A constraint resting at 0
1715
+ * that no animation keys above 0 is either a leftover that moves nothing or a
1716
+ * dial a game turns on at runtime, and the two export as the same bytes. The
1717
+ * gate refuses the shape because nothing *in the file* ever moves it; this is
1718
+ * the statement that something outside it does. It is the rig-side spelling of
1719
+ * `gallery/look`'s rule that a face angle is a value rather than a time: the
1720
+ * object offers the dial and the consumer decides when it turns.
1721
+ *
1722
+ * 🚨 **An opt-OUT, held to `deformMayFold`'s standard.** Each entry names the
1723
+ * constraint AND its `type` — names are unique per kind (issue #692), so a
1724
+ * name alone could mean an ik and a transform at once — and a `why`, required
1725
+ * and non-blank. A name that resolves to nothing, a kind that has no such
1726
+ * rule (a path, physics or slider constraint: `A36`, `A23` and `A37` read no
1727
+ * declaration, so the entry would exempt nothing), a repeat and a blank `why`
1728
+ * are each refused by name. A declared constraint that the file itself
1729
+ * switches on — resting live, or keyed above 0 — is refused at the gate, for
1730
+ * the same reason: the declaration would exempt nothing.
1731
+ *
1732
+ * ⚠️ What it buys is a SKIP, never a pass: a declared constraint is not
1733
+ * measured, and `A47`/`A48` say so by name when nothing else of that kind is
1734
+ * left to measure, and on the build's stats line when something is.
1735
+ */
1736
+ consumerDrivenMix?: RigConsumerDrivenMix[];
1737
+ /**
1738
+ * The rig's `idle` deforms meshes **on purpose**, so
1739
+ * `A15_IDLE_NO_MESH_BONE_KEYS` reports the renderer cost instead of refusing
1740
+ * each keyed bone (issues #855, #858).
1741
+ *
1742
+ * 🔑 **What A15 assumes, and the genre that breaks it.** The rule exists for a
1743
+ * renderer that skips redrawing a mesh nothing moved, so an `idle` keying a
1744
+ * mesh-driving bone spends that skip on every frame — right for a rig whose
1745
+ * meshes are mostly static, wrong for a painting rig: one illustration
1746
+ * decomposed into layers, most of them weighted meshes over bone chains, with
1747
+ * an `idle` whose whole job is to move them (hair, sleeves, breathing). The
1748
+ * only way through without this field was a same-origin parent above every
1749
+ * keyed bone — 42 extra bones on the first such rig, the same pose, the rule's
1750
+ * wording met and its purpose not.
1751
+ *
1752
+ * 🚨 **An opt-OUT, held to `deformMayFold`'s standard.** The shape is
1753
+ * `{ "why": … }` and a missing, blank or non-string `why` is refused by name.
1754
+ * What it buys is a **SKIP, never a pass**, and the SKIP carries the cost —
1755
+ * the keyed bones, how many mesh attachments they drive and how many vertices
1756
+ * those hold. A declaration on a rig whose `idle` keys no mesh-driving bone is
1757
+ * refused at the gate: it would switch off nothing while reading like it did.
1758
+ */
1759
+ idleDrivesMeshes?: RigIdleDrivesMeshes;
1760
+ }
1761
+
1762
+ /** The rig's `idle` is meant to deform meshes — `invariants.idleDrivesMeshes` (`A15`). */
1763
+ export interface RigIdleDrivesMeshes {
1764
+ /** Required, and blank is refused by name — see `idleDrivesMeshes`. */
1765
+ why: string;
1766
+ }
1767
+
1768
+ /** One constraint whose mix the consumer drives — `invariants.consumerDrivenMix`. */
1769
+ export interface RigConsumerDrivenMix {
1770
+ /** The constraint's `name`. */
1771
+ constraint: string;
1772
+ /** Its `type`: `ik` or `transform`, the two kinds `A47`/`A48` read the declaration for. */
1773
+ type: 'ik' | 'transform';
1774
+ /** Required, and blank is refused by name — see `consumerDrivenMix`. */
1775
+ why: string;
1776
+ }
1777
+
1778
+ /** One forbidden parentage — `invariants.detached` (`A25`). */
1779
+ export interface RigDetachedRule {
1780
+ bone: string;
1781
+ notUnder: string;
1782
+ why?: string;
1783
+ }
1784
+
1785
+ /** One slot exempted from `A39` — `invariants.deformMayFold`. */
1786
+ export interface RigDeformFoldExemption {
1787
+ slot: string;
1788
+ /** Required, and empty is refused by name — see `deformMayFold`. */
1789
+ why: string;
1790
+ }
1791
+
1792
+ // ---------------------------------------------------------------------------
1793
+ // the file
1794
+ // ---------------------------------------------------------------------------
1795
+
1796
+ export interface RigSpec {
1797
+ spec: 'rigc-rig/1';
1798
+ /**
1799
+ * The rig's own name. A motion spec's `archetype` field must equal it: the
1800
+ * spec was authored against one formation, and pairing it with a different rig
1801
+ * silently produces keys aimed at bones that mean something else.
1802
+ */
1803
+ name: string;
1804
+ note?: string;
1805
+ skeleton?: RigSkeletonHeader;
1806
+ /**
1807
+ * Base directory for every `image` in this file, relative to the rig file
1808
+ * itself. The CLI's `--images <dir>` overrides it (and is then relative to the
1809
+ * working directory), which is how a foreign corpus is compiled without
1810
+ * editing its rig spec.
1811
+ */
1812
+ images?: string;
1813
+ bones: RigBone[];
1814
+ slots: RigSlot[];
1815
+ /**
1816
+ * `default`, if the rig has one, becomes `skeletonData.defaultSkin` (`:441`).
1817
+ * It is emitted exactly when this map carries the key — an empty `{}` included
1818
+ * — or a manifest part files its states under it; a rig whose art is all in
1819
+ * named skins states no `default` and gets none, which is what the editor's
1820
+ * export of such a rig declares (issue #801).
1821
+ *
1822
+ * Each entry is either the short form — `slotName -> placeholderName ->
1823
+ * attachment` — or the long one, `{ "attachments": {…}, "bones": [...],
1824
+ * "ik": [...] }`, which also says which bones and constraints the skin
1825
+ * activates. `splitRigSkin` normalises the two.
1826
+ */
1827
+ skins?: Record<string, RigSkin>;
1828
+ constraints?: RigConstraint[];
1829
+ /**
1830
+ * `eventName -> payload defaults`. Emitted as `root.events`, which is an
1831
+ * OBJECT keyed by name and not an array. The motion spec's per-animation
1832
+ * `events` timeline fires them; a firing whose name is not a key here is a
1833
+ * compile error, because the parser throws on it at load.
1834
+ */
1835
+ events?: Record<string, RigEvent>;
1836
+ invariants?: RigInvariants;
1837
+ }
1838
+
1839
+ // ---------------------------------------------------------------------------
1840
+ // reading
1841
+ // ---------------------------------------------------------------------------
1842
+
1843
+ function isObj(v: unknown): v is Record<string, unknown> {
1844
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
1845
+ }
1846
+
1847
+ /**
1848
+ * Every key each shape of this format owns, keyed by the interface above that
1849
+ * declares it — the runtime shadow of the types, which TypeScript erases.
1850
+ *
1851
+ * 🔒 **Hand-written and mechanically held to the interfaces.** `CUR17` in
1852
+ * `selftest.ts` reads this file's own source, extracts each named interface's
1853
+ * field list and compares it to the entry here, so the pair cannot drift: adding
1854
+ * a field and forgetting this table is a red run, not a key an author cannot
1855
+ * write. `CUR18` closes the other direction — a key declared here and occurring
1856
+ * nowhere else in the tree is refused, which is exactly what `scaleYMode` was.
1857
+ *
1858
+ * ⚠️ `RigUnimplementedAttachment` is deliberately absent. It carries
1859
+ * `[field: string]: unknown` because its whole job is to let a spec *say* a
1860
+ * `point` or a `linkedmesh` and get a named `NotImplementedError` back; checking
1861
+ * the keys of an attachment rigc is about to refuse by type would name the wrong
1862
+ * fault.
1863
+ */
1864
+ export const RIG_KEYS = {
1865
+ RigSpec: ['spec', 'name', 'note', 'skeleton', 'images', 'bones', 'slots', 'skins', 'constraints', 'events', 'invariants'],
1866
+ RigSkeletonHeader: ['x', 'y', 'width', 'height', 'fps', 'referenceScale', 'images', 'audio', 'stageBox'],
1867
+ RigStageBox: ['slot', 'attachment'],
1868
+ RigBone: ['name', 'parent', 'length', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY', 'inherit', 'skin', 'color', 'icon', 'from'],
1869
+ RigBoneFrom: ['anchor', 'slotWindow', 'meshCenter', 'rotation'],
1870
+ RigSlot: ['name', 'bone', 'attachment', 'color', 'dark', 'blend'],
1871
+ RigEvent: ['int', 'float', 'string', 'audio', 'volume', 'balance'],
1872
+ RigInvariants: ['meshSlots', 'meshTriangles', 'axisBone', 'massBone', 'detached', 'deformMayFold', 'consumerDrivenMix', 'idleDrivesMeshes'],
1873
+ RigIdleDrivesMeshes: ['why'],
1874
+ RigDetachedRule: ['bone', 'notUnder', 'why'],
1875
+ RigDeformFoldExemption: ['slot', 'why'],
1876
+ RigConsumerDrivenMix: ['constraint', 'type', 'why'],
1877
+ // Not retyped: `RIG_SKIN_KEYS` already IS this set, and it is the set
1878
+ // `splitRigSkin` refuses a long-form skin's stray key against. A second
1879
+ // spelling of it here would be two lists that have to agree, which is the
1880
+ // defect this whole table is checked to avoid.
1881
+ RigSkinEntry: RIG_SKIN_KEYS,
1882
+ RigIkConstraint: ['name', 'skin', 'type', 'bones', 'target', 'scaleY', 'mix', 'softness', 'bendPositive', 'compress', 'stretch'],
1883
+ RigTransformConstraint: [
1884
+ 'name', 'skin', 'type', 'bones', 'source', 'localSource', 'localTarget', 'additive', 'clamp', 'properties',
1885
+ 'rotation', 'x', 'y', 'scaleX', 'scaleY', 'shearY',
1886
+ 'mixRotate', 'mixX', 'mixY', 'mixScaleX', 'mixScaleY', 'mixShearY',
1887
+ ],
1888
+ RigPathConstraint: [
1889
+ 'name', 'skin', 'type', 'bones', 'slot', 'positionMode', 'spacingMode', 'rotateMode',
1890
+ 'rotation', 'position', 'spacing', 'mixRotate', 'mixX', 'mixY',
1891
+ ],
1892
+ RigPhysicsConstraint: [
1893
+ 'name', 'skin', 'type', 'bone', 'x', 'y', 'rotate', 'scaleX', 'shearX', 'scaleY', 'limit', 'fps',
1894
+ 'inertia', 'strength', 'damping', 'mass', 'wind', 'gravity', 'mix',
1895
+ 'inertiaGlobal', 'strengthGlobal', 'dampingGlobal', 'massGlobal', 'windGlobal', 'gravityGlobal', 'mixGlobal',
1896
+ ],
1897
+ RigSliderConstraint: [
1898
+ 'name', 'skin', 'type', 'animation', 'mix', 'additive', 'loop', 'bone', 'property', 'from', 'to', 'scale',
1899
+ 'max', 'local', 'time',
1900
+ ],
1901
+ RigTransformProperty: ['offset', 'to'],
1902
+ RigTransformTo: ['offset', 'max', 'scale'],
1903
+ RigRegionAttachment: ['type', 'name', 'path', 'image', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'width', 'height', 'color', 'sequence'],
1904
+ RigMeshAttachment: [
1905
+ 'type', 'name', 'path', 'image', 'uvs', 'triangles', 'vertices', 'weights', 'boneIndexing', 'hull', 'edges',
1906
+ 'width', 'height', 'color', 'generator', 'sequence',
1907
+ ],
1908
+ RigLinkedMeshAttachment: [
1909
+ 'type', 'name', 'path', 'image', 'source', 'slot', 'skin', 'timelines', 'width', 'height', 'color', 'sequence',
1910
+ // Declared so the refusal can name them — see `RigLinkedMeshAttachment`.
1911
+ 'uvs', 'triangles', 'vertices', 'weights', 'boneIndexing', 'hull', 'edges', 'generator',
1912
+ ],
1913
+ RigMeshBinding: ['bone', 'x', 'y', 'weight'],
1914
+ RigSequence: ['count', 'start', 'digits', 'setup'],
1915
+ RigBoundingBoxAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name'],
1916
+ RigClippingAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name', 'end', 'convex', 'inverse'],
1917
+ RigPathAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'name', 'closed', 'constantSpeed', 'lengths'],
1918
+ RigRingGenerator: ['kind', 'hull', 'center', 'inner', 'size', 'bias', 'controls'],
1919
+ RigRibbonGenerator: ['kind', 'size', 'rows', 'chain'],
1920
+ RigContourGenerator: ['kind', 'tolerance', 'margin', 'maxVertices', 'alpha', 'depth', 'soft'],
1921
+ RigGridGenerator: ['kind', 'us', 'vs', 'cols', 'rows', 'depth', 'soft'],
1922
+ RigSegmentsGenerator: ['kind', 'cell', 'bones', 'falloff', 'alpha', 'anchor'],
1923
+ RigSegmentsFalloff: ['power', 'radius', 'maxBones', 'minWeight'],
1924
+ RigSegmentSpan: ['bone', 'from', 'to'],
1925
+ RigMeshBias: ['axis_deg', 'ramp'],
1926
+ RigDepthMap: ['image', 'near', 'zScale', 'gamma', 'contrast', 'bias'],
1927
+ RigSoftRegion: ['bone', 'mask'],
1928
+ } as const satisfies Record<string, readonly string[]>;
1929
+
1930
+ /**
1931
+ * The type every key of `RIG_KEYS` holds, shape by shape — what
1932
+ * `refuseValuesOfTheWrongType` refuses a value against (issue #890).
1933
+ *
1934
+ * 🔒 **Beside the key table, and held equal to it twice.** `satisfies` over
1935
+ * `RigTypeTable` makes a key typed here and absent there, or admitted there and
1936
+ * untyped here, a type error; the selftest compares the two at runtime as well,
1937
+ * and derives each checked type from the interface the row is named for, so a
1938
+ * field declared `number` and typed `string` here is a red run rather than a
1939
+ * refusal of correct work.
1940
+ *
1941
+ * Where an unchecked `object` or `mixed` row is refused: a nested shape by the
1942
+ * reader that walks it; `RigSegmentsGenerator.bones` (`mixed`: a name, a chain
1943
+ * of names or a span) by the segments generator, by index. Where an `enum` row
1944
+ * is refused is not a list in prose any more: `RIG_ENUMS`, below, has one entry
1945
+ * per `enum` row — a set refused by `refuseValuesOutsideTheirSet`, or the
1946
+ * reader that refuses it — and `satisfies` makes a row without one a type error.
1947
+ *
1948
+ * ⚠️ Until issue #900 this comment carried that list, and ended by naming three
1949
+ * `enum` keys with **no** owner: `boneIndexing`, `from.rotation` and a
1950
+ * generator `kind`. The list was right about the three and wrong by omission
1951
+ * about a fourth — the cut manifest's `mesh.kind`, which `MANIFEST_TYPES` said
1952
+ * "the manifest mesh reader" held and which that reader read as a ribbon in one
1953
+ * place and a ring in another. A list of owners in a comment is a claim nothing
1954
+ * checks; the table is one a control reads.
1955
+ */
1956
+ type RigTypeTable = {
1957
+ readonly [S in keyof typeof RIG_KEYS]: { readonly [K in (typeof RIG_KEYS)[S][number]]: SpecValueType };
1958
+ };
1959
+
1960
+ export const RIG_TYPES = {
1961
+ RigSpec: {
1962
+ spec: 'enum', name: 'string', note: 'string', skeleton: 'object', images: 'string', bones: 'object[]', slots: 'object[]',
1963
+ skins: 'map of object', constraints: 'object[]', events: 'map of object', invariants: 'object',
1964
+ },
1965
+ RigSkeletonHeader: {
1966
+ x: 'number', y: 'number', width: 'number | null', height: 'number | null', fps: 'number', referenceScale: 'number',
1967
+ images: 'string', audio: 'string | null', stageBox: 'object',
1968
+ },
1969
+ RigStageBox: { slot: 'string', attachment: 'string' },
1970
+ RigBone: {
1971
+ name: 'string', parent: 'string', length: 'number', x: 'number', y: 'number', rotation: 'number', scaleX: 'number',
1972
+ scaleY: 'number', shearX: 'number', shearY: 'number', inherit: 'enum', skin: 'boolean', color: 'string', icon: 'string',
1973
+ from: 'object',
1974
+ },
1975
+ RigBoneFrom: { anchor: 'string', slotWindow: 'string', meshCenter: 'string', rotation: 'enum' },
1976
+ RigSlot: { name: 'string', bone: 'string', attachment: 'string | null', color: 'string', dark: 'string', blend: 'enum' },
1977
+ RigEvent: { int: 'number', float: 'number', string: 'string', audio: 'string', volume: 'number', balance: 'number' },
1978
+ RigInvariants: {
1979
+ meshSlots: 'number', meshTriangles: 'number', axisBone: 'string', massBone: 'string', detached: 'object[]',
1980
+ deformMayFold: 'object[]', consumerDrivenMix: 'object[]', idleDrivesMeshes: 'object',
1981
+ },
1982
+ RigIdleDrivesMeshes: { why: 'string' },
1983
+ RigDetachedRule: { bone: 'string', notUnder: 'string', why: 'string' },
1984
+ RigDeformFoldExemption: { slot: 'string', why: 'string' },
1985
+ RigConsumerDrivenMix: { constraint: 'string', type: 'enum', why: 'string' },
1986
+ RigSkinEntry: {
1987
+ attachments: 'map of object', bones: 'string[]', ik: 'string[]', transform: 'string[]', path: 'string[]',
1988
+ physics: 'string[]', slider: 'string[]',
1989
+ },
1990
+ RigIkConstraint: {
1991
+ name: 'string', skin: 'boolean', type: 'enum', bones: 'string[]', target: 'string', scaleY: 'enum', mix: 'number',
1992
+ softness: 'number', bendPositive: 'boolean', compress: 'boolean', stretch: 'boolean',
1993
+ },
1994
+ RigTransformConstraint: {
1995
+ name: 'string', skin: 'boolean', type: 'enum', bones: 'string[]', source: 'string', localSource: 'boolean',
1996
+ localTarget: 'boolean', additive: 'boolean', clamp: 'boolean', properties: 'map of object',
1997
+ rotation: 'number', x: 'number', y: 'number', scaleX: 'number', scaleY: 'number', shearY: 'number',
1998
+ mixRotate: 'number', mixX: 'number', mixY: 'number', mixScaleX: 'number', mixScaleY: 'number', mixShearY: 'number',
1999
+ },
2000
+ RigPathConstraint: {
2001
+ name: 'string', skin: 'boolean', type: 'enum', bones: 'string[]', slot: 'string', positionMode: 'enum',
2002
+ spacingMode: 'enum', rotateMode: 'enum', rotation: 'number', position: 'number', spacing: 'number',
2003
+ mixRotate: 'number', mixX: 'number', mixY: 'number',
2004
+ },
2005
+ RigPhysicsConstraint: {
2006
+ name: 'string', skin: 'boolean', type: 'enum', bone: 'string', x: 'number', y: 'number', rotate: 'number',
2007
+ scaleX: 'number', shearX: 'number', scaleY: 'enum', limit: 'number', fps: 'number', inertia: 'number',
2008
+ strength: 'number', damping: 'number', mass: 'number', wind: 'number', gravity: 'number', mix: 'number',
2009
+ inertiaGlobal: 'boolean', strengthGlobal: 'boolean', dampingGlobal: 'boolean', massGlobal: 'boolean',
2010
+ windGlobal: 'boolean', gravityGlobal: 'boolean', mixGlobal: 'boolean',
2011
+ },
2012
+ RigSliderConstraint: {
2013
+ name: 'string', skin: 'boolean', type: 'enum', animation: 'string', mix: 'number', additive: 'boolean',
2014
+ loop: 'boolean', bone: 'string', property: 'enum', from: 'number', to: 'number', scale: 'number', max: 'number',
2015
+ local: 'boolean', time: 'number',
2016
+ },
2017
+ RigTransformProperty: { offset: 'number', to: 'map of object' },
2018
+ RigTransformTo: { offset: 'number', max: 'number', scale: 'number' },
2019
+ RigRegionAttachment: {
2020
+ type: 'enum', name: 'string', path: 'string', image: 'string', x: 'number', y: 'number', rotation: 'number',
2021
+ scaleX: 'number', scaleY: 'number', width: 'number', height: 'number', color: 'string', sequence: 'object',
2022
+ },
2023
+ RigMeshAttachment: {
2024
+ type: 'enum', name: 'string', path: 'string', image: 'string', uvs: 'number[]', triangles: 'number[]',
2025
+ vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', hull: 'number', edges: 'number[]',
2026
+ width: 'number', height: 'number', color: 'string', generator: 'object', sequence: 'object',
2027
+ },
2028
+ RigLinkedMeshAttachment: {
2029
+ type: 'enum', name: 'string', path: 'string', image: 'string', source: 'string', slot: 'string', skin: 'string',
2030
+ timelines: 'boolean', width: 'number', height: 'number', color: 'string', sequence: 'object',
2031
+ uvs: 'number[]', triangles: 'number[]', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum',
2032
+ hull: 'number', edges: 'number[]', generator: 'object',
2033
+ },
2034
+ RigMeshBinding: { bone: 'string', x: 'number', y: 'number', weight: 'number' },
2035
+ RigSequence: { count: 'number', start: 'number', digits: 'number', setup: 'number' },
2036
+ RigBoundingBoxAttachment: {
2037
+ vertexCount: 'number', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', color: 'string', type: 'enum', name: 'string',
2038
+ },
2039
+ RigClippingAttachment: {
2040
+ vertexCount: 'number', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', color: 'string', type: 'enum', name: 'string',
2041
+ end: 'string', convex: 'boolean', inverse: 'boolean',
2042
+ },
2043
+ RigPathAttachment: {
2044
+ vertexCount: 'number', vertices: 'number[]', weights: 'object[][]', boneIndexing: 'enum', color: 'string', type: 'enum', name: 'string',
2045
+ closed: 'boolean', constantSpeed: 'boolean', lengths: 'number[]',
2046
+ },
2047
+ RigRingGenerator: {
2048
+ kind: 'enum', hull: 'number[][]', center: 'number[]', inner: 'number', size: 'number[]', bias: 'object',
2049
+ controls: 'string[]',
2050
+ },
2051
+ RigRibbonGenerator: { kind: 'enum', size: 'number[]', rows: 'number', chain: 'string[]' },
2052
+ RigContourGenerator: {
2053
+ kind: 'enum', tolerance: 'number', margin: 'number', maxVertices: 'number', alpha: 'number', depth: 'object',
2054
+ soft: 'object',
2055
+ },
2056
+ RigGridGenerator: { kind: 'enum', us: 'number[]', vs: 'number[]', cols: 'number', rows: 'number', depth: 'object', soft: 'object' },
2057
+ RigSegmentsGenerator: { kind: 'enum', cell: 'number', bones: 'mixed', falloff: 'object', alpha: 'number', anchor: 'number[]' },
2058
+ RigSegmentsFalloff: { power: 'number', radius: 'number', maxBones: 'number', minWeight: 'number' },
2059
+ RigSegmentSpan: { bone: 'string', from: 'number[]', to: 'number[]' },
2060
+ RigMeshBias: { axis_deg: 'number', ramp: 'number[]' },
2061
+ RigDepthMap: { image: 'string', near: 'enum', zScale: 'number', gamma: 'number', contrast: 'number', bias: 'number' },
2062
+ RigSoftRegion: { bone: 'string', mask: 'string' },
2063
+ } as const satisfies RigTypeTable;
2064
+
2065
+ /** The two sources a bone's `from.rotation` names (`RigBoneFrom.rotation`). */
2066
+ export const RIG_FROM_ROTATIONS = ['axis', 'anchor'] as const satisfies ReadonlyArray<NonNullable<RigBoneFrom['rotation']>>;
2067
+
2068
+ /** The two ways a weighted `vertices` run names its bones (`RigMeshAttachment.boneIndexing`). */
2069
+ export const RIG_BONE_INDEXING = ['name', 'raw'] as const satisfies ReadonlyArray<NonNullable<RigMeshAttachment['boneIndexing']>>;
2070
+
2071
+ /** What `boneIndexing` outside its set was read as, measured before issue #900. */
2072
+ const BONE_INDEXING_READ_AS =
2073
+ 'anything else was read as the default "name" in silence: a weighted "vertices" run was refused as unflagged, ' +
2074
+ 'and every other attachment built byte for byte as if the key were absent';
2075
+
2076
+ /**
2077
+ * Who refuses each `enum` row of `RIG_TYPES` outside its set (issue #900) —
2078
+ * `SpecEnumTable` in [`keys.ts`](keys.ts) says what the two kinds of entry
2079
+ * mean, and `satisfies` makes an `enum` row without one a type error.
2080
+ *
2081
+ * ⭐ A `set` is stated for exactly the rows nobody held: measured when this
2082
+ * table was written, `boneIndexing` built green at `5`, `"foo"` and `"named"`
2083
+ * (the spelling the issue itself used) as the default, and `from.rotation`
2084
+ * built green at `5` and `"foo"` with the bone's setup rotation dropped. Every
2085
+ * other row already had a reader that refused a planted `5` and `"foo"` by
2086
+ * name, and keeps it; the generator's `kind` is the one of those that is a
2087
+ * dispatch, and its refusal is new — it threw a `TypeError` (*undefined is not
2088
+ * an object (evaluating 'generator.size')*), because the scan skipped a `kind`
2089
+ * it had no row for and the mesh builder fell through to the ring branch.
2090
+ */
2091
+ export const RIG_ENUMS = {
2092
+ RigSpec: { spec: { owner: 'parseRigSpec' } },
2093
+ RigBone: { inherit: { owner: 'parseRigSpec' } },
2094
+ RigBoneFrom: {
2095
+ rotation: {
2096
+ set: RIG_FROM_ROTATIONS,
2097
+ readAs: 'anything else was read as no rotation source, and the bone was emitted without the setup rotation it asked for',
2098
+ },
2099
+ },
2100
+ RigSlot: { blend: { owner: 'parseRigSpec' } },
2101
+ RigConsumerDrivenMix: { type: { owner: 'parseRigSpec' } },
2102
+ RigIkConstraint: { type: { owner: 'buildRigConstraint' }, scaleY: { owner: 'buildRigConstraint' } },
2103
+ RigTransformConstraint: { type: { owner: 'buildRigConstraint' } },
2104
+ RigPathConstraint: {
2105
+ type: { owner: 'buildRigConstraint' },
2106
+ positionMode: { owner: 'buildRigConstraint' },
2107
+ spacingMode: { owner: 'buildRigConstraint' },
2108
+ rotateMode: { owner: 'buildRigConstraint' },
2109
+ },
2110
+ RigPhysicsConstraint: { type: { owner: 'buildRigConstraint' }, scaleY: { owner: 'buildRigConstraint' } },
2111
+ RigSliderConstraint: { type: { owner: 'buildRigConstraint' }, property: { owner: 'buildRigConstraint' } },
2112
+ RigRegionAttachment: { type: { owner: 'buildRigAttachment' } },
2113
+ RigMeshAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2114
+ RigLinkedMeshAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2115
+ RigBoundingBoxAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2116
+ RigClippingAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2117
+ RigPathAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2118
+ // The generator's `kind` chooses the row its other keys are checked against,
2119
+ // so a `kind` outside the five has no row to be visited in: the scan that
2120
+ // dispatches on it is where it is refused.
2121
+ RigRingGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2122
+ RigRibbonGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2123
+ RigContourGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2124
+ RigGridGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2125
+ RigSegmentsGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2126
+ RigDepthMap: { near: { owner: 'sampleMeshDepth' } },
2127
+ } as const satisfies SpecEnumTable<typeof RIG_TYPES>;
2128
+
2129
+ /**
2130
+ * The five constraint `type` names, and the shape each one's keys come from.
2131
+ *
2132
+ * `satisfies` over `RigSkinConstraintKey` is what makes the five exhaustive: a
2133
+ * sixth constraint type added to that union without an entry here is a type
2134
+ * error rather than a shape whose keys go unchecked.
2135
+ */
2136
+ const CONSTRAINT_SHAPE: Record<string, keyof typeof RIG_KEYS> = {
2137
+ ik: 'RigIkConstraint',
2138
+ transform: 'RigTransformConstraint',
2139
+ path: 'RigPathConstraint',
2140
+ physics: 'RigPhysicsConstraint',
2141
+ slider: 'RigSliderConstraint',
2142
+ } satisfies Record<RigSkinConstraintKey, keyof typeof RIG_KEYS>;
2143
+
2144
+ /** The attachment `type` names, and the shape each one's keys come from. */
2145
+ const ATTACHMENT_SHAPE: Record<string, keyof typeof RIG_KEYS> = {
2146
+ region: 'RigRegionAttachment',
2147
+ mesh: 'RigMeshAttachment',
2148
+ linkedmesh: 'RigLinkedMeshAttachment',
2149
+ boundingbox: 'RigBoundingBoxAttachment',
2150
+ clipping: 'RigClippingAttachment',
2151
+ path: 'RigPathAttachment',
2152
+ };
2153
+
2154
+ /** The generator `kind` names, and the shape each one's keys come from. */
2155
+ const GENERATOR_SHAPE: Record<(typeof RIG_GENERATOR_KINDS)[number], keyof typeof RIG_KEYS> = {
2156
+ ring: 'RigRingGenerator',
2157
+ ribbon: 'RigRibbonGenerator',
2158
+ contour: 'RigContourGenerator',
2159
+ grid: 'RigGridGenerator',
2160
+ segments: 'RigSegmentsGenerator',
2161
+ };
2162
+
2163
+ /**
2164
+ * The attachment kinds that carry a `sequence` — the three `readAttachment`
2165
+ * branches that call `readSequence` (`SkeletonJson.js:530`, `:561`; `mesh` and
2166
+ * `linkedmesh` share the second).
2167
+ */
2168
+ export const SEQUENCE_ATTACHMENT_TYPES = ['region', 'mesh', 'linkedmesh'] as const;
2169
+
2170
+ /**
2171
+ * One attachment's `sequence` block, refused by name where the parser would read
2172
+ * it into a series that is not the one the spec states.
2173
+ *
2174
+ * Every refusal here is a silence measured on spine-core 4.3.13 (issue #729):
2175
+ *
2176
+ * - no `count` — the parser's default is 0, and the attachment loads holding
2177
+ * no region at all;
2178
+ * - a `setup` at or past `count` — `Sequence.resolveIndex` clamps it to the
2179
+ * last frame (`setup: 7` on a four-frame series showed frame 4), and a
2180
+ * negative one indexes `regions[-1]`;
2181
+ * - a fractional `count`, `start`, `digits` or `setup` — `start: 1.5` makes
2182
+ * `Sequence.getPath` ask the atlas for `stem1.5`;
2183
+ * - an `image` beside it — one file names one region, and the series names
2184
+ * `count` of them;
2185
+ * - a `generator` beside it — a generator traces one plate, and which frame it
2186
+ * should trace is not something the spec says.
2187
+ */
2188
+ function checkRigSequence(att: Record<string, unknown>, who: string, where: string): void {
2189
+ const seq = att.sequence;
2190
+ const at = `${who} "sequence"`;
2191
+ if (!isObj(seq)) {
2192
+ throw new CompileError(
2193
+ `${where}: ${at} is ${JSON.stringify(seq) ?? String(seq)}, and a sequence is an object: ` +
2194
+ '`{ "count": <frames>, "start"?: <first number>, "digits"?: <zero padding>, "setup"?: <setup frame> }`',
2195
+ );
2196
+ }
2197
+ const whole = (field: string, min: number): void => {
2198
+ const value = seq[field];
2199
+ if (value === undefined) return;
2200
+ // A number the file cannot carry is `refuseNumbersTheFileCannotCarry`'s,
2201
+ // one sentence for the whole family. This check runs inside the key scan,
2202
+ // before that walk, and printed a stated 1e309 as `null` (issue #881).
2203
+ if (typeof value === 'number' && !Number.isFinite(Math.fround(value))) return;
2204
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < min) {
2205
+ throw new CompileError(
2206
+ `${where}: ${at}.${field} is ${JSON.stringify(value) ?? String(value)}; it is a whole number` +
2207
+ (min > 0 ? ` of at least ${min}` : min === 0 ? ' of at least 0' : '') +
2208
+ ' — `Sequence.getPath` writes `start + i` into the region name digit for digit, so a fraction names a ' +
2209
+ 'region like `stem1.5` and a non-number names none',
2210
+ );
2211
+ }
2212
+ };
2213
+ if (seq.count === undefined) {
2214
+ throw new CompileError(
2215
+ `${where}: ${at} states no "count". The parser reads \`getValue(map, "count", 0)\` ` +
2216
+ '(`SkeletonJson.js:644`), so an omitted count is a series of NO frames: the attachment loads holding no ' +
2217
+ 'region and draws nothing, without an error. State how many frames the series has.',
2218
+ );
2219
+ }
2220
+ whole('count', 1);
2221
+ whole('start', 0);
2222
+ whole('digits', 0);
2223
+ whole('setup', 0);
2224
+ const count = seq.count as number;
2225
+ if (typeof seq.setup === 'number' && Number.isFinite(Math.fround(seq.setup)) && Number.isFinite(Math.fround(count)) && seq.setup >= count) {
2226
+ throw new CompileError(
2227
+ `${where}: ${at}.setup is ${seq.setup}, and a ${count}-frame series has frames 0 to ${count - 1}. ` +
2228
+ '`Sequence.resolveIndex` clamps an index at or past the end to the LAST frame (measured: `setup: 7` on ' +
2229
+ 'four frames showed frame 4), so this would show a frame the spec does not name. `setup` is 0-based.',
2230
+ );
2231
+ }
2232
+ for (const [field, why] of [
2233
+ ['image', 'an image names ONE region and a sequence names `count` of them — the frames are the regions ' +
2234
+ '`<path><number>`, and on the loose route each is the PNG of that name in the images directory'],
2235
+ ['generator', 'a generator traces one plate, and which frame of the series it should trace is not something ' +
2236
+ 'the spec says — author the geometry, which every frame shares'],
2237
+ ] as const) {
2238
+ if (att[field] !== undefined) {
2239
+ throw new CompileError(`${where}: ${who} states "${field}" beside "sequence"; ${why}. Remove "${field}".`);
2240
+ }
2241
+ }
2242
+ }
2243
+
2244
+ /**
2245
+ * Refuse every key of this rig spec that no shape above declares.
2246
+ *
2247
+ * ⭐ It walks the file rather than the emitter's route, and that is the whole
2248
+ * design. `compile` reaches an attachment only through a slot it is going to
2249
+ * draw and a `setup` entry only through a slot that has attachments, so a check
2250
+ * riding along with the emitter inherits its blind spots — which is how issue
2251
+ * #293's refusal sat green for three weeks on exactly the half-finished rigs it
2252
+ * was written for. Every node of the document is visited here, whether or not
2253
+ * anything downstream would have looked at it.
2254
+ *
2255
+ * A node that is not an object is left alone: its shape is somebody else's
2256
+ * refusal, and naming its keys would be a second opinion on a fault already
2257
+ * reported (see the note at the head of `parseMotionSpec`).
2258
+ */
2259
+ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{ node: Record<string, unknown>; shape: keyof typeof RIG_KEYS; path: Array<string | number> }> {
2260
+ const visits: Array<{ node: Record<string, unknown>; shape: keyof typeof RIG_KEYS; path: Array<string | number> }> = [];
2261
+ const at = (node: unknown, shape: keyof typeof RIG_KEYS, what: string, path: Array<string | number>): void => {
2262
+ if (!isObj(node)) return;
2263
+ refuseUnknownKeys(node, RIG_KEYS[shape], where, what);
2264
+ visits.push({ node, shape, path });
2265
+ };
2266
+
2267
+ at(raw, 'RigSpec', 'this rig spec', []);
2268
+ at(raw.skeleton, 'RigSkeletonHeader', '"skeleton"', ['skeleton']);
2269
+ if (isObj(raw.skeleton)) at(raw.skeleton.stageBox, 'RigStageBox', 'skeleton.stageBox', ['skeleton', 'stageBox']);
2270
+
2271
+ for (const [i, bone] of (Array.isArray(raw.bones) ? raw.bones : []).entries()) {
2272
+ const who = isObj(bone) && typeof bone.name === 'string' ? `bone "${bone.name}"` : `bones[${i}]`;
2273
+ at(bone, 'RigBone', who, ['bones', i]);
2274
+ if (isObj(bone)) at(bone.from, 'RigBoneFrom', `${who}'s "from"`, ['bones', i, 'from']);
2275
+ }
2276
+
2277
+ for (const [i, slot] of (Array.isArray(raw.slots) ? raw.slots : []).entries()) {
2278
+ at(slot, 'RigSlot', isObj(slot) && typeof slot.name === 'string' ? `slot "${slot.name}"` : `slots[${i}]`, ['slots', i]);
2279
+ }
2280
+
2281
+ for (const [i, constraint] of (Array.isArray(raw.constraints) ? raw.constraints : []).entries()) {
2282
+ if (!isObj(constraint)) continue;
2283
+ const named = typeof constraint.name === 'string' ? `constraint "${constraint.name}"` : `constraints[${i}]`;
2284
+ const shape = CONSTRAINT_SHAPE[String(constraint.type)];
2285
+ // An unknown `type` is `buildRigConstraint`'s refusal and names the five
2286
+ // that exist; there is no key set to check it against and no honest one to
2287
+ // guess, so it goes past here to the message that can say something.
2288
+ if (shape === undefined) continue;
2289
+ at(constraint, shape, `${named} (${String(constraint.type)})`, ['constraints', i]);
2290
+ for (const [from, entry] of Object.entries(isObj(constraint.properties) ? constraint.properties : {})) {
2291
+ at(entry, 'RigTransformProperty', `${named} properties."${from}"`, ['constraints', i, 'properties', from]);
2292
+ if (!isObj(entry)) continue;
2293
+ for (const [to, driven] of Object.entries(isObj(entry.to) ? entry.to : {})) {
2294
+ at(driven, 'RigTransformTo', `${named} properties."${from}".to."${to}"`, ['constraints', i, 'properties', from, 'to', to]);
2295
+ }
2296
+ }
2297
+ }
2298
+
2299
+ for (const [name, event] of Object.entries(isObj(raw.events) ? raw.events : {})) {
2300
+ at(event, 'RigEvent', `event "${name}"`, ['events', name]);
2301
+ }
2302
+
2303
+ if (isObj(raw.invariants)) {
2304
+ at(raw.invariants, 'RigInvariants', '"invariants"', ['invariants']);
2305
+ for (const [i, rule] of (Array.isArray(raw.invariants.detached) ? raw.invariants.detached : []).entries()) {
2306
+ at(rule, 'RigDetachedRule', `invariants.detached[${i}]`, ['invariants', 'detached', i]);
2307
+ }
2308
+ for (const [i, rule] of (Array.isArray(raw.invariants.deformMayFold) ? raw.invariants.deformMayFold : []).entries()) {
2309
+ at(rule, 'RigDeformFoldExemption', `invariants.deformMayFold[${i}]`, ['invariants', 'deformMayFold', i]);
2310
+ }
2311
+ for (const [i, rule] of (Array.isArray(raw.invariants.consumerDrivenMix) ? raw.invariants.consumerDrivenMix : []).entries()) {
2312
+ at(rule, 'RigConsumerDrivenMix', `invariants.consumerDrivenMix[${i}]`, ['invariants', 'consumerDrivenMix', i]);
2313
+ }
2314
+ at(raw.invariants.idleDrivesMeshes, 'RigIdleDrivesMeshes', 'invariants.idleDrivesMeshes', ['invariants', 'idleDrivesMeshes']);
2315
+ }
2316
+
2317
+ for (const [skinName, skin] of Object.entries(isObj(raw.skins) ? raw.skins : {})) {
2318
+ if (!isObj(skin)) continue;
2319
+ // The long form's own key check is `splitRigSkin`'s and it already names the
2320
+ // likeliest cause (a slot left outside `attachments`), so it is reused
2321
+ // rather than restated — one refusal per fault.
2322
+ const parts = splitRigSkin(skin as RigSkin, `${where}: skin "${skinName}"`);
2323
+ // The long form's own lists are typed too; `splitRigSkin` has just refused
2324
+ // a key outside them, so the row's keys are the node's keys.
2325
+ if (parts.explicit) visits.push({ node: skin, shape: 'RigSkinEntry', path: ['skins', skinName] });
2326
+ const base: Array<string | number> = parts.explicit ? ['skins', skinName, 'attachments'] : ['skins', skinName];
2327
+ for (const [slot, placeholders] of Object.entries(parts.attachments)) {
2328
+ if (!isObj(placeholders)) continue;
2329
+ for (const [placeholder, att] of Object.entries(placeholders)) {
2330
+ if (!isObj(att)) continue;
2331
+ const who = `skin "${skinName}" slot "${slot}" attachment "${placeholder}"`;
2332
+ // `type` absent means `region` — the parser's own default (`:539`).
2333
+ // A mesh carrying `source` is a LINKED mesh — `type: "mesh"` and
2334
+ // `type: "linkedmesh"` share one parser branch and the `source` key is
2335
+ // what decides (`:568-569`, `:582`; SPEC_COVERAGE part 1-6). So its keys
2336
+ // are checked against the LINK's shape whichever of the two spellings it
2337
+ // used: against a mesh's the fault came out as *2 keys this compiler does
2338
+ // not read: "source", "skin" … fix the spelling or remove it*, where
2339
+ // removing `source` is what unmakes the linked mesh. Until issue #691
2340
+ // this branch skipped the check entirely, because the construct had no
2341
+ // key set of its own to check against.
2342
+ const stated = att.type === undefined ? 'region' : String(att.type);
2343
+ const type = stated === 'mesh' && att.source !== undefined ? 'linkedmesh' : stated;
2344
+ const shape = ATTACHMENT_SHAPE[type];
2345
+ if (shape === undefined) continue;
2346
+ // Before the key check, so a `sequence` on a kind that has no texture is
2347
+ // named as that — "a key this compiler does not read … fix the spelling"
2348
+ // would send the author hunting for a typo in a word spelled right.
2349
+ if (att.sequence !== undefined && !(SEQUENCE_ATTACHMENT_TYPES as readonly string[]).includes(type)) {
2350
+ throw new CompileError(
2351
+ `${where}: ${who} is a ${type} and states a "sequence". A sequence is a numbered series of atlas ` +
2352
+ `regions, and only the ${SEQUENCE_ATTACHMENT_TYPES.length} kinds that draw a region carry one — ` +
2353
+ `${SEQUENCE_ATTACHMENT_TYPES.join(', ')} (\`readAttachment\` calls \`readSequence\` in exactly those ` +
2354
+ `branches, \`SkeletonJson.js:530\` and \`:561\`); on a ${type} the parser never reads the key, so the ` +
2355
+ 'series would be dropped in silence. Remove it, or put it on a region or a mesh.',
2356
+ );
2357
+ }
2358
+ const here = [...base, slot, placeholder];
2359
+ at(att, shape, `${who} (${type})`, here);
2360
+ if (att.sequence !== undefined) {
2361
+ at(att.sequence, 'RigSequence', `${who} "sequence"`, [...here, 'sequence']);
2362
+ checkRigSequence(att, who, where);
2363
+ }
2364
+ for (const [i, vertex] of (Array.isArray(att.weights) ? att.weights : []).entries()) {
2365
+ for (const [j, binding] of (Array.isArray(vertex) ? vertex : []).entries()) {
2366
+ at(binding, 'RigMeshBinding', `${who} weights[${i}][${j}]`, [...here, 'weights', i, j]);
2367
+ }
2368
+ }
2369
+ if (!isObj(att.generator)) continue;
2370
+ const gen = att.generator;
2371
+ const kind = RIG_GENERATOR_KINDS.find((k) => k === gen.kind);
2372
+ const genShape = kind === undefined ? undefined : GENERATOR_SHAPE[kind];
2373
+ // 🚨 An unknown `kind` used to be skipped here as "the mesh builder's
2374
+ // refusal", and the mesh builder had none: `buildGeneratedMesh` fell
2375
+ // through to the ring branch and threw a TypeError reading
2376
+ // `generator.size` (issue #900). `kind` chooses the row every other key
2377
+ // of the generator is checked against, so this dispatch is the one
2378
+ // place that can refuse it — before any of those keys is judged.
2379
+ if (genShape === undefined) {
2380
+ const stated = gen.kind === undefined ? 'absent' : (JSON.stringify(gen.kind) ?? String(gen.kind));
2381
+ throw new CompileError(
2382
+ `${where}: ${who} generator.kind is ${stated}; one of ${RIG_GENERATOR_KINDS.map((k) => JSON.stringify(k)).join(', ')} — ` +
2383
+ 'the kind names the builder, and each builder reads its own keys, so nothing about the mesh can be checked or built without one',
2384
+ );
2385
+ }
2386
+ at(gen, genShape, `${who} generator (${String(gen.kind)})`, [...here, 'generator']);
2387
+ at(gen.bias, 'RigMeshBias', `${who} generator.bias`, [...here, 'generator', 'bias']);
2388
+ at(gen.depth, 'RigDepthMap', `${who} generator.depth`, [...here, 'generator', 'depth']);
2389
+ at(gen.soft, 'RigSoftRegion', `${who} generator.soft`, [...here, 'generator', 'soft']);
2390
+ at(gen.falloff, 'RigSegmentsFalloff', `${who} generator.falloff`, [...here, 'generator', 'falloff']);
2391
+ for (const [i, entry] of (Array.isArray(gen.bones) ? gen.bones : []).entries()) {
2392
+ at(entry, 'RigSegmentSpan', `${who} generator.bones[${i}]`, [...here, 'generator', 'bones', i]);
2393
+ }
2394
+ }
2395
+ }
2396
+ }
2397
+ return visits;
2398
+ }
2399
+
2400
+
2401
+ /**
2402
+ * A rig-spec path in the words the other refusals in this file use for it:
2403
+ * `bone "hip" x`, `constraint "aim" mixRotate`, `skin "default" slot "tail"
2404
+ * attachment "tail" weights[0][1].x`, `event "step" float`. Anything else is
2405
+ * the dotted path, which names every number exactly.
2406
+ */
2407
+ function rigPlace(raw: Record<string, unknown>): (path: ReadonlyArray<string | number>) => string {
2408
+ const named = (list: unknown, i: number, kind: string, plural: string): string => {
2409
+ const node = Array.isArray(list) ? list[i] : undefined;
2410
+ return isObj(node) && typeof node.name === 'string' ? `${kind} ${JSON.stringify(node.name)}` : `${plural}[${i}]`;
2411
+ };
2412
+ const rest = (path: ReadonlyArray<string | number>): string => {
2413
+ const tail = dottedPath(path);
2414
+ return tail === '' ? '' : ` ${tail}`;
2415
+ };
2416
+ return (path) => {
2417
+ const [head, second] = path;
2418
+ if (typeof second === 'number') {
2419
+ if (head === 'bones') return `${named(raw.bones, second, 'bone', 'bones')}${rest(path.slice(2))}`;
2420
+ if (head === 'slots') return `${named(raw.slots, second, 'slot', 'slots')}${rest(path.slice(2))}`;
2421
+ if (head === 'constraints') return `${named(raw.constraints, second, 'constraint', 'constraints')}${rest(path.slice(2))}`;
2422
+ }
2423
+ if (head === 'events' && typeof second === 'string') return `event ${JSON.stringify(second)}${rest(path.slice(2))}`;
2424
+ if (head === 'skins' && typeof second === 'string') {
2425
+ const inner = path[2] === 'attachments' ? path.slice(3) : path.slice(2);
2426
+ const [slot, attachment] = inner;
2427
+ if (typeof slot === 'string' && typeof attachment === 'string') {
2428
+ return `skin ${JSON.stringify(second)} slot ${JSON.stringify(slot)} attachment ${JSON.stringify(attachment)}${rest(inner.slice(2))}`;
2429
+ }
2430
+ return `skin ${JSON.stringify(second)}${rest(path.slice(2))}`;
2431
+ }
2432
+ return dottedPath(path);
2433
+ };
2434
+ }
2435
+
2436
+ /**
2437
+ * Parse and check the envelope, then hand back a typed spec.
2438
+ *
2439
+ * What is checked here is what makes the REST of the compiler able to assume its
2440
+ * inputs: the version tag, the two required arrays, name uniqueness, and that
2441
+ * every parent and every slot bone resolves against a bone declared earlier.
2442
+ * Deeper checks (does an attachment's image exist, does a constraint's target
2443
+ * bone exist) belong where the data is used, so their message can name the
2444
+ * consumer.
2445
+ */
2446
+ export function parseRigSpec(raw: unknown, where: string): RigSpec {
2447
+ if (!isObj(raw)) throw new CompileError(`${where}: a rig spec must be a JSON object`);
2448
+ if (raw.spec !== RIG_SPEC_VERSION) {
2449
+ throw new CompileError(`${where}: unknown rig spec version ${JSON.stringify(raw.spec)}, expected "${RIG_SPEC_VERSION}"`);
2450
+ }
2451
+ // Before anything resolves by name AND before the required keys are asked
2452
+ // for, because a key nothing reads is very often the CAUSE of the name — or
2453
+ // the array — that is not there: `"bones"` typed on a slider is a slider with
2454
+ // no driving bone, and `"slot"` typed at the root is a rig with no `slots` at
2455
+ // all. The refusal an author wants names the typo rather than the consequence.
2456
+ //
2457
+ // ⚠️ This comment argued exactly that while sitting three lines BELOW the
2458
+ // throws it was arguing about (issue #672), so misspelling a required key —
2459
+ // the commonest way to lose one — printed `a rig spec needs a "slots" array`
2460
+ // and never named the `"slot"` the file carried. `parseMotionSpec` was
2461
+ // written to this order and cites this function as its precedent; the
2462
+ // precedent was the prose here rather than the code.
2463
+ //
2464
+ // 🔒 Two checks stay above it, and both are the scan's own preconditions
2465
+ // rather than a preference. `isObj` is what makes `raw` an object to read
2466
+ // keys off at all. The version tag decides WHICH key set applies: a file
2467
+ // declaring a spec version this compiler does not know would otherwise be
2468
+ // refused key by key against `rigc-rig/1`'s sets — a list of "keys this
2469
+ // compiler does not read" for a format it has never read at all. The three
2470
+ // throws directly below are the required-key checks, and each of them is the
2471
+ // consequence a typo at the root produces.
2472
+ const visits = checkRigSpecKeys(raw, where);
2473
+ const place = rigPlace(raw);
2474
+ // Every value the scan admitted, against the type its key holds, before any
2475
+ // reader — here or in `compile` — has done arithmetic on it (issue #890).
2476
+ // ⚠️ It runs ahead of this function's own field checks on purpose: those read
2477
+ // values too, and a reader that meets a wrong type names the wrong fault —
2478
+ // `constraint.skin === true` reads `"skin": "true"` as a constraint that asks
2479
+ // for no skin. The price, measured, is three controls whose type half pinned an
2480
+ // older sentence (RF86, PS177, PS189); their other halves are unchanged.
2481
+ const shapeVisits: ShapeVisit[] = visits.map((v) => ({ node: v.node, shape: v.shape, name: (tail) => place([...v.path, ...tail]) }));
2482
+ refuseValuesOfTheWrongType(shapeVisits, RIG_TYPES, where);
2483
+ // A name outside an `enum` row's stated set (issue #900), after the type walk
2484
+ // and over the same visits: `boneIndexing: "foo"` built as the default and
2485
+ // `from.rotation: "foo"` as no rotation, both green. A row whose entry names
2486
+ // an owner is that reader's to refuse, in its own words.
2487
+ refuseValuesOutsideTheirSet(shapeVisits, RIG_TYPES, RIG_ENUMS, where);
2488
+ // After the key check, so a misspelled key is named as a misspelling before
2489
+ // its value is judged, and before every reader below, so no range rule or
2490
+ // derived number ever meets a value the file cannot carry (issue #881).
2491
+ refuseNumbersTheFileCannotCarry(raw, where, place);
2492
+
2493
+ if (typeof raw.name !== 'string' || raw.name.length === 0) {
2494
+ throw new CompileError(`${where}: a rig spec needs a "name" — a motion spec names it to pick this rig`);
2495
+ }
2496
+ if (!Array.isArray(raw.bones) || raw.bones.length === 0) {
2497
+ throw new CompileError(`${where}: a rig spec needs a non-empty "bones" array`);
2498
+ }
2499
+ if (!Array.isArray(raw.slots)) {
2500
+ throw new CompileError(`${where}: a rig spec needs a "slots" array (it may be empty; its ORDER is the draw order)`);
2501
+ }
2502
+
2503
+ const spec = raw as unknown as RigSpec;
2504
+
2505
+ // The stage, stated or stated absent. Half a statement is refused here rather
2506
+ // than resolved in `compile`, because which half was meant is not derivable
2507
+ // and a compiler that picks one is inventing a number (issue #578).
2508
+ const header = spec.skeleton;
2509
+ if (header !== undefined) {
2510
+ if (header.audio !== undefined && header.audio !== null && typeof header.audio !== 'string') {
2511
+ throw new CompileError(
2512
+ `${where}: "skeleton" states audio ${JSON.stringify(header.audio)}; it is a path from the skeleton file to ` +
2513
+ 'its audio folder, or null for none — a string or null',
2514
+ );
2515
+ }
2516
+ const noWidth = header.width === null;
2517
+ const noHeight = header.height === null;
2518
+ if (noWidth !== noHeight) {
2519
+ const stated = noWidth ? 'height' : 'width';
2520
+ const absent = noWidth ? 'width' : 'height';
2521
+ throw new CompileError(
2522
+ `${where}: "skeleton" states ${absent}: null and a ${stated} of ` +
2523
+ `${JSON.stringify(noWidth ? header.height : header.width)}. A stage has both extents or neither: ` +
2524
+ 'write both as null for "this skeleton declares no stage", or give both a number',
2525
+ );
2526
+ }
2527
+ if (noWidth && noHeight && (header.x !== undefined || header.y !== undefined)) {
2528
+ const origin = [header.x !== undefined ? 'x' : null, header.y !== undefined ? 'y' : null].filter((k) => k !== null);
2529
+ throw new CompileError(
2530
+ `${where}: "skeleton" declares no stage (width: null, height: null) and still states ${origin.join(' and ')}. ` +
2531
+ `${origin.length === 1 ? 'That is an origin' : 'Those are an origin'} for a box that is not there: ` +
2532
+ 'drop them, or state a width and a height',
2533
+ );
2534
+ }
2535
+ // The stage's box (issue #1168): both names stated, and a stage to draw it from.
2536
+ const box = header.stageBox;
2537
+ if (box !== undefined) {
2538
+ const missing = (['slot', 'attachment'] as const).filter((key) => typeof box[key] !== 'string' || box[key].length === 0);
2539
+ if (missing.length > 0) {
2540
+ throw new CompileError(
2541
+ `${where}: skeleton.stageBox states no ${missing.map((key) => `"${key}"`).join(' and ')}. It names the slot the ` +
2542
+ 'stage box goes in and the attachment name it carries — `{ "slot": "stage", "attachment": "stage" }` — ' +
2543
+ 'and both are required: rigc writes the box\'s numbers from the stage and names nothing on its own',
2544
+ );
2545
+ }
2546
+ if (noWidth && noHeight) {
2547
+ throw new CompileError(
2548
+ `${where}: skeleton.stageBox asks for the stage as a bounding box in slot "${box.slot}", and "skeleton" ` +
2549
+ 'declares no stage (width: null, height: null) — a box around nothing. State a width and a height, or ' +
2550
+ 'drop stageBox',
2551
+ );
2552
+ }
2553
+ }
2554
+ }
2555
+
2556
+ const seen = new Set<string>();
2557
+ for (const bone of spec.bones) {
2558
+ if (!isObj(bone) || typeof bone.name !== 'string' || bone.name.length === 0) {
2559
+ throw new CompileError(`${where}: every bone needs a "name"`);
2560
+ }
2561
+ if (seen.has(bone.name)) {
2562
+ throw new CompileError(`${where}: two bones are called "${bone.name}"; bone names are the join key for slots, meshes and timelines`);
2563
+ }
2564
+ seen.add(bone.name);
2565
+ if (bone.parent === undefined) continue;
2566
+ if (typeof bone.parent !== 'string' || !seen.has(bone.parent)) {
2567
+ // The parser resolves `parent` against the bones it has already read, so a
2568
+ // forward reference is not a rigc restriction — it is a bone with no parent
2569
+ // in the loaded skeleton, which loads as a second root.
2570
+ throw new CompileError(
2571
+ `${where}: bone "${bone.name}" names parent ${JSON.stringify(bone.parent)}, which is not declared before it`,
2572
+ );
2573
+ }
2574
+ if (bone.inherit !== undefined && resolveBoneInherit(bone.inherit) === undefined) {
2575
+ throw new CompileError(
2576
+ `${where}: bone "${bone.name}" has inherit ${JSON.stringify(bone.inherit)}; ${BONE_INHERIT_KNOWN}`,
2577
+ );
2578
+ }
2579
+ const from = bone.from;
2580
+ if (from !== undefined) {
2581
+ const sources = ['anchor', 'slotWindow', 'meshCenter'].filter((k) => from[k as keyof RigBoneFrom] !== undefined);
2582
+ if (sources.length > 1) {
2583
+ throw new CompileError(
2584
+ `${where}: bone "${bone.name}" takes its position from more than one source (${sources.join(', ')}); name exactly one`,
2585
+ );
2586
+ }
2587
+ if ((bone.x !== undefined || bone.y !== undefined) && sources.length === 1) {
2588
+ throw new CompileError(
2589
+ `${where}: bone "${bone.name}" declares both a literal x/y and from.${sources[0]}; the two would disagree the first time the art moved`,
2590
+ );
2591
+ }
2592
+ if (from.rotation !== undefined && bone.rotation !== undefined) {
2593
+ throw new CompileError(`${where}: bone "${bone.name}" declares both a literal rotation and from.rotation`);
2594
+ }
2595
+ if (from.rotation === 'anchor' && from.anchor === undefined) {
2596
+ throw new CompileError(`${where}: bone "${bone.name}" wants its rotation from an anchor but names no from.anchor`);
2597
+ }
2598
+ }
2599
+ }
2600
+
2601
+ const slotNames = new Set<string>();
2602
+ for (const slot of spec.slots) {
2603
+ if (!isObj(slot) || typeof slot.name !== 'string' || slot.name.length === 0) {
2604
+ throw new CompileError(`${where}: every slot needs a "name"`);
2605
+ }
2606
+ if (slotNames.has(slot.name)) throw new CompileError(`${where}: two slots are called "${slot.name}"`);
2607
+ slotNames.add(slot.name);
2608
+ if (typeof slot.bone !== 'string' || !seen.has(slot.bone)) {
2609
+ throw new CompileError(
2610
+ `${where}: slot "${slot.name}" names bone ${JSON.stringify(slot.bone)}, which this rig does not declare`,
2611
+ );
2612
+ }
2613
+ // Issue #946: this used to compare case-insensitively, so `ADDITIVE` built
2614
+ // green and the runtime read the slot as no mode at all. The rule is now the
2615
+ // runtime's own, the one `buildRigConstraint`'s enums state.
2616
+ if (slot.blend !== undefined && !isRigSlotBlend(slot.blend)) {
2617
+ throw new CompileError(
2618
+ `${where}: slot "${slot.name}" has blend ${JSON.stringify(slot.blend)}; known: ${RIG_SLOT_BLEND.join(', ')} ` +
2619
+ "(only the first letter's case is free — the parser's enumValue uppercases that one character and nothing " +
2620
+ 'else, and an unresolved name becomes undefined without an error)',
2621
+ );
2622
+ }
2623
+ }
2624
+
2625
+ // `invariants.deformMayFold` — one of the three fields in this file that TURN
2626
+ // A CHECK OFF (`consumerDrivenMix` and `idleDrivesMeshes`, below, are the
2627
+ // others), so its own shape is checked harder than the fields that turn one on. A
2628
+ // typo in a slot name here would silently exempt nothing and gate everything,
2629
+ // which reads exactly like the check working; and an exemption with no reason
2630
+ // is unreviewable six months later. Both are refused by name.
2631
+ const mayFold = spec.invariants?.deformMayFold;
2632
+ if (mayFold !== undefined) {
2633
+ if (!Array.isArray(mayFold)) {
2634
+ throw new CompileError(
2635
+ `${where}: invariants.deformMayFold is ${JSON.stringify(mayFold)}, expected an array of { "slot": …, "why": … }`,
2636
+ );
2637
+ }
2638
+ const declared = new Set<string>();
2639
+ for (const entry of mayFold) {
2640
+ if (!isObj(entry) || typeof entry.slot !== 'string' || entry.slot.length === 0) {
2641
+ throw new CompileError(`${where}: every invariants.deformMayFold entry needs a "slot"`);
2642
+ }
2643
+ if (!slotNames.has(entry.slot)) {
2644
+ throw new CompileError(
2645
+ `${where}: invariants.deformMayFold exempts slot "${entry.slot}", which this rig does not declare — ` +
2646
+ 'a name that resolves to nothing exempts nothing, and reads like the exemption worked',
2647
+ );
2648
+ }
2649
+ if (declared.has(entry.slot)) {
2650
+ throw new CompileError(`${where}: invariants.deformMayFold names slot "${entry.slot}" twice`);
2651
+ }
2652
+ declared.add(entry.slot);
2653
+ if (typeof entry.why !== 'string' || entry.why.trim().length === 0) {
2654
+ throw new CompileError(
2655
+ `${where}: invariants.deformMayFold entry for slot "${entry.slot}" needs a "why" — this field switches ` +
2656
+ 'A39_DEFORM_KEEPS_TRIANGLE_WINDING off for that slot, and an exemption nobody can date or justify is ' +
2657
+ 'how a defect ships as a decision',
2658
+ );
2659
+ }
2660
+ }
2661
+ }
2662
+
2663
+ // A constraint's namespace is its KIND, not the array (issue #692).
2664
+ // `SkeletonData.findConstraint(name, type)` tests `constraint instanceof type`
2665
+ // BEFORE it compares the name, and every resolution in the format goes through
2666
+ // it: a timeline group, a skin's member list, a slider's own second pass. So an
2667
+ // ik constraint and a transform constraint called `leg` are two objects nothing
2668
+ // can confuse, and the rig spec refusing them was stricter than the file it
2669
+ // emits — a shape four skeletons of a production corpus have, where the chain
2670
+ // and the transform constraint that follows it carry the chain's name.
2671
+ /** Every constraint, in declaration order, so the refusals below read in file order. */
2672
+ const constraintsDeclared: Array<{ type: string; name: string; skinRequired: boolean }> = [];
2673
+ /** `<kind> constraint "<name>"` -> that constraint. The key IS the namespace. */
2674
+ const constraintFacts = new Map<string, { type: string; name: string; skinRequired: boolean }>();
2675
+ /** name -> the kinds that declare it, for the message that has to say which. */
2676
+ const constraintKinds = new Map<string, string[]>();
2677
+ for (const constraint of spec.constraints ?? []) {
2678
+ if (!isObj(constraint) || typeof constraint.name !== 'string' || constraint.name.length === 0) {
2679
+ throw new CompileError(`${where}: every constraint needs a "name"`);
2680
+ }
2681
+ const declared = { type: String(constraint.type), name: constraint.name, skinRequired: constraint.skin === true };
2682
+ if (constraintFacts.has(constraintAt(declared.type, declared.name))) {
2683
+ throw new CompileError(
2684
+ `${where}: two ${declared.type} constraints are called "${declared.name}" — a constraint resolves by name ` +
2685
+ 'AND type (`SkeletonData.findConstraint`), so names are unique PER KIND: an ik and a transform constraint ' +
2686
+ 'may share one, two of a kind may not',
2687
+ );
2688
+ }
2689
+ constraintsDeclared.push(declared);
2690
+ constraintFacts.set(constraintAt(declared.type, declared.name), declared);
2691
+ constraintKinds.set(declared.name, [...(constraintKinds.get(declared.name) ?? []), declared.type]);
2692
+ // An ik's `bones` as a shape (issue #1205): more than two, or a pair that is
2693
+ // not a parent and its child, both load and build green and the solver then
2694
+ // moves nothing or solves a triangle nobody drew — `ikShapeFault` says which.
2695
+ // Read only once every name resolves to a declared bone, so a misspelling is
2696
+ // still the compiler's refusal by name rather than a shape it does not have.
2697
+ const ikBones = constraint.type === 'ik' ? constraint.bones : undefined;
2698
+ if (Array.isArray(ikBones) && ikBones.every((b): b is string => typeof b === 'string' && seen.has(b))) {
2699
+ const secondAncestors: string[] = [];
2700
+ if (ikBones.length === 2) {
2701
+ for (let at = spec.bones.find((b) => b.name === ikBones[1])?.parent; at !== undefined; at = spec.bones.find((b) => b.name === at)?.parent) secondAncestors.push(at);
2702
+ }
2703
+ const fault = ikShapeFault(constraint.name, ikBones, secondAncestors);
2704
+ if (fault !== null) throw new CompileError(`${where}: ${fault}`);
2705
+ }
2706
+ }
2707
+
2708
+ // `invariants.consumerDrivenMix` — the second field in `invariants` that TURNS
2709
+ // A CHECK OFF, so it is held to `deformMayFold`'s standard above: every way an
2710
+ // entry could exempt nothing while reading like it worked is refused by name
2711
+ // (issue #784). It resolves against `constraintFacts` because a constraint's
2712
+ // namespace is its kind — the entry says which kind, and the lookup is that key.
2713
+ const consumerDriven = spec.invariants?.consumerDrivenMix;
2714
+ if (consumerDriven !== undefined) {
2715
+ const shape = 'an array of { "constraint": …, "type": "ik" | "transform", "why": … }';
2716
+ if (!Array.isArray(consumerDriven)) {
2717
+ throw new CompileError(`${where}: invariants.consumerDrivenMix is ${JSON.stringify(consumerDriven)}, expected ${shape}`);
2718
+ }
2719
+ const named = new Set<string>();
2720
+ for (const entry of consumerDriven) {
2721
+ if (!isObj(entry) || typeof entry.constraint !== 'string' || entry.constraint.length === 0) {
2722
+ throw new CompileError(`${where}: every invariants.consumerDrivenMix entry needs a "constraint" — ${shape}`);
2723
+ }
2724
+ if (entry.type !== 'ik' && entry.type !== 'transform') {
2725
+ const declaredAs = constraintKinds.get(entry.constraint) ?? [];
2726
+ throw new CompileError(
2727
+ `${where}: invariants.consumerDrivenMix entry for "${entry.constraint}" has type ${JSON.stringify(entry.type)}; ` +
2728
+ 'only "ik" and "transform" read this declaration (A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT, ' +
2729
+ 'A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT). A path, physics or slider constraint resting muted is ' +
2730
+ 'A36, A23 or A37, which read no declaration, so the entry would exempt nothing' +
2731
+ (declaredAs.length ? ` — the rig declares "${entry.constraint}" as ${declaredAs.map((t) => `${t === 'ik' ? 'an' : 'a'} ${t}`).join(' and ')} constraint` : ''),
2732
+ );
2733
+ }
2734
+ const key = constraintAt(entry.type, entry.constraint);
2735
+ if (!constraintFacts.has(key)) {
2736
+ const declaredAs = constraintKinds.get(entry.constraint) ?? [];
2737
+ throw new CompileError(
2738
+ `${where}: invariants.consumerDrivenMix names ${key}, which this rig does not declare` +
2739
+ (declaredAs.length
2740
+ ? ` — "${entry.constraint}" is declared as ${declaredAs.map((t) => `${t === 'ik' ? 'an' : 'a'} ${t}`).join(' and ')} constraint, and a constraint resolves by name AND type`
2741
+ : '') +
2742
+ '. A name that resolves to nothing exempts nothing, and reads like the exemption worked',
2743
+ );
2744
+ }
2745
+ if (named.has(key)) throw new CompileError(`${where}: invariants.consumerDrivenMix names ${key} twice`);
2746
+ named.add(key);
2747
+ if (typeof entry.why !== 'string' || entry.why.trim().length === 0) {
2748
+ throw new CompileError(
2749
+ `${where}: invariants.consumerDrivenMix entry for ${key} needs a "why" — this field switches ` +
2750
+ `${entry.type === 'ik' ? 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT' : 'A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT'} ` +
2751
+ 'off for that constraint, and an exemption nobody can date or justify is how a defect ships as a decision',
2752
+ );
2753
+ }
2754
+ }
2755
+ }
2756
+
2757
+ // `invariants.idleDrivesMeshes` — the third field that TURNS A CHECK OFF
2758
+ // (issues #855, #858), so it is held to the same standard: the one shape it
2759
+ // accepts is `{ "why": … }`, and a `why` that is missing, blank or not a
2760
+ // string is refused by name. `true` is refused too, even though it reads like
2761
+ // the obvious spelling — a switch with no reason attached is exactly the
2762
+ // exemption nobody can review later. Whether the declaration switches off
2763
+ // anything is a question about the emitted `idle`, so it is the gate's (A15
2764
+ // refuses a stale one), not this parser's.
2765
+ const idleDrives = spec.invariants?.idleDrivesMeshes;
2766
+ if (idleDrives !== undefined) {
2767
+ const shape = '{ "why": "<why this idle deforms meshes on purpose>" }';
2768
+ if (!isObj(idleDrives)) {
2769
+ throw new CompileError(`${where}: invariants.idleDrivesMeshes is ${JSON.stringify(idleDrives)}, expected ${shape}`);
2770
+ }
2771
+ if (typeof idleDrives.why !== 'string' || idleDrives.why.trim().length === 0) {
2772
+ throw new CompileError(
2773
+ `${where}: invariants.idleDrivesMeshes needs a "why" (a non-blank string), got ` +
2774
+ `${idleDrives.why === undefined ? 'none' : JSON.stringify(idleDrives.why)} — expected ${shape}. This field switches ` +
2775
+ 'A15_IDLE_NO_MESH_BONE_KEYS off, and an exemption nobody can date or justify is how a defect ships as a decision',
2776
+ );
2777
+ }
2778
+ }
2779
+
2780
+ // --- skins: the attachment table, and what the skin ACTIVATES --------------
2781
+ //
2782
+ // The lists resolve by name like everything else in this format, and the
2783
+ // parser is loud about a miss (`Couldn't find bone X for skin Y`) — but in the
2784
+ // consumer's process, so they are refused here where the message can name the
2785
+ // rig spec. What the parser does NOT check is the pairing with `skin: true`,
2786
+ // and that half is silent in both directions (see `RigSkinEntry`).
2787
+ //
2788
+ // ⭐ **Sets rather than "which skin owns this", because a name may be in
2789
+ // several lists.** Until issue #725 these were `name -> the skin that claimed
2790
+ // it first`, and a second skin naming the same bone or constraint was refused
2791
+ // with *"a bone belongs to one skin"*. The rule was never the parser's and the
2792
+ // comment above it said so; what it rested on was that "which skin am I for"
2793
+ // has no answer for a name in two lists. It has one, and the runtime gives it:
2794
+ // `Skeleton.updateCache` activates the bones of the skin being WORN, so a bone
2795
+ // two mutually exclusive variants both list is active under either — measured
2796
+ // on a hand-forged file the refusal used to prevent, `bone.active` true under
2797
+ // each of the two skins, false under a third that lists nothing and false with
2798
+ // no skin set. `Skin.addSkin` deduplicates by object identity, so even a
2799
+ // consumer combining both variants gets the bone once. The format is a
2800
+ // per-skin SET and rigc now says the same thing.
2801
+ //
2802
+ // ⚠️ **The worn skin, and only the worn skin.** `updateCache` reads
2803
+ // `this.skin` and never `SkeletonData.defaultSkin` — the default-skin fallback
2804
+ // is `getAttachment`'s and covers art alone — so a `skin: true` bone that only
2805
+ // the `default` skin lists is measured INACTIVE under every other skin and
2806
+ // with no skin set. That is why nothing here is a union, and it is the one
2807
+ // shape an author is most likely to write expecting "always on".
2808
+ //
2809
+ // What the rule was really guarding — a second list that was meant to name a
2810
+ // different bone — is not derivable from the file, so it is not refused. Every
2811
+ // refusal that IS derivable stays: a name the rig does not declare, a name
2812
+ // declared under another constraint kind, and both halves of the `skin: true`
2813
+ // switch, each of which the rig suite now measures on a shared member.
2814
+ const skinBoneUse = new Set<string>();
2815
+ const skinConstraintUse = new Set<string>();
2816
+ if (spec.skins !== undefined) {
2817
+ if (!isObj(spec.skins)) throw new CompileError(`${where}: "skins" is an object keyed by skin name`);
2818
+ const collision = RIG_SKIN_KEYS.find((key) => slotNames.has(key));
2819
+ if (collision !== undefined) {
2820
+ // The long form is recognised by these keys, so a slot of one of those
2821
+ // names is genuinely ambiguous in the short form. Guessing either way
2822
+ // loses an attachment table or a member list in silence.
2823
+ throw new CompileError(
2824
+ `${where}: a slot is called "${collision}", which is one of the keys that tell a skin's long form ` +
2825
+ '(`{ "attachments": {…}, "bones": [...] }`) from its short one (`slotName -> placeholder -> attachment`): ' +
2826
+ `${RIG_SKIN_KEYS.join(', ')}. Rename the slot.`,
2827
+ );
2828
+ }
2829
+ for (const [skinName, skin] of Object.entries(spec.skins)) {
2830
+ const at = `${where}: skin "${skinName}"`;
2831
+ const parts = splitRigSkin(skin, at);
2832
+ for (const bone of parts.bones) {
2833
+ if (!seen.has(bone)) {
2834
+ throw new CompileError(`${at} activates bone "${bone}", which this rig does not declare`);
2835
+ }
2836
+ skinBoneUse.add(bone);
2837
+ const declared = spec.bones.find((b) => b.name === bone);
2838
+ if (declared?.skin !== true) {
2839
+ throw new CompileError(
2840
+ `${at} activates bone "${bone}", but that bone does not declare \`"skin": true\`. ` +
2841
+ 'Skeleton.updateCache starts a bone active unless it is skinRequired, so this list changes nothing — ' +
2842
+ 'the bone poses under every skin.',
2843
+ );
2844
+ }
2845
+ }
2846
+ for (const type of RIG_SKIN_CONSTRAINT_KEYS) {
2847
+ for (const name of parts.constraints[type]) {
2848
+ const facts = constraintFacts.get(constraintAt(type, name));
2849
+ if (facts === undefined) {
2850
+ // The lookup is by name AND type here for the same reason the parser's
2851
+ // is, so "no constraint of this kind" and "no constraint at all" are
2852
+ // two different misses and say so.
2853
+ const kinds = constraintKinds.get(name) ?? [];
2854
+ if (kinds.length === 0) {
2855
+ throw new CompileError(`${at} activates ${type} constraint "${name}", which this rig does not declare`);
2856
+ }
2857
+ // `findConstraint(name, IkConstraintData)` resolves by name AND type,
2858
+ // and the parser throws on the miss.
2859
+ throw new CompileError(
2860
+ `${at} lists "${name}" under "${type}", but the rig declares it as a "${kinds.join('", "')}" constraint — ` +
2861
+ 'a skin looks its constraints up by name AND type, so this one is a miss and the loader throws',
2862
+ );
2863
+ }
2864
+ skinConstraintUse.add(constraintAt(type, name));
2865
+ if (!facts.skinRequired) {
2866
+ throw new CompileError(
2867
+ `${at} activates ${type} constraint "${name}", but that constraint does not declare \`"skin": true\`. ` +
2868
+ 'A constraint is active unless it is skinRequired, so this list changes nothing.',
2869
+ );
2870
+ }
2871
+ }
2872
+ }
2873
+ }
2874
+ }
2875
+ // The other direction, and the silent one that costs a pose: an object that
2876
+ // declared `skin: true` and appears in no list is switched off under every skin
2877
+ // there is. Outside the block above on purpose — a rig with no `skins` at all
2878
+ // is the strongest case of it. A listed bone activates its ancestors too
2879
+ // (`Skeleton.ts:198-205`), so a parent reachable only that way is not dead.
2880
+ const activated = new Set(skinBoneUse);
2881
+ const parentOf = new Map(spec.bones.map((b) => [b.name, b.parent]));
2882
+ for (const bone of [...activated]) {
2883
+ for (let cursor = parentOf.get(bone); cursor; cursor = parentOf.get(cursor)) activated.add(cursor);
2884
+ }
2885
+ for (const bone of spec.bones) {
2886
+ if (bone.skin === true && !activated.has(bone.name)) {
2887
+ throw new CompileError(
2888
+ `${where}: bone "${bone.name}" declares \`"skin": true\` but no skin activates it, so it is never active — ` +
2889
+ 'list it in the skin it belongs to, or drop the flag',
2890
+ );
2891
+ }
2892
+ }
2893
+ for (const { type, name, skinRequired } of constraintsDeclared) {
2894
+ if (skinRequired && !skinConstraintUse.has(constraintAt(type, name))) {
2895
+ throw new CompileError(
2896
+ `${where}: ${type} constraint "${name}" declares \`"skin": true\` but no skin activates it, so it never runs — ` +
2897
+ `list it in that skin's "${type}" array, or drop the flag`,
2898
+ );
2899
+ }
2900
+ }
2901
+
2902
+ if (raw.events !== undefined) {
2903
+ if (!isObj(raw.events)) {
2904
+ throw new CompileError(
2905
+ `${where}: "events" is an object keyed by event name (\`{ "footstep": {} }\`), not an array — the format's own shape`,
2906
+ );
2907
+ }
2908
+ for (const [name, def] of Object.entries(raw.events)) {
2909
+ if (name.length === 0) throw new CompileError(`${where}: an event has an empty name`);
2910
+ if (!isObj(def)) {
2911
+ throw new CompileError(`${where}: event "${name}" must be an object of payload defaults (use {} for none)`);
2912
+ }
2913
+ for (const field of ['int', 'float', 'volume', 'balance'] as const) {
2914
+ const v = def[field];
2915
+ if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v))) {
2916
+ throw new CompileError(`${where}: event "${name}" has ${field} ${JSON.stringify(v)}, which is not a finite number`);
2917
+ }
2918
+ }
2919
+ if (def.int !== undefined && !Number.isInteger(def.int)) {
2920
+ throw new CompileError(`${where}: event "${name}" has int ${JSON.stringify(def.int)}; the payload is an integer`);
2921
+ }
2922
+ for (const field of ['string', 'audio'] as const) {
2923
+ if (def[field] !== undefined && typeof def[field] !== 'string') {
2924
+ throw new CompileError(`${where}: event "${name}" has ${field} ${JSON.stringify(def[field])}, which is not a string`);
2925
+ }
2926
+ }
2927
+ // SkeletonJson.ts:478-481 reads these two ONLY inside `if (data.audioPath)`.
2928
+ // Without an audio path they are dropped with no error, so a spec that
2929
+ // wrote them down would carry a number no runtime ever reads.
2930
+ for (const field of ['volume', 'balance'] as const) {
2931
+ if (def[field] !== undefined && def.audio === undefined) {
2932
+ throw new CompileError(
2933
+ `${where}: event "${name}" declares ${field} but no "audio"; the parser reads ${field} only when an audio path is set, so it would be dropped in silence`,
2934
+ );
2935
+ }
2936
+ }
2937
+ }
2938
+ }
2939
+
2940
+ return spec;
2941
+ }