spine-rigc 0.20.3 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rig.ts CHANGED
@@ -60,6 +60,7 @@
60
60
  * with no manifest at all declares them here.
61
61
  */
62
62
  import { CompileError, NotImplementedError } from './errors.ts';
63
+ import { refuseUnknownKeys } from './keys.ts';
63
64
 
64
65
  export { CompileError, NotImplementedError };
65
66
 
@@ -254,19 +255,6 @@ export interface RigSlot {
254
255
  // attachments — `readAttachment` (SkeletonJson.ts:535-654)
255
256
  // ---------------------------------------------------------------------------
256
257
 
257
- /**
258
- * Which builder in `src/mesh.ts` makes this mesh's geometry, and its parameters.
259
- *
260
- * The builders stay **code** and are invoked by **data**: they encode a
261
- * deformation model (what is pinned, what may move, how authority falls off),
262
- * and a model is not a table of numbers.
263
- *
264
- * ⚠️ A cut with a manifest does not use this. There the generator is invoked
265
- * through the manifest's `mesh` block, because everything a generator needs —
266
- * the mask contour, the aperture centre, the part window — is *measured art*,
267
- * and measured art lives in the manifest. `generator` is for a skeleton with no
268
- * manifest behind it.
269
- */
270
258
  /**
271
259
  * A greyscale sheet, in a part's own pixel grid, giving each vertex a depth —
272
260
  * what `yaw` and `pitch` otherwise derive from one cylinder radius.
@@ -352,104 +340,143 @@ export interface RigSoftRegion {
352
340
  mask: string;
353
341
  }
354
342
 
355
- export type RigMeshGenerator =
356
- | {
357
- kind: 'ring';
358
- /** The seam contour, in part-local pixels, y down. At least 6 points. */
359
- hull: Array<[number, number]>;
360
- /** Aperture centre, part-local pixels, y down. */
361
- center: [number, number];
362
- /** Inner ring position between the centre (0) and the hull (1). */
363
- inner: number;
364
- /** Part window size, for UVs. */
365
- size: [number, number];
366
- /** Directional authority across an axis — see `sideWeight` in mesh.ts. */
367
- bias?: { axis_deg: number; ramp: [number, number] };
368
- /** Control bones, by name. More than one splits the ring by angle. */
369
- controls: string[];
370
- }
371
- | {
372
- kind: 'ribbon';
373
- /** Part window size in pixels. The strip spans it. */
374
- size: [number, number];
375
- /** Cross rows, entry first. Triangles = 2 * (rows - 1). */
376
- rows: number;
377
- /** The bone chain the strip rides, root first. */
378
- chain: string[];
379
- }
380
- | {
381
- /**
382
- * A mesh cut to the part's own alpha silhouette: trace the mask, simplify
383
- * the outline, push it out by a margin, ear-clip it (`buildContourMesh`).
384
- *
385
- * ⭐ It takes no `size` and no geometry. The shape is MEASURED off the
386
- * attachment's own `image` — the same rule a region attachment's
387
- * `width`/`height` follow (R5) — so there is no number here that can
388
- * disagree with the pixels, and no polygon to keep in step with the art.
389
- *
390
- * 🚨 It is geometry, not a deformation model: every vertex is pinned to
391
- * the slot bone at weight 1, so an undeformed contour mesh draws exactly
392
- * what the region drew and no bone can bend it. See the section header in
393
- * [`src/mesh.ts`](mesh.ts) for what it buys instead, and reach for `ring`
394
- * or authored `weights` when a bone has to move the art.
395
- */
396
- kind: 'contour';
397
- /**
398
- * Douglas-Peucker tolerance in part-local pixels. Bigger spends fewer
399
- * vertices and cuts more corners; the builder measures how much of the art
400
- * the result still covers and refuses a mesh that clips it.
401
- */
402
- tolerance: number;
403
- /**
404
- * How far the outline is pushed out past the traced silhouette, in pixels.
405
- * Default 1. Simplification may bite `tolerance` pixels INTO the art, so
406
- * `margin >= tolerance` is the setting that survives the coverage check.
407
- */
408
- margin?: number;
409
- /** Refuse rather than emit more outline vertices than this. Default 64. */
410
- maxVertices?: number;
411
- /** Alpha at or above which a pixel counts as art, 1..255. Default 1. */
412
- alpha?: number;
413
- /** A depth map for this part — see `RigDepthMap`. */
414
- depth?: RigDepthMap;
415
- /** A soft region carried by its own bone — see `RigSoftRegion`. */
416
- soft?: RigSoftRegion;
417
- }
418
- | {
419
- /**
420
- * A lattice over the part window — the topology `docs/FACE.md` §4 turns a
421
- * plate into so a turn has columns to move.
422
- *
423
- * ⭐ It takes no `size`: like a `contour`, the window is the attachment's
424
- * own `image`, so there is no number here that can disagree with the
425
- * pixels. Every vertex is pinned to the slot bone at weight 1, which
426
- * makes the lattice geometry to DEFORM rather than an authority split —
427
- * reach for `ring` when bones have to move it.
428
- */
429
- kind: 'grid';
430
- /**
431
- * Column positions across the window, 0..1, ascending. At least 2.
432
- *
433
- * ⚠️ Positions, not a count, and that is deliberate: FACE §4.1 places
434
- * columns where the drawing needs them, and the worked example's are
435
- * dense at the silhouette and sparse across the middle. They need not
436
- * reach the window edge — that example's run 0.0235 to 0.9765.
437
- */
438
- us?: number[];
439
- /** Row positions down the window, 0..1, ascending. At least 2. */
440
- vs?: number[];
441
- /**
442
- * Even division instead: `cols` columns and `rows` rows spanning the
443
- * whole window. A convenience for a plate with no shape to follow, and
444
- * refused beside `us`/`vs`, which say the same thing more precisely.
445
- */
446
- cols?: number;
447
- rows?: number;
448
- /** A depth map for this part — see `RigDepthMap`. */
449
- depth?: RigDepthMap;
450
- /** A soft region carried by its own bone — see `RigSoftRegion`. */
451
- soft?: RigSoftRegion;
452
- };
343
+ /**
344
+ * Directional authority across an axis — see `sideWeight` in
345
+ * [`mesh.ts`](mesh.ts).
346
+ *
347
+ * ⭐ It was an inline object type until issue #545. The four generator kinds
348
+ * were too: the union is spelled as four **named** interfaces now because
349
+ * `RIG_KEYS` pairs a key set with an interface by name, and a shape with no name
350
+ * is a shape the pairing cannot reach — so an anonymous corner of this file
351
+ * would have been a corner whose key set nothing checked.
352
+ */
353
+ export interface RigMeshBias {
354
+ /** The axis, in SCREEN degrees, y down — a manifest's own convention. */
355
+ axis_deg: number;
356
+ /** Signed distance across that axis over which authority goes 0 -> 1. */
357
+ ramp: [number, number];
358
+ }
359
+
360
+ /** A ring: a seam contour, an aperture inside it, and the bones that open it. */
361
+ export interface RigRingGenerator {
362
+ kind: 'ring';
363
+ /** The seam contour, in part-local pixels, y down. At least 6 points. */
364
+ hull: Array<[number, number]>;
365
+ /** Aperture centre, part-local pixels, y down. */
366
+ center: [number, number];
367
+ /** Inner ring position between the centre (0) and the hull (1). */
368
+ inner: number;
369
+ /** Part window size, for UVs. */
370
+ size: [number, number];
371
+ /** Directional authority across an axis — see `sideWeight` in mesh.ts. */
372
+ bias?: RigMeshBias;
373
+ /** Control bones, by name. More than one splits the ring by angle. */
374
+ controls: string[];
375
+ }
376
+
377
+ /** A ribbon: a strip of cross rows riding a bone chain. */
378
+ export interface RigRibbonGenerator {
379
+ kind: 'ribbon';
380
+ /** Part window size in pixels. The strip spans it. */
381
+ size: [number, number];
382
+ /** Cross rows, entry first. Triangles = 2 * (rows - 1). */
383
+ rows: number;
384
+ /** The bone chain the strip rides, root first. */
385
+ chain: string[];
386
+ }
387
+
388
+ /**
389
+ * A mesh cut to the part's own alpha silhouette: trace the mask, simplify
390
+ * the outline, push it out by a margin, ear-clip it (`buildContourMesh`).
391
+ *
392
+ * ⭐ It takes no `size` and no geometry. The shape is MEASURED off the
393
+ * attachment's own `image` — the same rule a region attachment's
394
+ * `width`/`height` follow (R5) — so there is no number here that can
395
+ * disagree with the pixels, and no polygon to keep in step with the art.
396
+ *
397
+ * 🚨 It is geometry, not a deformation model: every vertex is pinned to
398
+ * the slot bone at weight 1, so an undeformed contour mesh draws exactly
399
+ * what the region drew and no bone can bend it. See the section header in
400
+ * [`src/mesh.ts`](mesh.ts) for what it buys instead, and reach for `ring`
401
+ * or authored `weights` when a bone has to move the art.
402
+ */
403
+ export interface RigContourGenerator {
404
+ kind: 'contour';
405
+ /**
406
+ * Douglas-Peucker tolerance in part-local pixels. Bigger spends fewer
407
+ * vertices and cuts more corners; the builder measures how much of the art
408
+ * the result still covers and refuses a mesh that clips it.
409
+ */
410
+ tolerance: number;
411
+ /**
412
+ * How far the outline is pushed out past the traced silhouette, in pixels.
413
+ * Default 1. Simplification may bite `tolerance` pixels INTO the art, so
414
+ * `margin >= tolerance` is the setting that survives the coverage check.
415
+ */
416
+ margin?: number;
417
+ /** Refuse rather than emit more outline vertices than this. Default 64. */
418
+ maxVertices?: number;
419
+ /** Alpha at or above which a pixel counts as art, 1..255. Default 1. */
420
+ alpha?: number;
421
+ /** A depth map for this part — see `RigDepthMap`. */
422
+ depth?: RigDepthMap;
423
+ /** A soft region carried by its own bone — see `RigSoftRegion`. */
424
+ soft?: RigSoftRegion;
425
+ }
426
+
427
+ /**
428
+ * A lattice over the part window — the topology `docs/FACE.md` §4 turns a
429
+ * plate into so a turn has columns to move.
430
+ *
431
+ * ⭐ It takes no `size`: like a `contour`, the window is the attachment's
432
+ * own `image`, so there is no number here that can disagree with the
433
+ * pixels. Every vertex is pinned to the slot bone at weight 1, which
434
+ * makes the lattice geometry to DEFORM rather than an authority split —
435
+ * reach for `ring` when bones have to move it.
436
+ */
437
+ export interface RigGridGenerator {
438
+ kind: 'grid';
439
+ /**
440
+ * Column positions across the window, 0..1, ascending. At least 2.
441
+ *
442
+ * ⚠️ Positions, not a count, and that is deliberate: FACE §4.1 places
443
+ * columns where the drawing needs them, and the worked example's are
444
+ * dense at the silhouette and sparse across the middle. They need not
445
+ * reach the window edge — that example's run 0.0235 to 0.9765.
446
+ */
447
+ us?: number[];
448
+ /** Row positions down the window, 0..1, ascending. At least 2. */
449
+ vs?: number[];
450
+ /**
451
+ * Even division instead: `cols` columns and `rows` rows spanning the
452
+ * whole window. A convenience for a plate with no shape to follow, and
453
+ * refused beside `us`/`vs`, which say the same thing more precisely.
454
+ */
455
+ cols?: number;
456
+ rows?: number;
457
+ /** A depth map for this part — see `RigDepthMap`. */
458
+ depth?: RigDepthMap;
459
+ /** A soft region carried by its own bone — see `RigSoftRegion`. */
460
+ soft?: RigSoftRegion;
461
+ }
462
+
463
+ /**
464
+ * Which builder in `src/mesh.ts` makes this mesh's geometry, and its parameters.
465
+ *
466
+ * The builders stay **code** and are invoked by **data**: they encode a
467
+ * deformation model (what is pinned, what may move, how authority falls off),
468
+ * and a model is not a table of numbers.
469
+ *
470
+ * ⚠️ A cut with a manifest does not use this. There the generator is invoked
471
+ * through the manifest's `mesh` block, because everything a generator needs —
472
+ * the mask contour, the aperture centre, the part window — is *measured art*,
473
+ * and measured art lives in the manifest. `generator` is for a skeleton with no
474
+ * manifest behind it.
475
+ */
476
+ export type RigMeshGenerator = RigRingGenerator | RigRibbonGenerator | RigContourGenerator | RigGridGenerator;
477
+
478
+ /** The four `kind` names a generator may carry, and the order `RIG_KEYS` takes them in. */
479
+ export const RIG_GENERATOR_KINDS = ['ring', 'ribbon', 'contour', 'grid'] as const;
453
480
 
454
481
  /** `SkeletonJson.ts:540-559`. `type` defaults to `region` (`:539`). */
455
482
  export interface RigRegionAttachment {
@@ -849,6 +876,19 @@ export interface RigConstraintCommon {
849
876
  skin?: boolean;
850
877
  }
851
878
 
879
+ /**
880
+ * `ScaleYMode` (`ConstraintData.ts:37-45`), which an **ik** and a **physics**
881
+ * constraint both carry under the JSON key `scaleY`.
882
+ *
883
+ * ⚠️ Resolved by `Utils.enumValue`, so only the first letter's case is free and
884
+ * an unresolved name is assigned as `undefined` without a word — the hazard
885
+ * `RIG_PATH_POSITION_MODES` is checked for, at a field that had no check at all.
886
+ */
887
+ export const RIG_SCALE_Y_MODES = ['None', 'Uniform', 'Volume'] as const;
888
+
889
+ /** The three names, as a rig spec writes them. */
890
+ export type RigScaleYMode = 'none' | 'uniform' | 'volume' | 'None' | 'Uniform' | 'Volume';
891
+
852
892
  /** `type: "ik"` (`:149-176`). `scaleY` is 4.3's replacement for 4.2's `uniform`. */
853
893
  export interface RigIkConstraint extends RigConstraintCommon {
854
894
  type: 'ik';
@@ -856,7 +896,7 @@ export interface RigIkConstraint extends RigConstraintCommon {
856
896
  bones: string[];
857
897
  target: string;
858
898
  /** `ConstraintData.ts:50`. Absent → `None`. */
859
- scaleY?: 'none' | 'uniform' | 'volume';
899
+ scaleY?: RigScaleYMode;
860
900
  /** Default 1. */
861
901
  mix?: number;
862
902
  /** Default 0. */
@@ -877,7 +917,14 @@ export interface RigIkConstraint extends RigConstraintCommon {
877
917
  */
878
918
  export interface RigTransformProperty {
879
919
  offset?: number;
880
- to: Record<string, { offset?: number; max?: number; scale?: number }>;
920
+ to: Record<string, RigTransformTo>;
921
+ }
922
+
923
+ /** One driven property of a `RigTransformProperty.to` map. */
924
+ export interface RigTransformTo {
925
+ offset?: number;
926
+ max?: number;
927
+ scale?: number;
881
928
  }
882
929
 
883
930
  /** `type: "transform"` (`:177-268`) — rebuilt from scratch in 4.3. */
@@ -928,8 +975,27 @@ export interface RigPhysicsConstraint extends RigConstraintCommon {
928
975
  rotate?: number;
929
976
  scaleX?: number;
930
977
  shearX?: number;
931
- /** 4.3; absent → `ScaleYMode.None`. */
932
- scaleYMode?: 'none' | 'uniform' | 'volume';
978
+ /**
979
+ * 4.3; absent → `ScaleYMode.None`. Same field, same enum and same spelling as
980
+ * `RigIkConstraint.scaleY`.
981
+ *
982
+ * 🚨 It was called `scaleYMode` from this file's first commit (c0e9944,
983
+ * 2026-08-22) until issue #545, and the name was not a synonym — it was the
984
+ * one key in this file that no code anywhere read.
985
+ * `SkeletonJson.js:299` is `getValue(constraintMap, "scaleY", null)`, so the
986
+ * emitter copied `scaleY`, an author writing TypeScript against this interface
987
+ * got a type error on the key that works and silence on the key that does
988
+ * nothing, and `scaleYMode` occurred exactly once in the whole tree: here.
989
+ *
990
+ * ⭐ The rename rather than teaching the emitter to read `scaleYMode`, and the
991
+ * argument is not "the format's name wins" in the abstract — the tree had
992
+ * already answered it four hundred lines above. `RigIkConstraint.scaleY`
993
+ * carries the *same* `ScaleYMode` enum through the *same* `Utils.enumValue`
994
+ * call under the *same* JSON key, and spells it `scaleY`. Teaching the emitter
995
+ * the other name would have left one Spine enum with two rig-spec spellings
996
+ * chosen by constraint type, which is a worse format than either name alone.
997
+ */
998
+ scaleY?: RigScaleYMode;
933
999
  /** Default 5000. */
934
1000
  limit?: number;
935
1001
  /** Default 60 → `step = 1/fps`. */
@@ -1153,7 +1219,7 @@ export interface RigInvariants {
1153
1219
  */
1154
1220
  massBone?: string;
1155
1221
  /** Parentage that must never happen, with the reason it is tempting (`A25`). */
1156
- detached?: Array<{ bone: string; notUnder: string; why?: string }>;
1222
+ detached?: RigDetachedRule[];
1157
1223
  /**
1158
1224
  * Mesh slots whose `deform` timelines are allowed to turn a triangle inside
1159
1225
  * out, exempting them from `A39_DEFORM_KEEPS_TRIANGLE_WINDING`.
@@ -1172,7 +1238,46 @@ export interface RigInvariants {
1172
1238
  * in this repository (`gallery/flex`'s leaf) says outright that it is the
1173
1239
  * second kind, and names the issue.
1174
1240
  */
1175
- deformMayFold?: Array<{ slot: string; why: string }>;
1241
+ deformMayFold?: RigDeformFoldExemption[];
1242
+ /**
1243
+ * Declare that this rig is authored to come back out of the **Spine editor** —
1244
+ * imported, hand-edited, exported — and gate what that consumer cannot hold
1245
+ * (`A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP`).
1246
+ *
1247
+ * 🔑 **Opt-in, because rigc's output is not wrong.** A physics constraint
1248
+ * driving `rotate` is valid Spine 4.3 and every runtime plays it: a cowlick, a
1249
+ * tail, an ear. What is true is that one consumer discards it — measured, not
1250
+ * inferred (issue #540): the editor's physics model holds `x` and `y` only,
1251
+ * with no cap on how many at once, and a lone `rotate`, `scaleX` or `shearX`
1252
+ * comes back driving nothing at all. Refusing that by default would be rigc
1253
+ * refusing correct data on behalf of a pipeline nobody told it about, which is
1254
+ * the same silence pointed the other way. rigc knows what the object is; only
1255
+ * the rig knows which consumers it is for.
1256
+ *
1257
+ * ⚠️ **Declaring nothing is not the same as being told nothing.** `A41` SKIPs
1258
+ * on a rig that stays quiet — and the SKIP names the constraint and the
1259
+ * component a round trip would drop, because the defect this field exists for
1260
+ * is that nobody finds out. Declaring `true` turns that sentence into a
1261
+ * refusal.
1262
+ *
1263
+ * Only `true` is accepted. A `false` here would be a key nothing reads (issue
1264
+ * #545), and leaving it out says the same thing without the ambiguity.
1265
+ */
1266
+ editorRoundTrip?: boolean;
1267
+ }
1268
+
1269
+ /** One forbidden parentage — `invariants.detached` (`A25`). */
1270
+ export interface RigDetachedRule {
1271
+ bone: string;
1272
+ notUnder: string;
1273
+ why?: string;
1274
+ }
1275
+
1276
+ /** One slot exempted from `A39` — `invariants.deformMayFold`. */
1277
+ export interface RigDeformFoldExemption {
1278
+ slot: string;
1279
+ /** Required, and empty is refused by name — see `deformMayFold`. */
1280
+ why: string;
1176
1281
  }
1177
1282
 
1178
1283
  // ---------------------------------------------------------------------------
@@ -1226,6 +1331,210 @@ function isObj(v: unknown): v is Record<string, unknown> {
1226
1331
  return typeof v === 'object' && v !== null && !Array.isArray(v);
1227
1332
  }
1228
1333
 
1334
+ /**
1335
+ * Every key each shape of this format owns, keyed by the interface above that
1336
+ * declares it — the runtime shadow of the types, which TypeScript erases.
1337
+ *
1338
+ * 🔒 **Hand-written and mechanically held to the interfaces.** `KEY01` in
1339
+ * `selftest.ts` reads this file's own source, extracts each named interface's
1340
+ * field list and compares it to the entry here, so the pair cannot drift: adding
1341
+ * a field and forgetting this table is a red run, not a key an author cannot
1342
+ * write. `KEY02` closes the other direction — a key declared here and occurring
1343
+ * nowhere else in the tree is refused, which is exactly what `scaleYMode` was.
1344
+ *
1345
+ * ⚠️ `RigUnimplementedAttachment` is deliberately absent. It carries
1346
+ * `[field: string]: unknown` because its whole job is to let a spec *say* a
1347
+ * `point` or a `linkedmesh` and get a named `NotImplementedError` back; checking
1348
+ * the keys of an attachment rigc is about to refuse by type would name the wrong
1349
+ * fault.
1350
+ */
1351
+ export const RIG_KEYS = {
1352
+ RigSpec: ['spec', 'name', 'note', 'skeleton', 'images', 'bones', 'slots', 'skins', 'constraints', 'events', 'invariants'],
1353
+ RigSkeletonHeader: ['x', 'y', 'width', 'height', 'fps', 'referenceScale', 'images'],
1354
+ RigBone: ['name', 'parent', 'length', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY', 'inherit', 'skin', 'color', 'icon', 'from'],
1355
+ RigBoneFrom: ['anchor', 'slotWindow', 'meshCenter', 'rotation'],
1356
+ RigSlot: ['name', 'bone', 'attachment', 'color', 'dark', 'blend'],
1357
+ RigEvent: ['int', 'float', 'string', 'audio', 'volume', 'balance'],
1358
+ RigInvariants: ['meshSlots', 'meshTriangles', 'axisBone', 'massBone', 'detached', 'deformMayFold', 'editorRoundTrip'],
1359
+ RigDetachedRule: ['bone', 'notUnder', 'why'],
1360
+ RigDeformFoldExemption: ['slot', 'why'],
1361
+ // Not retyped: `RIG_SKIN_KEYS` already IS this set, and it is the set
1362
+ // `splitRigSkin` refuses a long-form skin's stray key against. A second
1363
+ // spelling of it here would be two lists that have to agree, which is the
1364
+ // defect this whole table is checked to avoid.
1365
+ RigSkinEntry: RIG_SKIN_KEYS,
1366
+ RigIkConstraint: ['name', 'skin', 'type', 'bones', 'target', 'scaleY', 'mix', 'softness', 'bendPositive', 'compress', 'stretch'],
1367
+ RigTransformConstraint: [
1368
+ 'name', 'skin', 'type', 'bones', 'source', 'localSource', 'localTarget', 'additive', 'clamp', 'properties',
1369
+ 'rotation', 'x', 'y', 'scaleX', 'scaleY', 'shearY',
1370
+ 'mixRotate', 'mixX', 'mixY', 'mixScaleX', 'mixScaleY', 'mixShearY',
1371
+ ],
1372
+ RigPathConstraint: [
1373
+ 'name', 'skin', 'type', 'bones', 'slot', 'positionMode', 'spacingMode', 'rotateMode',
1374
+ 'rotation', 'position', 'spacing', 'mixRotate', 'mixX', 'mixY',
1375
+ ],
1376
+ RigPhysicsConstraint: [
1377
+ 'name', 'skin', 'type', 'bone', 'x', 'y', 'rotate', 'scaleX', 'shearX', 'scaleY', 'limit', 'fps',
1378
+ 'inertia', 'strength', 'damping', 'mass', 'wind', 'gravity', 'mix',
1379
+ 'inertiaGlobal', 'strengthGlobal', 'dampingGlobal', 'massGlobal', 'windGlobal', 'gravityGlobal', 'mixGlobal',
1380
+ ],
1381
+ RigSliderConstraint: [
1382
+ 'name', 'skin', 'type', 'animation', 'mix', 'additive', 'loop', 'bone', 'property', 'from', 'to', 'scale',
1383
+ 'max', 'local', 'time',
1384
+ ],
1385
+ RigTransformProperty: ['offset', 'to'],
1386
+ RigTransformTo: ['offset', 'max', 'scale'],
1387
+ RigRegionAttachment: ['type', 'path', 'image', 'x', 'y', 'rotation', 'scaleX', 'scaleY', 'width', 'height', 'color'],
1388
+ RigMeshAttachment: [
1389
+ 'type', 'path', 'image', 'uvs', 'triangles', 'vertices', 'weights', 'boneIndexing', 'hull', 'edges',
1390
+ 'width', 'height', 'color', 'generator',
1391
+ ],
1392
+ RigMeshBinding: ['bone', 'x', 'y', 'weight'],
1393
+ RigBoundingBoxAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type'],
1394
+ RigClippingAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'end', 'convex', 'inverse'],
1395
+ RigPathAttachment: ['vertexCount', 'vertices', 'weights', 'boneIndexing', 'color', 'type', 'closed', 'constantSpeed', 'lengths'],
1396
+ RigRingGenerator: ['kind', 'hull', 'center', 'inner', 'size', 'bias', 'controls'],
1397
+ RigRibbonGenerator: ['kind', 'size', 'rows', 'chain'],
1398
+ RigContourGenerator: ['kind', 'tolerance', 'margin', 'maxVertices', 'alpha', 'depth', 'soft'],
1399
+ RigGridGenerator: ['kind', 'us', 'vs', 'cols', 'rows', 'depth', 'soft'],
1400
+ RigMeshBias: ['axis_deg', 'ramp'],
1401
+ RigDepthMap: ['image', 'near', 'zScale', 'gamma', 'contrast', 'bias'],
1402
+ RigSoftRegion: ['bone', 'mask'],
1403
+ } as const satisfies Record<string, readonly string[]>;
1404
+
1405
+ /**
1406
+ * The five constraint `type` names, and the shape each one's keys come from.
1407
+ *
1408
+ * `satisfies` over `RigSkinConstraintKey` is what makes the five exhaustive: a
1409
+ * sixth constraint type added to that union without an entry here is a type
1410
+ * error rather than a shape whose keys go unchecked.
1411
+ */
1412
+ const CONSTRAINT_SHAPE: Record<string, keyof typeof RIG_KEYS> = {
1413
+ ik: 'RigIkConstraint',
1414
+ transform: 'RigTransformConstraint',
1415
+ path: 'RigPathConstraint',
1416
+ physics: 'RigPhysicsConstraint',
1417
+ slider: 'RigSliderConstraint',
1418
+ } satisfies Record<RigSkinConstraintKey, keyof typeof RIG_KEYS>;
1419
+
1420
+ /** The attachment `type` names, and the shape each one's keys come from. */
1421
+ const ATTACHMENT_SHAPE: Record<string, keyof typeof RIG_KEYS> = {
1422
+ region: 'RigRegionAttachment',
1423
+ mesh: 'RigMeshAttachment',
1424
+ boundingbox: 'RigBoundingBoxAttachment',
1425
+ clipping: 'RigClippingAttachment',
1426
+ path: 'RigPathAttachment',
1427
+ };
1428
+
1429
+ /** The generator `kind` names, and the shape each one's keys come from. */
1430
+ const GENERATOR_SHAPE: Record<string, keyof typeof RIG_KEYS> = {
1431
+ ring: 'RigRingGenerator',
1432
+ ribbon: 'RigRibbonGenerator',
1433
+ contour: 'RigContourGenerator',
1434
+ grid: 'RigGridGenerator',
1435
+ };
1436
+
1437
+ /**
1438
+ * Refuse every key of this rig spec that no shape above declares.
1439
+ *
1440
+ * ⭐ It walks the file rather than the emitter's route, and that is the whole
1441
+ * design. `compile` reaches an attachment only through a slot it is going to
1442
+ * draw and a `setup` entry only through a slot that has attachments, so a check
1443
+ * riding along with the emitter inherits its blind spots — which is how issue
1444
+ * #293's refusal sat green for three weeks on exactly the half-finished rigs it
1445
+ * was written for. Every node of the document is visited here, whether or not
1446
+ * anything downstream would have looked at it.
1447
+ *
1448
+ * A node that is not an object is left alone: its shape is somebody else's
1449
+ * refusal, and naming its keys would be a second opinion on a fault already
1450
+ * reported (see the note at the head of `parseMotionSpec`).
1451
+ */
1452
+ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): void {
1453
+ const at = (node: unknown, shape: keyof typeof RIG_KEYS, what: string): void => {
1454
+ if (isObj(node)) refuseUnknownKeys(node, RIG_KEYS[shape], where, what);
1455
+ };
1456
+
1457
+ at(raw, 'RigSpec', 'this rig spec');
1458
+ at(raw.skeleton, 'RigSkeletonHeader', '"skeleton"');
1459
+
1460
+ for (const [i, bone] of (Array.isArray(raw.bones) ? raw.bones : []).entries()) {
1461
+ const who = isObj(bone) && typeof bone.name === 'string' ? `bone "${bone.name}"` : `bones[${i}]`;
1462
+ at(bone, 'RigBone', who);
1463
+ if (isObj(bone)) at(bone.from, 'RigBoneFrom', `${who}'s "from"`);
1464
+ }
1465
+
1466
+ for (const [i, slot] of (Array.isArray(raw.slots) ? raw.slots : []).entries()) {
1467
+ at(slot, 'RigSlot', isObj(slot) && typeof slot.name === 'string' ? `slot "${slot.name}"` : `slots[${i}]`);
1468
+ }
1469
+
1470
+ for (const [i, constraint] of (Array.isArray(raw.constraints) ? raw.constraints : []).entries()) {
1471
+ if (!isObj(constraint)) continue;
1472
+ const named = typeof constraint.name === 'string' ? `constraint "${constraint.name}"` : `constraints[${i}]`;
1473
+ const shape = CONSTRAINT_SHAPE[String(constraint.type)];
1474
+ // An unknown `type` is `buildRigConstraint`'s refusal and names the five
1475
+ // that exist; there is no key set to check it against and no honest one to
1476
+ // guess, so it goes past here to the message that can say something.
1477
+ if (shape === undefined) continue;
1478
+ at(constraint, shape, `${named} (${String(constraint.type)})`);
1479
+ for (const [from, entry] of Object.entries(isObj(constraint.properties) ? constraint.properties : {})) {
1480
+ at(entry, 'RigTransformProperty', `${named} properties."${from}"`);
1481
+ if (!isObj(entry)) continue;
1482
+ for (const [to, driven] of Object.entries(isObj(entry.to) ? entry.to : {})) {
1483
+ at(driven, 'RigTransformTo', `${named} properties."${from}".to."${to}"`);
1484
+ }
1485
+ }
1486
+ }
1487
+
1488
+ for (const [name, event] of Object.entries(isObj(raw.events) ? raw.events : {})) {
1489
+ at(event, 'RigEvent', `event "${name}"`);
1490
+ }
1491
+
1492
+ if (isObj(raw.invariants)) {
1493
+ at(raw.invariants, 'RigInvariants', '"invariants"');
1494
+ for (const [i, rule] of (Array.isArray(raw.invariants.detached) ? raw.invariants.detached : []).entries()) {
1495
+ at(rule, 'RigDetachedRule', `invariants.detached[${i}]`);
1496
+ }
1497
+ for (const [i, rule] of (Array.isArray(raw.invariants.deformMayFold) ? raw.invariants.deformMayFold : []).entries()) {
1498
+ at(rule, 'RigDeformFoldExemption', `invariants.deformMayFold[${i}]`);
1499
+ }
1500
+ }
1501
+
1502
+ for (const [skinName, skin] of Object.entries(isObj(raw.skins) ? raw.skins : {})) {
1503
+ if (!isObj(skin)) continue;
1504
+ // The long form's own key check is `splitRigSkin`'s and it already names the
1505
+ // likeliest cause (a slot left outside `attachments`), so it is reused
1506
+ // rather than restated — one refusal per fault.
1507
+ const parts = splitRigSkin(skin as RigSkin, `${where}: skin "${skinName}"`);
1508
+ for (const [slot, placeholders] of Object.entries(parts.attachments)) {
1509
+ if (!isObj(placeholders)) continue;
1510
+ for (const [placeholder, att] of Object.entries(placeholders)) {
1511
+ if (!isObj(att)) continue;
1512
+ const who = `skin "${skinName}" slot "${slot}" attachment "${placeholder}"`;
1513
+ // `type` absent means `region` — the parser's own default (`:539`).
1514
+ const type = att.type === undefined ? 'region' : String(att.type);
1515
+ const shape = ATTACHMENT_SHAPE[type];
1516
+ if (shape === undefined) continue;
1517
+ at(att, shape, `${who} (${type})`);
1518
+ for (const [i, vertex] of (Array.isArray(att.weights) ? att.weights : []).entries()) {
1519
+ for (const [j, binding] of (Array.isArray(vertex) ? vertex : []).entries()) {
1520
+ at(binding, 'RigMeshBinding', `${who} weights[${i}][${j}]`);
1521
+ }
1522
+ }
1523
+ if (!isObj(att.generator)) continue;
1524
+ const gen = att.generator;
1525
+ const genShape = GENERATOR_SHAPE[String(gen.kind)];
1526
+ // Same rule as an unknown attachment type: an unknown `kind` is the mesh
1527
+ // builder's refusal, which can name the four that exist.
1528
+ if (genShape === undefined) continue;
1529
+ at(gen, genShape, `${who} generator (${String(gen.kind)})`);
1530
+ at(gen.bias, 'RigMeshBias', `${who} generator.bias`);
1531
+ at(gen.depth, 'RigDepthMap', `${who} generator.depth`);
1532
+ at(gen.soft, 'RigSoftRegion', `${who} generator.soft`);
1533
+ }
1534
+ }
1535
+ }
1536
+ }
1537
+
1229
1538
  /**
1230
1539
  * Parse and check the envelope, then hand back a typed spec.
1231
1540
  *
@@ -1250,6 +1559,12 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
1250
1559
  if (!Array.isArray(raw.slots)) {
1251
1560
  throw new CompileError(`${where}: a rig spec needs a "slots" array (it may be empty; its ORDER is the draw order)`);
1252
1561
  }
1562
+ // Before anything resolves by name, because a key nothing reads is very often
1563
+ // the CAUSE of the name that does not resolve: `"bones"` typed on a slider is
1564
+ // a slider with no driving bone, and the refusal an author wants names the
1565
+ // typo rather than the consequence.
1566
+ checkRigSpecKeys(raw, where);
1567
+
1253
1568
  const spec = raw as unknown as RigSpec;
1254
1569
 
1255
1570
  const seen = new Set<string>();
@@ -1351,6 +1666,19 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
1351
1666
  }
1352
1667
  }
1353
1668
 
1669
+ // `invariants.editorRoundTrip` — the field that turns a check ON, so the only
1670
+ // thing it can be wrong about is saying nothing while looking like it said
1671
+ // something. `false` is refused for exactly that: it reads as a decision and
1672
+ // behaves as an absence, which is the shape issue #545 closed elsewhere.
1673
+ const roundTrip = spec.invariants?.editorRoundTrip;
1674
+ if (roundTrip !== undefined && roundTrip !== true) {
1675
+ throw new CompileError(
1676
+ `${where}: invariants.editorRoundTrip is ${JSON.stringify(roundTrip)}; the only accepted value is \`true\`. ` +
1677
+ 'A rig that is not authored for the editor leaves the key out — A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP then ' +
1678
+ 'SKIPs and still names anything a round trip would drop, so nothing is lost by saying nothing',
1679
+ );
1680
+ }
1681
+
1354
1682
  const constraintNames = new Set<string>();
1355
1683
  /** name -> [type, skinRequired], for the skin lists below. */
1356
1684
  const constraintFacts = new Map<string, [string, boolean]>();