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/README.md +258 -9
- package/cli.ts +57 -6
- package/docs/AUTHORING.md +1006 -50
- package/docs/SPEC_COVERAGE.md +21 -14
- package/package.json +5 -2
- package/src/chains.ts +170 -0
- package/src/check.ts +1555 -98
- package/src/compile.ts +325 -6
- package/src/framing.ts +280 -0
- package/src/ladder.ts +1 -1
- package/src/render.ts +43 -2
- package/src/rig.ts +169 -6
- package/src/slots.ts +102 -4
- package/src/timelines.ts +9 -5
- package/src/types.ts +80 -1
- package/src/validate.ts +192 -2
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:
|
|
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(
|
|
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
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
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: '
|
|
481
|
+
type: 'point' | 'path' | 'linkedmesh';
|
|
406
482
|
[field: string]: unknown;
|
|
407
483
|
}
|
|
408
484
|
|
|
409
|
-
export type RigAttachment =
|
|
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
|
}
|