spine-rigc 0.2.1 → 0.4.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/framing.ts CHANGED
@@ -32,6 +32,19 @@
32
32
  * measurement error in this file cancels. What is left is a procedure whose noise
33
33
  * shrinks as the candidate improves, which is the only shape of noise an authoring
34
34
  * loop can work against.
35
+ *
36
+ * ## And one pass after that, on the MAE itself
37
+ *
38
+ * The extent fit has a floor it cannot see past, because the best fit of two
39
+ * extents is not the best alignment of two pictures. On a hard shot that floor is
40
+ * a **constant** pixel worth a tenth of the headline figure (issue #146), which a
41
+ * loop reads as motion. So a fitted box gets one final pass — `OffsetScan` — that
42
+ * searches whole-pixel translations in a ±`REFINE_RADIUS` window for the lowest
43
+ * *reference-denominator MAE* and moves the box when the gain clears
44
+ * `REFINE_MIN_GAIN`. It optimises the reported figure directly rather than a proxy
45
+ * for it, which is what separates it from the extent refinement measured and
46
+ * rejected in `frameByDeclaredBox`: that one walked off the answer because the
47
+ * answer was not what it was minimising.
35
48
  */
36
49
  import { Plate, type RGBA } from '../tools/plate.ts';
37
50
  import {
@@ -515,6 +528,273 @@ function cornerSpread(box: ContentBox, displace: (x: number, y: number) => [numb
515
528
  */
516
529
  export const CYCLE_PIXELS = SETTLED_PIXELS / 2;
517
530
 
531
+ // ---------------------------------------------------------------------------
532
+ // the MAE-refined final pass
533
+ // ---------------------------------------------------------------------------
534
+
535
+ /**
536
+ * How far the MAE-refined pass may move a fitted box, in frame pixels.
537
+ *
538
+ * Two, because what it is there to recover is *one* pixel. The extent fit lands
539
+ * within a fraction of a pixel of its own optimum and its optimum is the wrong
540
+ * one by about that much (`fitFraming`, "the floor, stated plainly"), so the
541
+ * distance between "where the extents agree" and "where the pictures agree" is a
542
+ * pixel or two and never more — measured across the committed corpus, every
543
+ * offset the pass applies is `(0, ±1)`, `(±1, 0)`, `(±1, ±1)`, `(−1, 2)` or
544
+ * `(−2, ≤1)`, and none of the 86 sets wants a corner of the window. A wider
545
+ * window would start being able to absorb a real displacement, which is the one
546
+ * thing the framing must not do.
547
+ */
548
+ export const REFINE_RADIUS = 2;
549
+
550
+ /**
551
+ * How much of the figure a shift must buy before it is allowed to move the box,
552
+ * as a fraction of the set's own reference-denominator MAE...
553
+ *
554
+ * ...and `REFINE_MIN_GAIN_MAE` beside it, absolutely, because a ratio alone means
555
+ * nothing on a shot whose MAE is already 3.
556
+ *
557
+ * ## What the corpus says, and what the threshold is therefore for
558
+ *
559
+ * Measured over the 86 compared sets of the committed runs: the best offset in
560
+ * ±2 px is the **exact identity** on 52 of them — every set framed by
561
+ * `frames.json`'s own box among them, which is the `idle`-class control issue #146
562
+ * asked for — and on the other 34 the gain runs **0.9 % … 30.9 %** (0.40 … 14.34
563
+ * MAE), clustering at 3 % and above with two lone readings at 1.0 % and 0.9 %.
564
+ *
565
+ * ⚠️ So this is **not** a threshold separating two measured populations, and it
566
+ * must not be quoted as one: it is a floor under a continuum. What makes a low
567
+ * floor the right shape here is that the pass minimises the reported figure
568
+ * *itself*, so the cost of applying a marginal offset is bounded by the threshold
569
+ * — a hundredth of the figure — while the cost of refusing one is a constant pixel
570
+ * left inside a number an author reads as motion. Erring towards applying is the
571
+ * cheap direction, and the report prints what was applied and what it was worth
572
+ * either way.
573
+ */
574
+ export const REFINE_MIN_GAIN = 0.01;
575
+ /** ...and how much of the figure that is, in MAE points, whatever the ratio says. */
576
+ export const REFINE_MIN_GAIN_MAE = 0.1;
577
+
578
+ /** What the best whole-pixel offset in a window is worth, over a set's frames. */
579
+ export interface OffsetGain {
580
+ dx: number;
581
+ dy: number;
582
+ /** Mean reference-denominator MAE at the identity — the figure as it stands. */
583
+ identity: number;
584
+ /** ...and at `dx, dy`, which is the same figure with one constant taken out. */
585
+ best: number;
586
+ /** How far the search looked, and how many frames it pooled. */
587
+ radius: number;
588
+ frames: number;
589
+ }
590
+
591
+ /**
592
+ * The set's reference-denominator MAE at every whole-pixel offset in a window,
593
+ * accumulated one frame at a time.
594
+ *
595
+ * ## What this is for: the constant pixel a settled fit still leaves
596
+ *
597
+ * `fitFraming` registers two shots by their **extent**, and a shot whose
598
+ * silhouette genuinely differs has its best extent fit about a third of a pixel
599
+ * from its best alignment. Measured on the spineboy candidates (issue #146), that
600
+ * floor is not a rounding detail: a **constant** translation of one or two pixels
601
+ * is worth 12 % of `death`'s headline MAE and up to 30 % of a fitted set's,
602
+ * while the genuinely per-frame remainder is a tenth of it. A loop reading those
603
+ * numbers as motion is reading a framing offset.
604
+ *
605
+ * ## Why the objective is the reference denominator
606
+ *
607
+ * Because it is the figure the report tells an author to optimise against
608
+ * (`FrameCheck.maeReference`), and because it is the one the candidate cannot
609
+ * grow: minimising the union MAE would let a shift that drags more cheap pixels
610
+ * into the denominator win, which is issue #119 arriving by another door.
611
+ *
612
+ * ## Why whole pixels, and why a plate shift rather than a re-render
613
+ *
614
+ * The projector is `px = (wx − minX)·k`, so moving the box by exactly `dx/k`
615
+ * moves every sample point by exactly one pixel and samples the same texels —
616
+ * the render at the shifted box **is** the render shifted, but for content
617
+ * outside the old frame. That makes a 25-offset search cost one render per frame
618
+ * instead of 25, and it is why the window is whole pixels: a sub-pixel offset
619
+ * changes the resampling, so nothing could be searched without re-rendering it.
620
+ * The offset that wins is then applied to the viewport and everything the report
621
+ * prints is measured on a real render at a real box.
622
+ */
623
+ export class OffsetScan {
624
+ readonly radius: number;
625
+ private readonly span: number;
626
+ /** Σ over frames of the per-frame reference-denominator MAE, per offset. */
627
+ private readonly sums: Float64Array;
628
+ private counted = 0;
629
+
630
+ constructor(radius: number = REFINE_RADIUS) {
631
+ this.radius = Math.max(0, Math.round(radius));
632
+ this.span = this.radius * 2 + 1;
633
+ this.sums = new Float64Array(this.span * this.span);
634
+ }
635
+
636
+ /**
637
+ * One frame: the candidate as it was rendered, its own coverage mask, and the
638
+ * reference as it is on disk.
639
+ *
640
+ * ⭐ The figure accumulated here is `FrameCheck.maeReference` **exactly** —
641
+ * the difference summed over the pixels either side covers, over the count of
642
+ * the ones the *reference* covers — because the line the report prints and the
643
+ * line this pass minimises have to be the same line. Taking the numerator over
644
+ * the reference's pixels alone would be a near neighbour of it and a different
645
+ * number, and a "54.31 → 48.47" that did not match the MAE line under it would
646
+ * be two measurements wearing one name.
647
+ *
648
+ * A frame the reference drew nothing in counts as zero rather than being
649
+ * skipped, which is again what `checkOneFrame` does with it: an empty
650
+ * denominator is not a measurement, and dropping the frame instead would divide
651
+ * this pass's mean by a different frame count than the report's.
652
+ */
653
+ add(candidate: Plate, coverage: Uint8Array, reference: Plate, background: RGBA): void {
654
+ const { width, height } = reference;
655
+ this.counted++;
656
+ // The reference's own drawn pixels, by the predicate `checkOneFrame` uses.
657
+ const drawn = new Uint8Array(width * height);
658
+ const drawnAt: number[] = [];
659
+ for (let y = 0; y < height; y++) {
660
+ for (let x = 0; x < width; x++) {
661
+ if (!isContent(reference, x, y, background)) continue;
662
+ const at = y * width + x;
663
+ drawn[at] = 1;
664
+ drawnAt.push(at);
665
+ }
666
+ }
667
+ if (drawnAt.length === 0) return;
668
+ // ...and the candidate's, which move with the offset. Held as a list because
669
+ // the second sum below walks them in candidate coordinates.
670
+ const inkAt: number[] = [];
671
+ for (let at = 0; at < coverage.length; at++) if (coverage[at] === 1) inkAt.push(at);
672
+
673
+ const a = candidate.data;
674
+ const b = reference.data;
675
+ const bg = background;
676
+ /** |candidate at `from` − reference at `to`|, mean over RGB, either off-grid. */
677
+ const delta = (from: number, to: number): number => {
678
+ const j = to * 4;
679
+ const i = from * 4;
680
+ const ar = from < 0 ? bg[0] : a[i];
681
+ const ag = from < 0 ? bg[1] : a[i + 1];
682
+ const ab = from < 0 ? bg[2] : a[i + 2];
683
+ const br = to < 0 ? bg[0] : b[j];
684
+ const bgc = to < 0 ? bg[1] : b[j + 1];
685
+ const bb = to < 0 ? bg[2] : b[j + 2];
686
+ return (Math.abs(ar - br) + Math.abs(ag - bgc) + Math.abs(ab - bb)) / 3;
687
+ };
688
+ for (let dy = -this.radius; dy <= this.radius; dy++) {
689
+ for (let dx = -this.radius; dx <= this.radius; dx++) {
690
+ let sum = 0;
691
+ // Every reference-drawn pixel, against whatever the shifted candidate puts
692
+ // there — background where the shift pulls it off its own grid, which is
693
+ // what the render at the shifted box would draw.
694
+ for (let i = 0; i < drawnAt.length; i++) {
695
+ const at = drawnAt[i];
696
+ const x = at % width;
697
+ const y = (at - x) / width;
698
+ const sx = x - dx;
699
+ const sy = y - dy;
700
+ sum += delta(sx < 0 || sy < 0 || sx >= width || sy >= height ? -1 : sy * width + sx, at);
701
+ }
702
+ // ...and every pixel the candidate covers that the reference does not,
703
+ // which is the other half of the union. A pixel the shift carries out of
704
+ // the frame is clipped there, so it leaves the sum entirely.
705
+ for (let i = 0; i < inkAt.length; i++) {
706
+ const at = inkAt[i];
707
+ const x = at % width;
708
+ const y = (at - x) / width;
709
+ const tx = x + dx;
710
+ const ty = y + dy;
711
+ if (tx < 0 || ty < 0 || tx >= width || ty >= height) continue;
712
+ const to = ty * width + tx;
713
+ if (drawn[to] === 1) continue;
714
+ sum += delta(at, to);
715
+ }
716
+ this.sums[this.index(dx, dy)] += sum / drawnAt.length;
717
+ }
718
+ }
719
+ }
720
+
721
+ /**
722
+ * The offset with the lowest figure, or `null` when no frame carried reference
723
+ * ink.
724
+ *
725
+ * Ties go to the smaller displacement and then to the lower `dy`, `dx`, so the
726
+ * answer is a function of the pixels and not of the iteration order —
727
+ * `A18_DETERMINISTIC_EMIT`'s discipline applied to a measurement. The identity
728
+ * therefore wins any tie it is in, which is what makes "no constant offset
729
+ * here" a reachable answer rather than an arbitrary one.
730
+ */
731
+ best(): OffsetGain | null {
732
+ if (this.counted === 0) return null;
733
+ let bestDx = 0;
734
+ let bestDy = 0;
735
+ let bestSum = this.sums[this.index(0, 0)];
736
+ for (let dy = -this.radius; dy <= this.radius; dy++) {
737
+ for (let dx = -this.radius; dx <= this.radius; dx++) {
738
+ const sum = this.sums[this.index(dx, dy)];
739
+ if (sum > bestSum) continue;
740
+ if (sum === bestSum && !closerToHome(dx, dy, bestDx, bestDy)) continue;
741
+ bestSum = sum;
742
+ bestDx = dx;
743
+ bestDy = dy;
744
+ }
745
+ }
746
+ return {
747
+ dx: bestDx,
748
+ dy: bestDy,
749
+ identity: this.sums[this.index(0, 0)] / this.counted,
750
+ best: bestSum / this.counted,
751
+ radius: this.radius,
752
+ frames: this.counted,
753
+ };
754
+ }
755
+
756
+ private index(dx: number, dy: number): number {
757
+ return (dy + this.radius) * this.span + (dx + this.radius);
758
+ }
759
+ }
760
+
761
+ /** Is `(dx, dy)` the smaller displacement, ties broken by `dy` then `dx`? */
762
+ function closerToHome(dx: number, dy: number, atX: number, atY: number): boolean {
763
+ const mine = dx * dx + dy * dy;
764
+ const theirs = atX * atX + atY * atY;
765
+ if (mine !== theirs) return mine < theirs;
766
+ if (dy !== atY) return dy < atY;
767
+ return dx < atX;
768
+ }
769
+
770
+ /** Is this offset worth moving a box for? See `REFINE_MIN_GAIN`. */
771
+ export function offsetIsWorthApplying(gain: OffsetGain): boolean {
772
+ if (gain.dx === 0 && gain.dy === 0) return false;
773
+ const won = gain.identity - gain.best;
774
+ return won >= REFINE_MIN_GAIN_MAE && gain.identity > 0 && won / gain.identity >= REFINE_MIN_GAIN;
775
+ }
776
+
777
+ /**
778
+ * The same box moved by whole frame pixels, at the same scale.
779
+ *
780
+ * `applyFit` with a scale of exactly 1, written out rather than routed through
781
+ * it, because the refined pass changes no scale at all and a fit-shaped argument
782
+ * with `scale: 1` in it would invite one.
783
+ */
784
+ export function shiftViewport(
785
+ viewport: Viewport,
786
+ dx: number,
787
+ dy: number,
788
+ pixelWidth: number,
789
+ pixelHeight: number,
790
+ ): Viewport {
791
+ const minX = viewport.minX - dx / viewport.scale;
792
+ const maxY = viewport.maxY + dy / viewport.scale;
793
+ const width = pixelWidth / viewport.scale;
794
+ const height = pixelHeight / viewport.scale;
795
+ return viewportOfSize(minX, maxY - height, width, height, viewport.scale, pixelWidth, pixelHeight);
796
+ }
797
+
518
798
  /**
519
799
  * The viewport that renders the candidate where the fit says it belongs.
520
800
  *
package/src/ladder.ts CHANGED
@@ -103,7 +103,7 @@ export const LADDER: readonly Rung[] = [
103
103
  {
104
104
  id: 'spineboy',
105
105
  example: 'spineboy',
106
- gates: 'IK, events, bounding box, clipping, unweighted meshes — and scale: 67 bones, 52 slots, 11 animations',
106
+ gates: 'IK, events, bounding box, clipping, unweighted meshes — and scale: ess 18 bones/20 slots/8 animations, pro 67 bones/52 slots/11 animations',
107
107
  skeletons: [
108
108
  { label: 'ess', file: 'spineboy-ess.json', atlas: 'spineboy.atlas', role: 'rung' },
109
109
  // `-pro` is reported and does not count. It is a harder rig than the
package/src/render.ts CHANGED
@@ -124,6 +124,27 @@ export const FRAMING_FPS = 60;
124
124
  export const FRAMES_SIDECAR = 'frames.json';
125
125
  export const FRAMES_SPEC = 'rigc-frames/1';
126
126
 
127
+ /**
128
+ * The contact sheet beside a frame set, and the one number its layout needs.
129
+ *
130
+ * ⭐ A sheet is **part of the frame set**, not an illustration of it: a long shot
131
+ * commits a couple of stills and folds every sampled frame into one PNG, so for
132
+ * such a set the sheet is the only picture of the 309 frames in between, and
133
+ * `check` compares against its tiles (issue #36). That makes the layout a
134
+ * contract between two programs — `bench/render_reference.ts` writes the grid and
135
+ * `src/check.ts` reads it — so the column count lives here rather than in either.
136
+ *
137
+ * The tile SIZE is deliberately not here. It is a `--tile` choice per run, and a
138
+ * reader can measure it exactly off the sheet's own dimensions given the frame
139
+ * count and the column count (`check`'s `sheetGeometry` does), so recording it
140
+ * would be a second definition of something already written down in pixels.
141
+ */
142
+ export const SHEET_COLUMNS = 8;
143
+ /** The sheet's file name inside a frame directory. */
144
+ export const SHEET_FILE = 'contact.png';
145
+ /** One pixel of rule between tiles, and one around the outside. */
146
+ export const SHEET_GAP = 1;
147
+
127
148
  /** One rendered frame directory: which animation, at what rate, and what is on disk. */
128
149
  export interface FrameSet {
129
150
  /** Directory name under the skeleton root — `heavy`, or `heavy@24fps`. */
@@ -909,6 +930,17 @@ export interface FrameGeometry {
909
930
  /** 1 where any piece drew, in `viewport.width * viewport.height` row-major order. */
910
931
  coverage: Uint8Array;
911
932
  footprints: Map<string, Footprint>;
933
+ /**
934
+ * Which owner drew each pixel last, or `-1` — `null` unless `owners` was given.
935
+ *
936
+ * "Last" is the composite's own rule: pieces arrive in draw order, so the owner
937
+ * left in a pixel is the one you would see there. That is deliberately the
938
+ * opposite of `footprints`, which measures each slot on its own pixels
939
+ * *ignoring* what covers it — a footprint answers "where is this part", and
940
+ * this mask answers "whose part is this pixel", and only the second one can be
941
+ * a partition.
942
+ */
943
+ owner: Int32Array | null;
912
944
  }
913
945
 
914
946
  /**
@@ -923,11 +955,19 @@ export interface FrameGeometry {
923
955
  * an occluded part merges into its occluder's component — and that is what the
924
956
  * matcher reports as ambiguity rather than as drift.
925
957
  */
926
- export function frameGeometry(frame: Frame, pages: Map<string, Plate>, viewport: Viewport): FrameGeometry {
958
+ export function frameGeometry(
959
+ frame: Frame,
960
+ pages: Map<string, Plate>,
961
+ viewport: Viewport,
962
+ /** Slot name → owner id, when the caller also wants the per-pixel owner mask. */
963
+ owners?: Map<string, number>,
964
+ ): FrameGeometry {
927
965
  const coverage = new Uint8Array(viewport.width * viewport.height);
966
+ const owner = owners === undefined ? null : new Int32Array(viewport.width * viewport.height).fill(-1);
928
967
  const footprints = new Map<string, Footprint>();
929
968
  const project = projector(viewport);
930
969
  for (const piece of frame.pieces) {
970
+ const owned = owners === undefined ? -1 : (owners.get(piece.slot) ?? -1);
931
971
  let weight = 0;
932
972
  let sx = 0;
933
973
  let sy = 0;
@@ -937,6 +977,7 @@ export function frameGeometry(frame: Frame, pages: Map<string, Plate>, viewport:
937
977
  let maxY = -Infinity;
938
978
  rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, _r, _g, _b, a) => {
939
979
  coverage[py * viewport.width + px] = 1;
980
+ if (owner !== null && owned >= 0) owner[py * viewport.width + px] = owned;
940
981
  const w = a / 255;
941
982
  weight += w;
942
983
  sx += (px + 0.5) * w;
@@ -956,7 +997,7 @@ export function frameGeometry(frame: Frame, pages: Map<string, Plate>, viewport:
956
997
  // answer, and it keeps the map keyed by slot the way the report reads it.
957
998
  footprints.set(piece.slot, previous && previous.pixels > 0 ? mergeFootprints(previous, here) : here);
958
999
  }
959
- return { coverage, footprints };
1000
+ return { coverage, footprints, owner };
960
1001
  }
961
1002
 
962
1003
  function mergeFootprints(a: Footprint, b: Footprint): Footprint {
package/src/rig.ts CHANGED
@@ -395,18 +395,99 @@ export interface RigMeshAttachment {
395
395
  }
396
396
 
397
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
398
+ * The geometry every non-region attachment shares: a polygon, either pinned to
399
+ * one bone or weighted across several.
400
+ *
401
+ * ⭐ `vertexCount` is REQUIRED and cross-checked, and that is the whole design of
402
+ * these two types. A mesh gets its vertex count from `uvs.length`, so there is
403
+ * nothing to state; a bounding box and a clipping polygon have no uvs, and the
404
+ * parser reads `map.vertexCount << 1` — with the field absent that is
405
+ * `undefined << 1` = **0**, so `readVertices` takes the weighted branch,
406
+ * decodes coordinates as a weight run, and hands back an attachment with no
407
+ * vertices at all. Nothing throws. So the count is declared here and checked
408
+ * against whichever encoding the spec used.
409
+ *
410
+ * The two encodings are the mesh's, unchanged, and for the same reason:
411
+ * `weights` binds by NAME and is the default; `vertices` is either an unweighted
412
+ * `x, y` run (one pair per vertex) or Spine's index-encoded weighted run, and
413
+ * the second of those needs `boneIndexing: "raw"` said out loud because a bone
414
+ * inserted anywhere above shifts every index in silence (issue #45).
415
+ */
416
+ export interface RigVertexGeometry {
417
+ /** Required. No parser default: absent reads as 0 and the polygon vanishes. */
418
+ vertexCount: number;
419
+ /**
420
+ * Unweighted `x, y` pairs (`vertices.length === vertexCount * 2`), or Spine's
421
+ * weighted run behind `boneIndexing: "raw"`. Mutually exclusive with `weights`.
422
+ */
423
+ vertices?: number[];
424
+ /** Weighted geometry bound by name — one entry per vertex. The default form. */
425
+ weights?: RigMeshBinding[][];
426
+ /** `"raw"` opts a `vertices` weighted run into the index encoding. */
427
+ boneIndexing?: 'name' | 'raw';
428
+ /** `rrggbbaa`. Editor affordance: the colour the box is drawn in. */
429
+ color?: string;
430
+ }
431
+
432
+ /**
433
+ * `type: "boundingbox"` (`SkeletonJson.ts:560-567`).
434
+ *
435
+ * **When you need one:** a polygon the game can hit-test against — a hurt box, a
436
+ * pick region, a trigger volume — that moves with the skeleton and draws
437
+ * nothing. It is the only attachment type whose entire purpose is outside the
438
+ * renderer, which is why it has no `path`, no size and no uvs.
439
+ */
440
+ export interface RigBoundingBoxAttachment extends RigVertexGeometry {
441
+ type: 'boundingbox';
442
+ }
443
+
444
+ /**
445
+ * `type: "clipping"` (`SkeletonJson.ts:635-651`).
446
+ *
447
+ * **When you need one:** a mask. The polygon clips every slot drawn from the one
448
+ * carrying it up to and including `end`, so a window, a portal or a wipe is one
449
+ * attachment rather than a second set of art.
450
+ *
451
+ * ⚠️ `end` is resolved with `skeletonData.findSlot(end)`, which returns **null**
452
+ * on a miss and assigns that null without complaint (`:626-627`). The clip then
453
+ * never ends — it runs to the bottom of the draw order and takes every slot
454
+ * below it with it. rigc refuses a name the rig does not declare.
455
+ */
456
+ export interface RigClippingAttachment extends RigVertexGeometry {
457
+ type: 'clipping';
458
+ /**
459
+ * The last slot this clip applies to, by name. Absent leaves `endSlot` null,
460
+ * which is the parser's own encoding for "clip everything after this one".
461
+ */
462
+ end?: string;
463
+ /** 4.3. Default false. */
464
+ convex?: boolean;
465
+ /** 4.3. Default false. */
466
+ inverse?: boolean;
467
+ }
468
+
469
+ /**
470
+ * The three types the format holds and rigc's emitter does not cover. They are
471
+ * in the type so a spec can *say* them and get a named `NotImplementedError`;
472
+ * the alternative is the parser's own behaviour, which is to return `null` for
473
+ * an unknown `type` and drop the attachment without a word
402
474
  * (`SkeletonJson.ts:653`).
475
+ *
476
+ * 🚧 None of the three appears anywhere in the benchmark corpus
477
+ * (SPEC_COVERAGE parts 3-1 and 4-2), so none is on the ladder's critical path —
478
+ * which is the reason they are deferred rather than an oversight.
403
479
  */
404
480
  export interface RigUnimplementedAttachment {
405
- type: 'boundingbox' | 'point' | 'clipping' | 'path' | 'linkedmesh';
481
+ type: 'point' | 'path' | 'linkedmesh';
406
482
  [field: string]: unknown;
407
483
  }
408
484
 
409
- export type RigAttachment = RigRegionAttachment | RigMeshAttachment | RigUnimplementedAttachment;
485
+ export type RigAttachment =
486
+ | RigRegionAttachment
487
+ | RigMeshAttachment
488
+ | RigBoundingBoxAttachment
489
+ | RigClippingAttachment
490
+ | RigUnimplementedAttachment;
410
491
 
411
492
  /** `slotName -> placeholderName -> attachment` (`SkeletonJson.ts:431-439`). */
412
493
  export type RigSkin = Record<string, Record<string, RigAttachment>>;
@@ -549,6 +630,43 @@ export type RigConstraint =
549
630
  | RigPhysicsConstraint
550
631
  | RigUnimplementedConstraint;
551
632
 
633
+ // ---------------------------------------------------------------------------
634
+ // events — `root.events` (SkeletonJson.ts:469-484), an OBJECT, not an array
635
+ // ---------------------------------------------------------------------------
636
+
637
+ /**
638
+ * One event **definition**: a name the skeleton owns, plus the payload a firing
639
+ * carries when the animation does not override it.
640
+ *
641
+ * ⭐ The declaration lives in the rig spec and the firings live in the motion
642
+ * spec, for the same reason slots live here and their colour keys live there:
643
+ * the name is structure — the runtime looks it up, the game listens for it —
644
+ * and *when* it fires is time. `skeletonData.findEvent` resolves an animation's
645
+ * key against this table and **throws** on a miss (`:1244`), so an animation
646
+ * that names an event nobody declared does not load at all. rigc refuses it at
647
+ * compile instead, where the message can name the file that has to change.
648
+ *
649
+ * ⚠️ `volume` and `balance` are read **only when `audio` is set** (`:478-481`).
650
+ * Declared without one they are dropped in silence, so rigc refuses that pairing
651
+ * rather than emitting two numbers the runtime will never look at.
652
+ */
653
+ export interface RigEvent {
654
+ /** Default 0. The `int` payload every firing inherits unless it overrides it. */
655
+ int?: number;
656
+ /** Default 0. */
657
+ float?: number;
658
+ /** Default `""`. */
659
+ string?: string;
660
+ /**
661
+ * Audio path the editor recorded for this event. Nonessential to playback —
662
+ * no runtime here loads it — but it is what makes `volume`/`balance` legible.
663
+ */
664
+ audio?: string;
665
+ /** Only read when `audio` is set. */
666
+ volume?: number;
667
+ balance?: number;
668
+ }
669
+
552
670
  // ---------------------------------------------------------------------------
553
671
  // invariants — what skeleton JSON cannot say about itself
554
672
  // ---------------------------------------------------------------------------
@@ -619,6 +737,13 @@ export interface RigSpec {
619
737
  /** At least `default`, which becomes `skeletonData.defaultSkin` (`:441`). */
620
738
  skins?: Record<string, RigSkin>;
621
739
  constraints?: RigConstraint[];
740
+ /**
741
+ * `eventName -> payload defaults`. Emitted as `root.events`, which is an
742
+ * OBJECT keyed by name and not an array. The motion spec's per-animation
743
+ * `events` timeline fires them; a firing whose name is not a key here is a
744
+ * compile error, because the parser throws on it at load.
745
+ */
746
+ events?: Record<string, RigEvent>;
622
747
  invariants?: RigInvariants;
623
748
  }
624
749
 
@@ -727,5 +852,43 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
727
852
  constraintNames.add(constraint.name);
728
853
  }
729
854
 
855
+ if (raw.events !== undefined) {
856
+ if (!isObj(raw.events)) {
857
+ throw new CompileError(
858
+ `${where}: "events" is an object keyed by event name (\`{ "footstep": {} }\`), not an array — the format's own shape`,
859
+ );
860
+ }
861
+ for (const [name, def] of Object.entries(raw.events)) {
862
+ if (name.length === 0) throw new CompileError(`${where}: an event has an empty name`);
863
+ if (!isObj(def)) {
864
+ throw new CompileError(`${where}: event "${name}" must be an object of payload defaults (use {} for none)`);
865
+ }
866
+ for (const field of ['int', 'float', 'volume', 'balance'] as const) {
867
+ const v = def[field];
868
+ if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v))) {
869
+ throw new CompileError(`${where}: event "${name}" has ${field} ${JSON.stringify(v)}, which is not a finite number`);
870
+ }
871
+ }
872
+ if (def.int !== undefined && !Number.isInteger(def.int)) {
873
+ throw new CompileError(`${where}: event "${name}" has int ${JSON.stringify(def.int)}; the payload is an integer`);
874
+ }
875
+ for (const field of ['string', 'audio'] as const) {
876
+ if (def[field] !== undefined && typeof def[field] !== 'string') {
877
+ throw new CompileError(`${where}: event "${name}" has ${field} ${JSON.stringify(def[field])}, which is not a string`);
878
+ }
879
+ }
880
+ // SkeletonJson.ts:478-481 reads these two ONLY inside `if (data.audioPath)`.
881
+ // Without an audio path they are dropped with no error, so a spec that
882
+ // wrote them down would carry a number no runtime ever reads.
883
+ for (const field of ['volume', 'balance'] as const) {
884
+ if (def[field] !== undefined && def.audio === undefined) {
885
+ throw new CompileError(
886
+ `${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`,
887
+ );
888
+ }
889
+ }
890
+ }
891
+ }
892
+
730
893
  return spec;
731
894
  }