spine-rigc 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rig.ts ADDED
@@ -0,0 +1,731 @@
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 { CompileError, NotImplementedError } from './errors.ts';
63
+
64
+ export { CompileError, NotImplementedError };
65
+
66
+ /** The only version this compiler reads. */
67
+ export const RIG_SPEC_VERSION = 'rigc-rig/1';
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // skeleton header — `root.skeleton` (SkeletonJson.ts:75-87)
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * The setup-pose bounding box and the runtime hints, all optional.
75
+ *
76
+ * `x`/`y` default to 0 and `width`/`height` fall back to the cut manifest's crop
77
+ * when there is one. With neither a manifest nor a declaration here the compile
78
+ * fails by name: `width`/`height` are what `A14_NO_FULL_FRAME_MESH` and
79
+ * `A19_OVERLAY_PNGS_HAVE_ALPHA` measure against, and a guessed stage is a gate
80
+ * that measures against a number nobody wrote down.
81
+ *
82
+ * `spine` is not here: rigc emits its own version label and `A16` re-checks it.
83
+ * `hash` is not here either — it is the editor's change-detection token and
84
+ * inventing one would be claiming an export this file did not come from.
85
+ */
86
+ export interface RigSkeletonHeader {
87
+ x?: number;
88
+ y?: number;
89
+ width?: number;
90
+ height?: number;
91
+ /** Nonessential; `SkeletonData.fps` stays 30 when absent. */
92
+ fps?: number;
93
+ /** 4.2+; the runtime's physics/scale reference. Parser default 100. */
94
+ referenceScale?: number;
95
+ /** Nonessential path hint the editor writes; carried through verbatim. */
96
+ images?: string;
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // bones — `root.bones[]` (SkeletonJson.ts:90-118)
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * `BoneData.ts:80`. Resolved by `Utils.enumValue`, which upper-cases the first
105
+ * letter, so `"noScale"` and `"NoScale"` both load; rigc accepts either and
106
+ * emits the lower-camel spelling the editor writes.
107
+ *
108
+ * ⚠️ 4.0/4.1 called this field `transform`. That name still *loads* in 4.3 and
109
+ * the inheritance silently falls back to Normal — assertion `A02`.
110
+ */
111
+ export type RigBoneInherit = 'normal' | 'onlyTranslation' | 'noRotationOrReflection' | 'noScale' | 'noScaleOrReflection';
112
+
113
+ export const RIG_BONE_INHERIT: readonly RigBoneInherit[] = [
114
+ 'normal',
115
+ 'onlyTranslation',
116
+ 'noRotationOrReflection',
117
+ 'noScale',
118
+ 'noScaleOrReflection',
119
+ ];
120
+
121
+ /**
122
+ * Take a bone's setup transform from the cut manifest rather than from a literal.
123
+ *
124
+ * ⭐ This is the one place the rig spec deliberately does not mirror Spine, and
125
+ * the reason is the oldest rule in this project: **the compiler never re-measures
126
+ * art, and a measured number lives in exactly one file.** A rig that wrote
127
+ * `x: 456.5` would be a second copy of a part offset the manifest already holds,
128
+ * and the two would drift the first time the art moved — silently, because both
129
+ * files would still be valid.
130
+ *
131
+ * Exactly one of `anchor` / `slotWindow` / `meshCenter` may be given, and it
132
+ * supplies the bone's `x` and `y`. All three name a point in **crop pixels, y
133
+ * down**; the compiler converts it to Spine world (y up, origin at the crop's
134
+ * bottom-left) and then into the parent bone's local space, so a rotated parent
135
+ * is handled by the same inverse the mesh binder uses.
136
+ */
137
+ export interface RigBoneFrom {
138
+ /** A key of the manifest's `anchors` block: `[x, y]` or `[x, y, facing_deg]`. */
139
+ anchor?: string;
140
+ /** The centre of a manifest part's window, named by the rig slot it fills. */
141
+ slotWindow?: string;
142
+ /** A manifest part's `mesh.center` — the aperture a ring deforms about. */
143
+ meshCenter?: string;
144
+ /**
145
+ * Where the setup rotation comes from. Omit and no rotation is emitted.
146
+ *
147
+ * `axis` — the manifest's `axis.deg`, negated into Spine's y-up CCW. This
148
+ * is the keystone of an articulated cut: the stroke is a
149
+ * translateX along this bone, so a sibling cut at another camera
150
+ * angle changes one number instead of every key.
151
+ * `anchor` — the third element of the named anchor, a screen-space facing
152
+ * angle. A grip whose local +X points radially outward turns
153
+ * "expand the ring" into one shared translate key.
154
+ */
155
+ rotation?: 'axis' | 'anchor';
156
+ }
157
+
158
+ /**
159
+ * One bone. Spine's field set, Spine's defaults (`SkeletonJson.ts:90-118`).
160
+ *
161
+ * `parent` is resolved by name and **must be declared earlier in the array** —
162
+ * the parser resolves against the bones it has already read, so a forward
163
+ * reference is not a rigc restriction.
164
+ */
165
+ export interface RigBone {
166
+ name: string;
167
+ /** Omitted only by the skeleton's single root bone. */
168
+ parent?: string;
169
+ /** Default 0. Cosmetic in a renderer; part of a faithful reproduction. */
170
+ length?: number;
171
+ /** Local to the parent. Default 0. Supplied by `from` when that is given. */
172
+ x?: number;
173
+ y?: number;
174
+ /** Degrees, CCW, y up. Default 0. Supplied by `from.rotation` when given. */
175
+ rotation?: number;
176
+ /** Default 1. */
177
+ scaleX?: number;
178
+ scaleY?: number;
179
+ /** Default 0. */
180
+ shearX?: number;
181
+ shearY?: number;
182
+ /** Default `normal`. 4.2+ name; 4.0/4.1 called it `transform` — see A02. */
183
+ inherit?: RigBoneInherit;
184
+ /** Default false → `BoneData.skinRequired`. */
185
+ skin?: boolean;
186
+ /** `rrggbbaa`. Editor affordance; no rendering effect. */
187
+ color?: string;
188
+ /**
189
+ * The editor's icon for this bone. Editor affordance; no rendering effect,
190
+ * and no assertion checks the name — the icon vocabulary belongs to the
191
+ * editor, so an unknown one is not rigc's error to raise.
192
+ */
193
+ icon?: string;
194
+ /** rigc extension — see `RigBoneFrom`. */
195
+ from?: RigBoneFrom;
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // slots — `root.slots[]` (SkeletonJson.ts:121-141)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /** `SlotData.ts:64`. */
203
+ export type RigSlotBlend = 'normal' | 'additive' | 'multiply' | 'screen';
204
+
205
+ export const RIG_SLOT_BLEND: readonly RigSlotBlend[] = ['normal', 'additive', 'multiply', 'screen'];
206
+
207
+ /**
208
+ * One slot. **The array order IS the draw order** — there is no separate setup
209
+ * draw-order field anywhere in the format.
210
+ *
211
+ * The rig's slot list is the CANONICAL table, which is a slightly stronger claim
212
+ * than "the slots this cut emits". A cut whose manifest carries no part for a
213
+ * slot does not emit it, and the emitted array is then a *subsequence* of this
214
+ * one; that is what `A26_SLOT_DRAW_ORDER` checks. Declaring a slot no cut fills
215
+ * is therefore legitimate — it fixes where that slot will sit when a cut does
216
+ * fill it.
217
+ */
218
+ export interface RigSlot {
219
+ name: string;
220
+ /** Required. A miss throws in the parser: `Couldn't find bone … for slot …`. */
221
+ bone: string;
222
+ /**
223
+ * The setup-pose attachment name, or `null` for "show nothing".
224
+ *
225
+ * ⚠️ For a cut compiled with a motion spec this is **not** where the setup pose
226
+ * comes from: `motion.setup` owns it, because which of the two overlay
227
+ * mechanisms a slot uses (attachment + alpha 0, or attachment swapping) is a
228
+ * decision about time. Declaring it in both is a compile error.
229
+ */
230
+ attachment?: string | null;
231
+ /** `rrggbbaa`. Default opaque white. */
232
+ color?: string;
233
+ /** Two-colour tint, `rrggbb`. 🚫 `A12_NO_DARK_COLOR` under `spine-html`. */
234
+ dark?: string;
235
+ /** Default `normal`. */
236
+ blend?: RigSlotBlend;
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // attachments — `readAttachment` (SkeletonJson.ts:535-654)
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Which builder in `src/mesh.ts` makes this mesh's geometry, and its parameters.
245
+ *
246
+ * The builders stay **code** and are invoked by **data**: they encode a
247
+ * deformation model (what is pinned, what may move, how authority falls off),
248
+ * and a model is not a table of numbers.
249
+ *
250
+ * ⚠️ A cut with a manifest does not use this. There the generator is invoked
251
+ * through the manifest's `mesh` block, because everything a generator needs —
252
+ * the mask contour, the aperture centre, the part window — is *measured art*,
253
+ * and measured art lives in the manifest. `generator` is for a skeleton with no
254
+ * manifest behind it.
255
+ */
256
+ export type RigMeshGenerator =
257
+ | {
258
+ kind: 'ring';
259
+ /** The seam contour, in part-local pixels, y down. At least 6 points. */
260
+ hull: Array<[number, number]>;
261
+ /** Aperture centre, part-local pixels, y down. */
262
+ center: [number, number];
263
+ /** Inner ring position between the centre (0) and the hull (1). */
264
+ inner: number;
265
+ /** Part window size, for UVs. */
266
+ size: [number, number];
267
+ /** Directional authority across an axis — see `sideWeight` in mesh.ts. */
268
+ bias?: { axis_deg: number; ramp: [number, number] };
269
+ /** Control bones, by name. More than one splits the ring by angle. */
270
+ controls: string[];
271
+ }
272
+ | {
273
+ kind: 'ribbon';
274
+ /** Part window size in pixels. The strip spans it. */
275
+ size: [number, number];
276
+ /** Cross rows, entry first. Triangles = 2 * (rows - 1). */
277
+ rows: number;
278
+ /** The bone chain the strip rides, root first. */
279
+ chain: string[];
280
+ }
281
+ | {
282
+ /**
283
+ * 🚧 Not implemented. An alpha-contour triangulator (ear clipping over the
284
+ * part's own mask) is the obvious third builder and `src/mesh.ts` does not
285
+ * have one; `buildRingMesh` and `buildRibbonMesh` are the only two.
286
+ */
287
+ kind: 'contour';
288
+ [param: string]: unknown;
289
+ };
290
+
291
+ /** `SkeletonJson.ts:540-559`. `type` defaults to `region` (`:539`). */
292
+ export interface RigRegionAttachment {
293
+ type?: 'region';
294
+ /** The atlas region to resolve. Defaults to the attachment's own name. */
295
+ path?: string;
296
+ /**
297
+ * rigc extension: a PNG, relative to the rig's `images` directory.
298
+ *
299
+ * ⭐ Naming a file instead of a size is the point. `width`/`height` have **no
300
+ * parser default** — an omission loads as `NaN` and every UV collapses with no
301
+ * error — so a spec that restates them by hand carries a number that can
302
+ * disagree with the pixels. Give an `image` and rigc reads the PNG header and
303
+ * fills both in; the atlas page it emits is that same file, so the size in the
304
+ * skeleton and the size in the atlas cannot drift apart.
305
+ */
306
+ image?: string;
307
+ x?: number;
308
+ y?: number;
309
+ /** Degrees. Cancels a rotated bone for a plate authored in screen space. */
310
+ rotation?: number;
311
+ scaleX?: number;
312
+ scaleY?: number;
313
+ /** Required by the format; may be omitted here when `image` is given. */
314
+ width?: number;
315
+ height?: number;
316
+ /** `rrggbbaa`. */
317
+ color?: string;
318
+ }
319
+
320
+ /**
321
+ * `SkeletonJson.ts:568-605`. Either authored geometry or a `generator`, never
322
+ * both.
323
+ *
324
+ * ⚠️ `vertices` has no encoding flag anywhere in the format. If its length
325
+ * equals `uvs.length` the parser reads unweighted x/y pairs; otherwise it reads
326
+ * the weighted run `boneCount, (boneIndex, bindX, bindY, weight) × n, …`. A
327
+ * coincidental length match reads weight data as coordinates, silently — which
328
+ * is `A04_MESH_TRIANGLES_AND_ENCODING`.
329
+ */
330
+ /**
331
+ * One bone's pull on one vertex of an authored mesh, **named**.
332
+ *
333
+ * `x`/`y` are the vertex's position in that bone's own setup space — the same
334
+ * pair Spine's weighted run carries after the bone index. `weight` is its share;
335
+ * a vertex's weights sum to 1.
336
+ */
337
+ export interface RigMeshBinding {
338
+ /** Resolved against the rig's bone list at emit. An unknown name is refused. */
339
+ bone: string;
340
+ x: number;
341
+ y: number;
342
+ weight: number;
343
+ }
344
+
345
+ export interface RigMeshAttachment {
346
+ type: 'mesh';
347
+ path?: string;
348
+ image?: string;
349
+ /** Its length defines `worldVerticesLength`; required with authored geometry. */
350
+ uvs?: number[];
351
+ triangles?: number[];
352
+ /**
353
+ * Geometry, in one of two forms.
354
+ *
355
+ * **Unweighted** — one `x, y` pair per uv pair, and `vertices.length` equals
356
+ * `uvs.length`. Nothing here names a bone, so nothing here can be rebound.
357
+ *
358
+ * **Weighted, raw** — Spine's own encoding,
359
+ * `boneCount, (boneIndex, bindX, bindY, weight) x n` per vertex, where
360
+ * `boneIndex` is a position in the EMITTED bone array. 🚨 That array is not
361
+ * something a rig spec writes or can see, so those indices shift under any
362
+ * edit to the bone list and every vertex silently rebinds — the mesh still
363
+ * loads, every weight still sums to 1, and nothing in the file objects. rigc
364
+ * therefore refuses this form unless the attachment says `boneIndexing: "raw"`
365
+ * out loud. Use `weights` instead.
366
+ */
367
+ vertices?: number[];
368
+ /**
369
+ * Weighted geometry that binds **by name**: one entry per vertex, each a list
370
+ * of `{ bone, x, y, weight }`. This is the default form and the one everything
371
+ * else in a rig spec already uses — a bone's `parent`, a slot's `bone`, a
372
+ * constraint's `bones` and `target` all resolve by name and refuse a miss by
373
+ * name. The compiler resolves these to indices on emit, so inserting a bone
374
+ * moves the indices and changes nothing about what the mesh is bound to.
375
+ *
376
+ * Mutually exclusive with `vertices`.
377
+ */
378
+ weights?: RigMeshBinding[][];
379
+ /**
380
+ * How a weighted `vertices` run names its bones. Default `"name"`, which means
381
+ * "there is no weighted run here — use `weights`". `"raw"` opts into the index
382
+ * encoding above, for a spec transcribed from an export that has not been
383
+ * migrated yet. It is an opt-in because the cost of it is silence.
384
+ */
385
+ boneIndexing?: 'name' | 'raw';
386
+ /** Hull vertex count. The loader stores it doubled. */
387
+ hull?: number;
388
+ /** Edge index pairs; nonessential, editor-drawn. */
389
+ edges?: number[];
390
+ width?: number;
391
+ height?: number;
392
+ color?: string;
393
+ /** Build the geometry instead of authoring it — see `RigMeshGenerator`. */
394
+ generator?: RigMeshGenerator;
395
+ }
396
+
397
+ /**
398
+ * The four types the format holds and rigc's emitter does not cover yet. They
399
+ * are in the type so a spec can *say* them and get a named
400
+ * `NotImplementedError`; the alternative is the parser's own behaviour, which is
401
+ * to return `null` for an unknown `type` and drop the attachment without a word
402
+ * (`SkeletonJson.ts:653`).
403
+ */
404
+ export interface RigUnimplementedAttachment {
405
+ type: 'boundingbox' | 'point' | 'clipping' | 'path' | 'linkedmesh';
406
+ [field: string]: unknown;
407
+ }
408
+
409
+ export type RigAttachment = RigRegionAttachment | RigMeshAttachment | RigUnimplementedAttachment;
410
+
411
+ /** `slotName -> placeholderName -> attachment` (`SkeletonJson.ts:431-439`). */
412
+ export type RigSkin = Record<string, Record<string, RigAttachment>>;
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // constraints — `root.constraints[]` (SkeletonJson.ts:144-369), the 4.3 shape
416
+ // ---------------------------------------------------------------------------
417
+
418
+ /**
419
+ * 4.3 folds every constraint into ONE array with a `type` discriminator. The
420
+ * 4.1/4.2 shape — top-level `ik`/`transform`/`path`/`physics` arrays — still
421
+ * loads clean and the constraints simply vanish, which is `A01`.
422
+ *
423
+ * 🚨 An entry whose `type` matches no case is dropped with no error and no
424
+ * `default:` branch (`:148-367`). rigc therefore refuses an unknown `type` by
425
+ * name rather than passing it through.
426
+ */
427
+ export interface RigConstraintCommon {
428
+ name: string;
429
+ /** Default false → `skinRequired` (`:147`). */
430
+ skin?: boolean;
431
+ }
432
+
433
+ /** `type: "ik"` (`:149-176`). `scaleY` is 4.3's replacement for 4.2's `uniform`. */
434
+ export interface RigIkConstraint extends RigConstraintCommon {
435
+ type: 'ik';
436
+ /** At least one, resolved by name; a miss throws in the parser. */
437
+ bones: string[];
438
+ target: string;
439
+ /** `ConstraintData.ts:50`. Absent → `None`. */
440
+ scaleY?: 'none' | 'uniform' | 'volume';
441
+ /** Default 1. */
442
+ mix?: number;
443
+ /** Default 0. */
444
+ softness?: number;
445
+ /** Default true → `bendDirection = ±1`. */
446
+ bendPositive?: boolean;
447
+ /** Default false. */
448
+ compress?: boolean;
449
+ stretch?: boolean;
450
+ }
451
+
452
+ /**
453
+ * One entry of a transform constraint's `properties` map: which source property
454
+ * drives which target properties, and by how much (`:241`, `:521`).
455
+ *
456
+ * The `from` and `to` names are drawn from a fixed six — `rotate`, `x`, `y`,
457
+ * `scaleX`, `scaleY`, `shearY` — and **anything else throws in the parser**.
458
+ */
459
+ export interface RigTransformProperty {
460
+ offset?: number;
461
+ to: Record<string, { offset?: number; max?: number; scale?: number }>;
462
+ }
463
+
464
+ /** `type: "transform"` (`:177-268`) — rebuilt from scratch in 4.3. */
465
+ export interface RigTransformConstraint extends RigConstraintCommon {
466
+ type: 'transform';
467
+ bones: string[];
468
+ /** 4.2 called this `target`. */
469
+ source: string;
470
+ localSource?: boolean;
471
+ localTarget?: boolean;
472
+ additive?: boolean;
473
+ clamp?: boolean;
474
+ /** `fromName -> { offset, to: { toName -> { offset, max, scale } } }`. */
475
+ properties?: Record<string, RigTransformProperty>;
476
+ /** The offsets array. Default 0 each. */
477
+ rotation?: number;
478
+ x?: number;
479
+ y?: number;
480
+ scaleX?: number;
481
+ scaleY?: number;
482
+ shearY?: number;
483
+ /**
484
+ * Default 1. ⚠️ Each mix is read **only if the matching `to` property was
485
+ * declared** (`:259-264`), so a mix without its property is dead data.
486
+ */
487
+ mixRotate?: number;
488
+ mixX?: number;
489
+ /** Defaults to `mixX`. */
490
+ mixY?: number;
491
+ mixScaleX?: number;
492
+ /** Defaults to `mixScaleX`. */
493
+ mixScaleY?: number;
494
+ mixShearY?: number;
495
+ }
496
+
497
+ /**
498
+ * `type: "physics"` (`:301-339`), 4.2+.
499
+ *
500
+ * ⚠️ The five components all default to 0, so a constraint that names none of
501
+ * them parses cleanly and does absolutely nothing — `A23`.
502
+ */
503
+ export interface RigPhysicsConstraint extends RigConstraintCommon {
504
+ type: 'physics';
505
+ bone: string;
506
+ /** The components. All zero = a constraint that parses and does nothing. */
507
+ x?: number;
508
+ y?: number;
509
+ rotate?: number;
510
+ scaleX?: number;
511
+ shearX?: number;
512
+ /** 4.3; absent → `ScaleYMode.None`. */
513
+ scaleYMode?: 'none' | 'uniform' | 'volume';
514
+ /** Default 5000. */
515
+ limit?: number;
516
+ /** Default 60 → `step = 1/fps`. */
517
+ fps?: number;
518
+ /** Defaults: 0.5 / 100 / 0.85 / 1 / 0 / 0 / 1. */
519
+ inertia?: number;
520
+ strength?: number;
521
+ damping?: number;
522
+ /** Stored as `massInverse = 1/mass`, so 0 becomes Infinity — `A23`. */
523
+ mass?: number;
524
+ wind?: number;
525
+ gravity?: number;
526
+ mix?: number;
527
+ inertiaGlobal?: boolean;
528
+ strengthGlobal?: boolean;
529
+ dampingGlobal?: boolean;
530
+ massGlobal?: boolean;
531
+ windGlobal?: boolean;
532
+ gravityGlobal?: boolean;
533
+ mixGlobal?: boolean;
534
+ }
535
+
536
+ /**
537
+ * 🚧 `path` (`:269-300`) and `slider` (`:340-366`) are in the type so a spec can
538
+ * say them and be refused by name. Neither appears anywhere in the benchmark
539
+ * corpus (SPEC_COVERAGE part 4-2), so neither is on the ladder's critical path.
540
+ */
541
+ export interface RigUnimplementedConstraint extends RigConstraintCommon {
542
+ type: 'path' | 'slider';
543
+ [field: string]: unknown;
544
+ }
545
+
546
+ export type RigConstraint =
547
+ | RigIkConstraint
548
+ | RigTransformConstraint
549
+ | RigPhysicsConstraint
550
+ | RigUnimplementedConstraint;
551
+
552
+ // ---------------------------------------------------------------------------
553
+ // invariants — what skeleton JSON cannot say about itself
554
+ // ---------------------------------------------------------------------------
555
+
556
+ /**
557
+ * Structural facts the emitted artifact does not record, handed to the validator
558
+ * so its archetype assertions have something to check instead of a guess.
559
+ *
560
+ * These are the fields that used to be properties of a hard-coded formation.
561
+ * They are optional, and an assertion whose field is absent reports **SKIP** —
562
+ * never a pass, because an assertion with nothing to look at has not looked.
563
+ */
564
+ export interface RigInvariants {
565
+ /**
566
+ * How many slots of this rig may carry a mesh. A budget, not a Spine rule:
567
+ * every mesh is a canvas that re-rasterises whenever a bone driving it moves.
568
+ */
569
+ meshSlots?: number;
570
+ /**
571
+ * How many triangles one of those meshes may carry. Also a budget, and also
572
+ * not a Spine rule — the editor's own example projects ship meshes several
573
+ * times this size and they are perfectly valid.
574
+ *
575
+ * ⚠️ Declare it or `A13_MESH_BUDGET` has nothing to measure against and SKIPs.
576
+ * A number baked into the validator would be one project's frame time
577
+ * masquerading as a property of the format, and would fail every foreign
578
+ * skeleton that is merely denser than that project can afford.
579
+ */
580
+ meshTriangles?: number;
581
+ /**
582
+ * The bone whose setup rotation carries the cut's insertion axis. Its subtree
583
+ * is authored in **axis space** — translateX only — which is what lets one set
584
+ * of keys move to a cut at another camera angle (`A24`).
585
+ */
586
+ axisBone?: string;
587
+ /**
588
+ * The bone carrying the inserting mass. Its own inward keys spend the same
589
+ * clearance the stroke does, so `A29`/`A30` add them together.
590
+ */
591
+ massBone?: string;
592
+ /** Parentage that must never happen, with the reason it is tempting (`A25`). */
593
+ detached?: Array<{ bone: string; notUnder: string; why?: string }>;
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // the file
598
+ // ---------------------------------------------------------------------------
599
+
600
+ export interface RigSpec {
601
+ spec: 'rigc-rig/1';
602
+ /**
603
+ * The rig's own name. A motion spec's `archetype` field must equal it: the
604
+ * spec was authored against one formation, and pairing it with a different rig
605
+ * silently produces keys aimed at bones that mean something else.
606
+ */
607
+ name: string;
608
+ note?: string;
609
+ skeleton?: RigSkeletonHeader;
610
+ /**
611
+ * Base directory for every `image` in this file, relative to the rig file
612
+ * itself. The CLI's `--images <dir>` overrides it (and is then relative to the
613
+ * working directory), which is how a foreign corpus is compiled without
614
+ * editing its rig spec.
615
+ */
616
+ images?: string;
617
+ bones: RigBone[];
618
+ slots: RigSlot[];
619
+ /** At least `default`, which becomes `skeletonData.defaultSkin` (`:441`). */
620
+ skins?: Record<string, RigSkin>;
621
+ constraints?: RigConstraint[];
622
+ invariants?: RigInvariants;
623
+ }
624
+
625
+ // ---------------------------------------------------------------------------
626
+ // reading
627
+ // ---------------------------------------------------------------------------
628
+
629
+ function isObj(v: unknown): v is Record<string, unknown> {
630
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
631
+ }
632
+
633
+ /**
634
+ * Parse and check the envelope, then hand back a typed spec.
635
+ *
636
+ * What is checked here is what makes the REST of the compiler able to assume its
637
+ * inputs: the version tag, the two required arrays, name uniqueness, and that
638
+ * every parent and every slot bone resolves against a bone declared earlier.
639
+ * Deeper checks (does an attachment's image exist, does a constraint's target
640
+ * bone exist) belong where the data is used, so their message can name the
641
+ * consumer.
642
+ */
643
+ export function parseRigSpec(raw: unknown, where: string): RigSpec {
644
+ if (!isObj(raw)) throw new CompileError(`${where}: a rig spec must be a JSON object`);
645
+ if (raw.spec !== RIG_SPEC_VERSION) {
646
+ throw new CompileError(`${where}: unknown rig spec version ${JSON.stringify(raw.spec)}, expected "${RIG_SPEC_VERSION}"`);
647
+ }
648
+ if (typeof raw.name !== 'string' || raw.name.length === 0) {
649
+ throw new CompileError(`${where}: a rig spec needs a "name" — a motion spec names it to pick this rig`);
650
+ }
651
+ if (!Array.isArray(raw.bones) || raw.bones.length === 0) {
652
+ throw new CompileError(`${where}: a rig spec needs a non-empty "bones" array`);
653
+ }
654
+ if (!Array.isArray(raw.slots)) {
655
+ throw new CompileError(`${where}: a rig spec needs a "slots" array (it may be empty; its ORDER is the draw order)`);
656
+ }
657
+ const spec = raw as unknown as RigSpec;
658
+
659
+ const seen = new Set<string>();
660
+ for (const bone of spec.bones) {
661
+ if (!isObj(bone) || typeof bone.name !== 'string' || bone.name.length === 0) {
662
+ throw new CompileError(`${where}: every bone needs a "name"`);
663
+ }
664
+ if (seen.has(bone.name)) {
665
+ throw new CompileError(`${where}: two bones are called "${bone.name}"; bone names are the join key for slots, meshes and timelines`);
666
+ }
667
+ seen.add(bone.name);
668
+ if (bone.parent === undefined) continue;
669
+ if (typeof bone.parent !== 'string' || !seen.has(bone.parent)) {
670
+ // The parser resolves `parent` against the bones it has already read, so a
671
+ // forward reference is not a rigc restriction — it is a bone with no parent
672
+ // in the loaded skeleton, which loads as a second root.
673
+ throw new CompileError(
674
+ `${where}: bone "${bone.name}" names parent ${JSON.stringify(bone.parent)}, which is not declared before it`,
675
+ );
676
+ }
677
+ if (bone.inherit !== undefined && !RIG_BONE_INHERIT.some((v) => v.toLowerCase() === String(bone.inherit).toLowerCase())) {
678
+ throw new CompileError(
679
+ `${where}: bone "${bone.name}" has inherit ${JSON.stringify(bone.inherit)}; known: ${RIG_BONE_INHERIT.join(', ')}`,
680
+ );
681
+ }
682
+ const from = bone.from;
683
+ if (from !== undefined) {
684
+ const sources = ['anchor', 'slotWindow', 'meshCenter'].filter((k) => from[k as keyof RigBoneFrom] !== undefined);
685
+ if (sources.length > 1) {
686
+ throw new CompileError(
687
+ `${where}: bone "${bone.name}" takes its position from more than one source (${sources.join(', ')}); name exactly one`,
688
+ );
689
+ }
690
+ if ((bone.x !== undefined || bone.y !== undefined) && sources.length === 1) {
691
+ throw new CompileError(
692
+ `${where}: bone "${bone.name}" declares both a literal x/y and from.${sources[0]}; the two would disagree the first time the art moved`,
693
+ );
694
+ }
695
+ if (from.rotation !== undefined && bone.rotation !== undefined) {
696
+ throw new CompileError(`${where}: bone "${bone.name}" declares both a literal rotation and from.rotation`);
697
+ }
698
+ if (from.rotation === 'anchor' && from.anchor === undefined) {
699
+ throw new CompileError(`${where}: bone "${bone.name}" wants its rotation from an anchor but names no from.anchor`);
700
+ }
701
+ }
702
+ }
703
+
704
+ const slotNames = new Set<string>();
705
+ for (const slot of spec.slots) {
706
+ if (!isObj(slot) || typeof slot.name !== 'string' || slot.name.length === 0) {
707
+ throw new CompileError(`${where}: every slot needs a "name"`);
708
+ }
709
+ if (slotNames.has(slot.name)) throw new CompileError(`${where}: two slots are called "${slot.name}"`);
710
+ slotNames.add(slot.name);
711
+ if (typeof slot.bone !== 'string' || !seen.has(slot.bone)) {
712
+ throw new CompileError(
713
+ `${where}: slot "${slot.name}" names bone ${JSON.stringify(slot.bone)}, which this rig does not declare`,
714
+ );
715
+ }
716
+ if (slot.blend !== undefined && !RIG_SLOT_BLEND.some((v) => v.toLowerCase() === String(slot.blend).toLowerCase())) {
717
+ throw new CompileError(`${where}: slot "${slot.name}" has blend ${JSON.stringify(slot.blend)}; known: ${RIG_SLOT_BLEND.join(', ')}`);
718
+ }
719
+ }
720
+
721
+ const constraintNames = new Set<string>();
722
+ for (const constraint of spec.constraints ?? []) {
723
+ if (!isObj(constraint) || typeof constraint.name !== 'string' || constraint.name.length === 0) {
724
+ throw new CompileError(`${where}: every constraint needs a "name"`);
725
+ }
726
+ if (constraintNames.has(constraint.name)) throw new CompileError(`${where}: two constraints are called "${constraint.name}"`);
727
+ constraintNames.add(constraint.name);
728
+ }
729
+
730
+ return spec;
731
+ }