spine-rigc 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +76 -0
- package/README.md +558 -0
- package/cli.ts +739 -0
- package/docs/AUTHORING.md +1303 -0
- package/docs/SPEC_COVERAGE.md +1109 -0
- package/package.json +65 -0
- package/src/check.ts +1714 -0
- package/src/compile.ts +1861 -0
- package/src/diff.ts +847 -0
- package/src/errors.ts +22 -0
- package/src/framing.ts +539 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +433 -0
- package/src/png.ts +50 -0
- package/src/render.ts +974 -0
- package/src/rig.ts +731 -0
- package/src/slots.ts +603 -0
- package/src/timelines.ts +253 -0
- package/src/transform.ts +130 -0
- package/src/types.ts +586 -0
- package/src/validate.ts +1586 -0
- package/tools/font5x7.ts +101 -0
- package/tools/plate.ts +286 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compiler's two error kinds, in their own module so that `src/rig.ts` and
|
|
3
|
+
* `src/compile.ts` can both throw them without importing each other.
|
|
4
|
+
*
|
|
5
|
+
* `NotImplementedError` is not a lesser `CompileError`; it is a promise about
|
|
6
|
+
* the failure mode. The Spine 4.3 format holds seven attachment types and five
|
|
7
|
+
* constraint types (SPEC_COVERAGE part 1), rigc emits a slice of that, and the
|
|
8
|
+
* parser's behaviour on the rest is to **drop them without a word** — an unknown
|
|
9
|
+
* attachment `type` returns null (`SkeletonJson.ts:653`), a constraint entry with
|
|
10
|
+
* an unrecognised `type` matches no case and vanishes (`:148-367`). So a rig spec
|
|
11
|
+
* that asks for one of those must be refused by name rather than compiled into a
|
|
12
|
+
* skeleton that is quietly missing it.
|
|
13
|
+
*/
|
|
14
|
+
export class CompileError extends Error {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The spec can say it, the format can hold it, and rigc cannot emit it yet.
|
|
18
|
+
*
|
|
19
|
+
* Always name the field and what would have to be built, so the message is a
|
|
20
|
+
* work item rather than a wall.
|
|
21
|
+
*/
|
|
22
|
+
export class NotImplementedError extends CompileError {}
|
package/src/framing.ts
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framing — putting the candidate's pixels onto the reference's pixels.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is its own module
|
|
5
|
+
*
|
|
6
|
+
* Everything `check` reports sits downstream of one decision: which world box the
|
|
7
|
+
* candidate is rendered into. Get it wrong and every pixel below it is shifted or
|
|
8
|
+
* rescaled, and the error arrives disguised as MAE — as motion, which is the one
|
|
9
|
+
* thing `check` exists to measure. Two independent honest authoring runs measured
|
|
10
|
+
* exactly that (issue #34): rung 4's `wave-by-hand` read 49.45 framed and 22.96
|
|
11
|
+
* with the box pinned, on identical keys, and rung 5's first correct build read
|
|
12
|
+
* 39.00 instead of 4.35 because its content box came out 0.93 % narrow.
|
|
13
|
+
*
|
|
14
|
+
* ## The rule: both sides are measured the same way, on pixels
|
|
15
|
+
*
|
|
16
|
+
* The old procedure framed the candidate by the union of its **posed quad
|
|
17
|
+
* corners**. A region attachment's quad extends past its own artwork wherever the
|
|
18
|
+
* art is transparent, so a corner can sit where no pixel is — and since the
|
|
19
|
+
* mapping used `minX`, `maxY` and the long side only, one such corner in one frame
|
|
20
|
+
* of one animation set the scale for the whole comparison.
|
|
21
|
+
*
|
|
22
|
+
* So the box is taken from **drawn pixels** instead, on both sides, with the same
|
|
23
|
+
* predicate: a pixel is content when it differs from the frames' background by
|
|
24
|
+
* more than `BACKGROUND_TOLERANCE` on any channel. The candidate's box is the
|
|
25
|
+
* union over the frames it will be compared on, the reference's is the union over
|
|
26
|
+
* the frames on disk, and a similarity transform (uniform scale + translation)
|
|
27
|
+
* carries one onto the other.
|
|
28
|
+
*
|
|
29
|
+
* ⭐ The property that buys: **when the candidate is right, the framing is exact.**
|
|
30
|
+
* A faithful candidate renders to the same pixels as the reference, so its content
|
|
31
|
+
* box IS the reference's content box, the fit is the identity, and every
|
|
32
|
+
* measurement error in this file cancels. What is left is a procedure whose noise
|
|
33
|
+
* shrinks as the candidate improves, which is the only shape of noise an authoring
|
|
34
|
+
* loop can work against.
|
|
35
|
+
*/
|
|
36
|
+
import { Plate, type RGBA } from '../tools/plate.ts';
|
|
37
|
+
import {
|
|
38
|
+
fill,
|
|
39
|
+
pageFor,
|
|
40
|
+
projector,
|
|
41
|
+
rasterisePiece,
|
|
42
|
+
viewportOfSize,
|
|
43
|
+
type Frame,
|
|
44
|
+
type Viewport,
|
|
45
|
+
} from './render.ts';
|
|
46
|
+
|
|
47
|
+
/** How far a channel must move for a pixel to count as "not background". */
|
|
48
|
+
export const BACKGROUND_TOLERANCE = 8;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How far a pixel is from the background, as the largest channel difference.
|
|
52
|
+
*
|
|
53
|
+
* Used two ways, and they have to be the same function or the two uses disagree
|
|
54
|
+
* about where an edge is: as a threshold (`isContent`) and as a weight (the
|
|
55
|
+
* sub-pixel edge estimate below).
|
|
56
|
+
*/
|
|
57
|
+
export function backgroundDistance(plate: Plate, x: number, y: number, background: RGBA): number {
|
|
58
|
+
const [r, g, b] = plate.get(x, y);
|
|
59
|
+
return Math.max(
|
|
60
|
+
Math.abs(r - background[0]),
|
|
61
|
+
Math.abs(g - background[1]),
|
|
62
|
+
Math.abs(b - background[2]),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isContent(plate: Plate, x: number, y: number, background: RGBA): boolean {
|
|
67
|
+
return backgroundDistance(plate, x, y, background) > BACKGROUND_TOLERANCE;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Where a shape stops, as opposed to where it is faintly visible.
|
|
72
|
+
*
|
|
73
|
+
* ## Why the content box does not use `BACKGROUND_TOLERANCE`
|
|
74
|
+
*
|
|
75
|
+
* `BACKGROUND_TOLERANCE` answers "is there anything here at all", at about 3 % of
|
|
76
|
+
* a channel, and that is the right question for the union alpha and for
|
|
77
|
+
* connected components. It is the wrong question for an *edge*, because the two
|
|
78
|
+
* sides of this comparison do not render edges the same way: the reference frames
|
|
79
|
+
* are drawn from the example's packed atlas, which for several rungs ships at
|
|
80
|
+
* `scale: 0.5`, while the candidate is compiled from the loose full-size PNGs.
|
|
81
|
+
* Same geometry, softer ramp — and at a 3 % threshold the softer ramp reaches
|
|
82
|
+
* further out.
|
|
83
|
+
*
|
|
84
|
+
* Measured on rung 3's mechanical transcription, where the two sides are the same
|
|
85
|
+
* skeleton and the true answer is "identical": at tolerance 8 the left edges
|
|
86
|
+
* disagreed by **0.36 px**, which is enough to double the reported MAE.
|
|
87
|
+
*
|
|
88
|
+
* A blurred step crosses **half** its own contrast at the position of the
|
|
89
|
+
* original step, whatever the blur — so the box is taken at half of a robust
|
|
90
|
+
* estimate of a solid pixel's contrast, and the same disagreement drops to
|
|
91
|
+
* **0.01 px**. The level is derived from the reference frames and used on both
|
|
92
|
+
* sides, so it is one number rather than two that can drift apart.
|
|
93
|
+
*/
|
|
94
|
+
export const EDGE_FRACTION = 0.5;
|
|
95
|
+
/** Which quantile of the content's contrast counts as "a solid pixel". */
|
|
96
|
+
export const CONTENT_QUANTILE = 0.75;
|
|
97
|
+
|
|
98
|
+
/** Pooled contrast of everything that is not background, as a 0..255 histogram. */
|
|
99
|
+
export class ContrastHistogram {
|
|
100
|
+
private readonly bins = new Uint32Array(256);
|
|
101
|
+
private counted = 0;
|
|
102
|
+
|
|
103
|
+
add(plate: Plate, background: RGBA): void {
|
|
104
|
+
for (let y = 0; y < plate.height; y++) {
|
|
105
|
+
for (let x = 0; x < plate.width; x++) {
|
|
106
|
+
const d = backgroundDistance(plate, x, y, background);
|
|
107
|
+
if (d <= BACKGROUND_TOLERANCE) continue;
|
|
108
|
+
this.bins[Math.min(255, Math.round(d))]++;
|
|
109
|
+
this.counted++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The half-maximum level, or the bare tolerance when nothing was counted. */
|
|
115
|
+
level(): number {
|
|
116
|
+
if (this.counted === 0) return BACKGROUND_TOLERANCE;
|
|
117
|
+
const target = this.counted * CONTENT_QUANTILE;
|
|
118
|
+
let seen = 0;
|
|
119
|
+
for (let d = 0; d < 256; d++) {
|
|
120
|
+
seen += this.bins[d];
|
|
121
|
+
if (seen >= target) return Math.max(BACKGROUND_TOLERANCE, d * EDGE_FRACTION);
|
|
122
|
+
}
|
|
123
|
+
return BACKGROUND_TOLERANCE;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A content box in frame pixels, with **real** edges rather than pixel indices.
|
|
129
|
+
*
|
|
130
|
+
* The convention is the one a rasteriser uses: pixel `i` covers `[i, i+1)`, so a
|
|
131
|
+
* box `{ left: 3, right: 7 }` is four whole pixels wide and `{ left: 3.5 }` says
|
|
132
|
+
* the edge runs down the middle of pixel 3.
|
|
133
|
+
*/
|
|
134
|
+
export interface ContentBox {
|
|
135
|
+
left: number;
|
|
136
|
+
top: number;
|
|
137
|
+
right: number;
|
|
138
|
+
bottom: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const boxWidth = (b: ContentBox): number => b.right - b.left;
|
|
142
|
+
export const boxHeight = (b: ContentBox): number => b.bottom - b.top;
|
|
143
|
+
|
|
144
|
+
export function unionBoxes(a: ContentBox | null, b: ContentBox | null): ContentBox | null {
|
|
145
|
+
if (!a) return b;
|
|
146
|
+
if (!b) return a;
|
|
147
|
+
return {
|
|
148
|
+
left: Math.min(a.left, b.left),
|
|
149
|
+
top: Math.min(a.top, b.top),
|
|
150
|
+
right: Math.max(a.right, b.right),
|
|
151
|
+
bottom: Math.max(a.bottom, b.bottom),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** An integer scan window, half-open on the far edges. */
|
|
156
|
+
export interface ScanWindow {
|
|
157
|
+
minX: number;
|
|
158
|
+
minY: number;
|
|
159
|
+
maxX: number;
|
|
160
|
+
maxY: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Where a rendered plate's content stops, to a fraction of a pixel.
|
|
165
|
+
*
|
|
166
|
+
* ## The sub-pixel part, and why it is worth the paragraph
|
|
167
|
+
*
|
|
168
|
+
* A box read off integer pixel indices is quantised to ±0.5 px per edge, and rung
|
|
169
|
+
* 5 measured this shot's MAE moving 1.35 for a **0.065 px** viewport offset. Half
|
|
170
|
+
* a pixel of framing noise would therefore be louder than most of what an author
|
|
171
|
+
* is trying to hear.
|
|
172
|
+
*
|
|
173
|
+
* So each edge is refined by the mass in its outermost row or column. Anti-aliased
|
|
174
|
+
* coverage scales `backgroundDistance` roughly linearly, so if the first column
|
|
175
|
+
* holding content carries half the mass of the column behind it, the true edge
|
|
176
|
+
* runs about half a pixel in. That model is crude for a wedge — a shape that
|
|
177
|
+
* genuinely narrows towards its edge reads as partial coverage — but it is applied
|
|
178
|
+
* **identically to both sides**, so on similar silhouettes the bias is common-mode
|
|
179
|
+
* and on an exact candidate it cancels outright.
|
|
180
|
+
*
|
|
181
|
+
* `level` is the edge threshold — see `EDGE_FRACTION` for why it is not the bare
|
|
182
|
+
* background tolerance. `within` bounds the scan; it must contain every pixel that
|
|
183
|
+
* could be content, and the caller has that for free from the rasteriser's own
|
|
184
|
+
* destination bounds.
|
|
185
|
+
*/
|
|
186
|
+
export function contentBoxOfPlate(
|
|
187
|
+
plate: Plate,
|
|
188
|
+
background: RGBA,
|
|
189
|
+
level: number,
|
|
190
|
+
within?: ScanWindow,
|
|
191
|
+
): ContentBox | null {
|
|
192
|
+
const x0 = Math.max(0, within?.minX ?? 0);
|
|
193
|
+
const y0 = Math.max(0, within?.minY ?? 0);
|
|
194
|
+
const x1 = Math.min(plate.width, within?.maxX ?? plate.width);
|
|
195
|
+
const y1 = Math.min(plate.height, within?.maxY ?? plate.height);
|
|
196
|
+
if (x1 <= x0 || y1 <= y0) return null;
|
|
197
|
+
|
|
198
|
+
const columns = new Float64Array(plate.width);
|
|
199
|
+
const rows = new Float64Array(plate.height);
|
|
200
|
+
let minX = Infinity;
|
|
201
|
+
let minY = Infinity;
|
|
202
|
+
let maxX = -Infinity;
|
|
203
|
+
let maxY = -Infinity;
|
|
204
|
+
for (let y = y0; y < y1; y++) {
|
|
205
|
+
for (let x = x0; x < x1; x++) {
|
|
206
|
+
const d = backgroundDistance(plate, x, y, background);
|
|
207
|
+
if (d <= level) continue;
|
|
208
|
+
columns[x] += d;
|
|
209
|
+
rows[y] += d;
|
|
210
|
+
if (x < minX) minX = x;
|
|
211
|
+
if (x > maxX) maxX = x;
|
|
212
|
+
if (y < minY) minY = y;
|
|
213
|
+
if (y > maxY) maxY = y;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!Number.isFinite(minX)) return null;
|
|
217
|
+
return {
|
|
218
|
+
left: minX + inset(columns, minX, 1),
|
|
219
|
+
right: maxX + 1 - inset(columns, maxX, -1),
|
|
220
|
+
top: minY + inset(rows, minY, 1),
|
|
221
|
+
bottom: maxY + 1 - inset(rows, maxY, -1),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* How far inside its own pixel an edge sits, from the mass either side of it.
|
|
227
|
+
*
|
|
228
|
+
* `towards` points into the shape. A full outer line (as much mass as the line
|
|
229
|
+
* behind it) insets nothing; an outer line with none of it insets a whole pixel,
|
|
230
|
+
* which is the limit rather than a case that happens — a line with no mass is not
|
|
231
|
+
* the edge.
|
|
232
|
+
*/
|
|
233
|
+
function inset(mass: Float64Array, edge: number, towards: 1 | -1): number {
|
|
234
|
+
const inner = mass[edge + towards];
|
|
235
|
+
if (!inner || inner <= 0) return 0;
|
|
236
|
+
return Math.max(0, Math.min(1, 1 - mass[edge] / inner));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Every pixel a frame's pieces could touch, as an integer scan window. */
|
|
240
|
+
function pieceWindow(frame: Frame, viewport: Viewport): ScanWindow | null {
|
|
241
|
+
const project = projector(viewport);
|
|
242
|
+
let minX = Infinity;
|
|
243
|
+
let minY = Infinity;
|
|
244
|
+
let maxX = -Infinity;
|
|
245
|
+
let maxY = -Infinity;
|
|
246
|
+
for (const piece of frame.pieces) {
|
|
247
|
+
for (let i = 0; i < piece.world.length; i += 2) {
|
|
248
|
+
const [px, py] = project(piece.world[i], piece.world[i + 1]);
|
|
249
|
+
if (px < minX) minX = px;
|
|
250
|
+
if (px > maxX) maxX = px;
|
|
251
|
+
if (py < minY) minY = py;
|
|
252
|
+
if (py > maxY) maxY = py;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!Number.isFinite(minX)) return null;
|
|
256
|
+
return {
|
|
257
|
+
minX: Math.floor(minX) - 1,
|
|
258
|
+
minY: Math.floor(minY) - 1,
|
|
259
|
+
maxX: Math.ceil(maxX) + 2,
|
|
260
|
+
maxY: Math.ceil(maxY) + 2,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The content box of one candidate frame, drawn into `viewport`.
|
|
266
|
+
*
|
|
267
|
+
* Composited rather than measured piece by piece: two nearly-transparent parts
|
|
268
|
+
* overlapping at an extreme edge are content together and neither alone, and the
|
|
269
|
+
* reference side is a composite, so this one has to be too.
|
|
270
|
+
*/
|
|
271
|
+
export function frameContentBox(
|
|
272
|
+
frame: Frame,
|
|
273
|
+
pages: Map<string, Plate>,
|
|
274
|
+
viewport: Viewport,
|
|
275
|
+
background: RGBA,
|
|
276
|
+
level: number,
|
|
277
|
+
): ContentBox | null {
|
|
278
|
+
const window = pieceWindow(frame, viewport);
|
|
279
|
+
if (!window) return null;
|
|
280
|
+
const plate = new Plate(viewport.width, viewport.height);
|
|
281
|
+
fill(plate, background);
|
|
282
|
+
const project = projector(viewport);
|
|
283
|
+
for (const piece of frame.pieces) {
|
|
284
|
+
rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, r, g, b, a) => {
|
|
285
|
+
plate.blend(px, py, [r, g, b, a]);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return contentBoxOfPlate(plate, background, level, window);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// the fit
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/** One frame's two content boxes: what the candidate drew, and what is on disk. */
|
|
296
|
+
export interface BoxPair {
|
|
297
|
+
candidate: ContentBox;
|
|
298
|
+
reference: ContentBox;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** What framing the candidate cost, and what it could not absorb. */
|
|
302
|
+
export interface FramingFit {
|
|
303
|
+
/** The candidate's content box, unioned over every frame compared. */
|
|
304
|
+
candidate: ContentBox;
|
|
305
|
+
/** The reference frames' content box, over the same frames. */
|
|
306
|
+
reference: ContentBox;
|
|
307
|
+
/** How many frames the fit was made from. */
|
|
308
|
+
frames: number;
|
|
309
|
+
/**
|
|
310
|
+
* Uniform scale carrying candidate pixels onto reference pixels.
|
|
311
|
+
*
|
|
312
|
+
* `> 1` means the candidate's content is SMALLER than the reference's and was
|
|
313
|
+
* scaled up to meet it; `1` means the two agree.
|
|
314
|
+
*/
|
|
315
|
+
scale: number;
|
|
316
|
+
/** Translation in frame pixels, applied after the scale. */
|
|
317
|
+
dx: number;
|
|
318
|
+
dy: number;
|
|
319
|
+
/** RMS of what the fit left over, across every edge of every frame, in pixels. */
|
|
320
|
+
rms: number;
|
|
321
|
+
/** What one uniform scale could not absorb on the union box: `scale·w − W`. */
|
|
322
|
+
residualWidth: number;
|
|
323
|
+
residualHeight: number;
|
|
324
|
+
/** Candidate aspect ÷ reference aspect − 1, on the union box. */
|
|
325
|
+
aspectError: number;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The similarity transform mapping the candidate's content onto the reference's,
|
|
330
|
+
* by least squares over **every edge of every frame**.
|
|
331
|
+
*
|
|
332
|
+
* ## Why every frame, and not the union box
|
|
333
|
+
*
|
|
334
|
+
* The first version of this fitted one union box to the other, and that is still
|
|
335
|
+
* an extreme-value statistic — it just moved the fragility from an invisible quad
|
|
336
|
+
* corner to a visible pixel. Rung 5 demonstrates it: that candidate's runner has
|
|
337
|
+
* rigid limbs where the reference's fly off the body, so on a handful of frames it
|
|
338
|
+
* reaches about 1.5 px further left than anything in the reference does. Framing on
|
|
339
|
+
* the union let those few frames rescale all 322 of them, and the reported MAE went
|
|
340
|
+
* **4.35 → 19.7** against a viewport that is known to be right.
|
|
341
|
+
*
|
|
342
|
+
* Fitting over every frame gives each frame's four edges one vote out of `4N`, so
|
|
343
|
+
* a pose that overreaches on three frames moves the framing by about `3/N` of what
|
|
344
|
+
* it used to. What that overreach *should* do — and now does — is show up in the
|
|
345
|
+
* residual, where it reads as "your shot covers more ground than the reference's".
|
|
346
|
+
*
|
|
347
|
+
* ## The derivation
|
|
348
|
+
*
|
|
349
|
+
* With `T(p) = s·p + t` the optimal `t` always centres the two clouds, so with `x`
|
|
350
|
+
* the candidate's edge positions and `X` the reference's,
|
|
351
|
+
*
|
|
352
|
+
* s = [Σ(x−x̄)(X−X̄) + Σ(y−ȳ)(Y−Ȳ)] / [Σ(x−x̄)² + Σ(y−ȳ)²]
|
|
353
|
+
*
|
|
354
|
+
* x and y pooled into one sum because the scale is uniform, with their own means
|
|
355
|
+
* because the translation is not. The single-box case is this with `N = 1`.
|
|
356
|
+
*
|
|
357
|
+
* ## What it deliberately does NOT do: throw outliers away
|
|
358
|
+
*
|
|
359
|
+
* Four robust variants were written and measured against the five ladder
|
|
360
|
+
* candidates and the selftest fixture — sigma trimming, Tukey IRLS, a median
|
|
361
|
+
* estimator, and dropping whichever of the four edge kinds disagrees with the
|
|
362
|
+
* other three. None of them beat plain least squares across the set, and two were
|
|
363
|
+
* clearly worse on the one shot where a correct viewport is known (rung 5, whose
|
|
364
|
+
* author matched the reference's world box by hand): 8.60 trimmed and 13.4 IRLS
|
|
365
|
+
* against 12.5 plain, where the correct framing scores 4.35. The simplest
|
|
366
|
+
* estimator that no single frame can move is the one that ships.
|
|
367
|
+
*
|
|
368
|
+
* ## The floor, stated plainly
|
|
369
|
+
*
|
|
370
|
+
* This registers two shots by their **extent**, and when the candidate's
|
|
371
|
+
* silhouette genuinely differs the best fit of the extents is not the best
|
|
372
|
+
* alignment of the pictures. Measured on rung 5: at the viewport known to be
|
|
373
|
+
* right, the candidate's top edge sits 0.2 px inside the reference's, and the fit
|
|
374
|
+
* spends 0.1 % of scale and a quarter-pixel of offset absorbing it — which costs
|
|
375
|
+
* more than leaving it. That shot moves 1.35 MAE per 0.065 px of viewport, so a
|
|
376
|
+
* framing good to a third of a pixel is worth several MAE there and nothing at all
|
|
377
|
+
* on rung 4. The residual line says when this is happening (`union residual`
|
|
378
|
+
* larger than a pixel, or `rms` above one) and `--viewport` pins the box when an
|
|
379
|
+
* author knows better.
|
|
380
|
+
*
|
|
381
|
+
* ⚠️ What least squares cannot do is make two different shapes agree. If the
|
|
382
|
+
* candidate covers a different extent the fit splits the difference, and the
|
|
383
|
+
* leftover is reported as `residualWidth`/`residualHeight` and `rms` — computed
|
|
384
|
+
* over **every** edge, trimmed ones included — rather than being silently spent.
|
|
385
|
+
* That residual is the number that says "something is a different size, or is in
|
|
386
|
+
* one shot and not the other", which used to arrive disguised as MAE.
|
|
387
|
+
*/
|
|
388
|
+
export function fitFraming(pairs: BoxPair[]): FramingFit {
|
|
389
|
+
if (pairs.length === 0) throw new Error('fitFraming needs at least one frame to fit');
|
|
390
|
+
const xs: Array<[number, number]> = [];
|
|
391
|
+
const ys: Array<[number, number]> = [];
|
|
392
|
+
let candidate: ContentBox | null = null;
|
|
393
|
+
let reference: ContentBox | null = null;
|
|
394
|
+
for (const pair of pairs) {
|
|
395
|
+
xs.push([pair.candidate.left, pair.reference.left], [pair.candidate.right, pair.reference.right]);
|
|
396
|
+
ys.push([pair.candidate.top, pair.reference.top], [pair.candidate.bottom, pair.reference.bottom]);
|
|
397
|
+
candidate = unionBoxes(candidate, pair.candidate);
|
|
398
|
+
reference = unionBoxes(reference, pair.reference);
|
|
399
|
+
}
|
|
400
|
+
const mean = (v: Array<[number, number]>, i: 0 | 1): number => v.reduce((a, p) => a + p[i], 0) / v.length;
|
|
401
|
+
const xBar = mean(xs, 0);
|
|
402
|
+
const XBar = mean(xs, 1);
|
|
403
|
+
const yBar = mean(ys, 0);
|
|
404
|
+
const YBar = mean(ys, 1);
|
|
405
|
+
let numerator = 0;
|
|
406
|
+
let denominator = 0;
|
|
407
|
+
for (const [x, X] of xs) {
|
|
408
|
+
numerator += (x - xBar) * (X - XBar);
|
|
409
|
+
denominator += (x - xBar) ** 2;
|
|
410
|
+
}
|
|
411
|
+
for (const [y, Y] of ys) {
|
|
412
|
+
numerator += (y - yBar) * (Y - YBar);
|
|
413
|
+
denominator += (y - yBar) ** 2;
|
|
414
|
+
}
|
|
415
|
+
const scale = denominator > 0 ? numerator / denominator : 1;
|
|
416
|
+
const dx = XBar - scale * xBar;
|
|
417
|
+
const dy = YBar - scale * yBar;
|
|
418
|
+
let squared = 0;
|
|
419
|
+
for (const [x, X] of xs) squared += (scale * x + dx - X) ** 2;
|
|
420
|
+
for (const [y, Y] of ys) squared += (scale * y + dy - Y) ** 2;
|
|
421
|
+
|
|
422
|
+
const c = candidate as ContentBox;
|
|
423
|
+
const r = reference as ContentBox;
|
|
424
|
+
const candidateAspect = boxHeight(c) > 0 ? boxWidth(c) / boxHeight(c) : 0;
|
|
425
|
+
const referenceAspect = boxHeight(r) > 0 ? boxWidth(r) / boxHeight(r) : 0;
|
|
426
|
+
return {
|
|
427
|
+
candidate: c,
|
|
428
|
+
reference: r,
|
|
429
|
+
frames: pairs.length,
|
|
430
|
+
scale,
|
|
431
|
+
dx,
|
|
432
|
+
dy,
|
|
433
|
+
rms: Math.sqrt(squared / (xs.length + ys.length)),
|
|
434
|
+
residualWidth: scale * boxWidth(c) - boxWidth(r),
|
|
435
|
+
residualHeight: scale * boxHeight(c) - boxHeight(r),
|
|
436
|
+
aspectError: referenceAspect > 0 ? candidateAspect / referenceAspect - 1 : 0,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* How far from the identity a fit may be and still be called settled, in pixels.
|
|
442
|
+
*
|
|
443
|
+
* A tenth of a pixel, because that is roughly the floor of the method: a content
|
|
444
|
+
* box read off a rendered grid is quantised, and the sub-pixel edge estimate
|
|
445
|
+
* recovers a fraction of that rather than all of it. Chasing below this is
|
|
446
|
+
* chasing the measurement, and the loop starts jittering instead of converging.
|
|
447
|
+
*/
|
|
448
|
+
export const SETTLED_PIXELS = 0.1;
|
|
449
|
+
|
|
450
|
+
/** Is this fit close enough to the identity that another pass would only add noise? */
|
|
451
|
+
export function fitIsSettled(fit: FramingFit): boolean {
|
|
452
|
+
return fitDistance(fit) < SETTLED_PIXELS;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* How far applying this fit would move the content, in pixels: the worst of its
|
|
457
|
+
* four box corners.
|
|
458
|
+
*
|
|
459
|
+
* Not `|scale − 1|` and not `|t|` — either alone is misleading, because the
|
|
460
|
+
* translation is chosen to centre the boxes and therefore *cancels* part of the
|
|
461
|
+
* scale. What an author cares about, and what the loop should stop on, is how far
|
|
462
|
+
* anything actually moves.
|
|
463
|
+
*/
|
|
464
|
+
export function fitDistance(fit: FramingFit): number {
|
|
465
|
+
return cornerSpread(fit.candidate, (x, y) => [fit.scale * x + fit.dx - x, fit.scale * y + fit.dy - y]);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* How far apart two passes' corrections are, in pixels — the same corner measure
|
|
470
|
+
* as `fitDistance`, applied to the difference between the two transforms.
|
|
471
|
+
*
|
|
472
|
+
* This is what tells a framing loop that it is **cycling** rather than
|
|
473
|
+
* converging. The loop's passes are not independent samples: each one is measured
|
|
474
|
+
* on the render the previous one produced, so when the fit has no fixed point the
|
|
475
|
+
* sequence falls into a repeating orbit instead of wandering. Rung 6 does exactly
|
|
476
|
+
* that with period 4 — passes 4–7 repeat as 8–11 and again as 12–15, agreeing to
|
|
477
|
+
* within 0.02 px — and a loop that cannot tell that apart from slow convergence
|
|
478
|
+
* spends every remaining pass re-measuring states it has already seen.
|
|
479
|
+
*
|
|
480
|
+
* The boxes come from `a`, because the two fits are measured against the same
|
|
481
|
+
* reference frames and it is the candidate's own extent that the correction moves.
|
|
482
|
+
*/
|
|
483
|
+
export function fitSeparation(a: FramingFit, b: FramingFit): number {
|
|
484
|
+
return cornerSpread(a.candidate, (x, y) => [
|
|
485
|
+
a.scale * x + a.dx - (b.scale * x + b.dx),
|
|
486
|
+
a.scale * y + a.dy - (b.scale * y + b.dy),
|
|
487
|
+
]);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The worst displacement a transform applies over a box's four corners. */
|
|
491
|
+
function cornerSpread(box: ContentBox, displace: (x: number, y: number) => [number, number]): number {
|
|
492
|
+
const { left, top, right, bottom } = box;
|
|
493
|
+
let worst = 0;
|
|
494
|
+
for (const [x, y] of [
|
|
495
|
+
[left, top],
|
|
496
|
+
[right, top],
|
|
497
|
+
[left, bottom],
|
|
498
|
+
[right, bottom],
|
|
499
|
+
]) {
|
|
500
|
+
const [dx, dy] = displace(x, y);
|
|
501
|
+
worst = Math.max(worst, Math.hypot(dx, dy));
|
|
502
|
+
}
|
|
503
|
+
return worst;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* How far two passes' corrections may differ and still be called the same state.
|
|
508
|
+
*
|
|
509
|
+
* Half of `SETTLED_PIXELS`, and the gap it has to live in was measured rather than
|
|
510
|
+
* picked: on rung 6 two passes one period apart differ by **0.018 px** while two
|
|
511
|
+
* adjacent passes of the same orbit differ by **0.110 px**. A detector at 0.05 px
|
|
512
|
+
* separates those by a factor of three either way. Loose enough to fire late, and
|
|
513
|
+
* a late cycle report costs a pass; tight enough that it never fires on a loop
|
|
514
|
+
* that is still moving, and a false one would stop a converging fit short.
|
|
515
|
+
*/
|
|
516
|
+
export const CYCLE_PIXELS = SETTLED_PIXELS / 2;
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The viewport that renders the candidate where the fit says it belongs.
|
|
520
|
+
*
|
|
521
|
+
* `projector` is `px = (wx − minX)·k`, `py = (maxY − wy)·k`, so asking for
|
|
522
|
+
* `px' = s·px + dx` is asking for `k' = s·k` and an origin moved by `dx/k'`. The
|
|
523
|
+
* pixel size is the frames' own and is never re-derived from the box — rounding it
|
|
524
|
+
* a second time would shift every measurement by up to half a pixel, which on
|
|
525
|
+
* these shots is louder than most of what is being measured.
|
|
526
|
+
*/
|
|
527
|
+
export function applyFit(
|
|
528
|
+
viewport: Viewport,
|
|
529
|
+
fit: FramingFit,
|
|
530
|
+
pixelWidth: number,
|
|
531
|
+
pixelHeight: number,
|
|
532
|
+
): Viewport {
|
|
533
|
+
const scale = viewport.scale * fit.scale;
|
|
534
|
+
const minX = viewport.minX - fit.dx / scale;
|
|
535
|
+
const maxY = viewport.maxY + fit.dy / scale;
|
|
536
|
+
const width = pixelWidth / scale;
|
|
537
|
+
const height = pixelHeight / scale;
|
|
538
|
+
return viewportOfSize(minX, maxY - height, width, height, scale, pixelWidth, pixelHeight);
|
|
539
|
+
}
|
package/src/ladder.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The benchmark ladder: which official Spine example is which rung, and which
|
|
3
|
+
* file in it is the reference.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ This is a table and not a naming rule, because there is no naming rule.
|
|
6
|
+
* The obvious one — `examples/<name>/export/<name>-ess.json` — is wrong on four
|
|
7
|
+
* of the nine examples: `6-arcs` ships only a `-pro` export, `7-anticipation`'s
|
|
8
|
+
* skeleton is called `sack-pro` after its subject rather than its directory,
|
|
9
|
+
* and `1-weight-and-mass` and `8-follow-through` ship two skeletons each. A
|
|
10
|
+
* rule that is right five times out of nine is worse than a table, because the
|
|
11
|
+
* four failures look like missing files rather than like a wrong assumption.
|
|
12
|
+
*
|
|
13
|
+
* The rung ORDER is not the numeric order of the directories: rung 3 is the
|
|
14
|
+
* smallest skeleton in the corpus (3 bones, 2 slots, 2 animations) and is the
|
|
15
|
+
* first one attempted. `docs/LADDER.md` carries the order, the per-rung gating
|
|
16
|
+
* features and the status; this file carries only what `bench` has to resolve.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** `rung` counts towards the rung; `stretch` is reported and does not. */
|
|
20
|
+
export type RungRole = 'rung' | 'stretch';
|
|
21
|
+
|
|
22
|
+
export interface RungSkeleton {
|
|
23
|
+
/** Short label for the report, unique within the rung. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** File name inside the example's `export/` directory. */
|
|
26
|
+
file: string;
|
|
27
|
+
/** Atlas file name inside the same directory. */
|
|
28
|
+
atlas: string;
|
|
29
|
+
role: RungRole;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Rung {
|
|
33
|
+
/** What `bench <rung>` is spelled as. */
|
|
34
|
+
id: string;
|
|
35
|
+
/** Directory under `examples/`. */
|
|
36
|
+
example: string;
|
|
37
|
+
/** One line on what this rung is testing, from SPEC_COVERAGE part 4-1. */
|
|
38
|
+
gates: string;
|
|
39
|
+
skeletons: RungSkeleton[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const LADDER: readonly Rung[] = [
|
|
43
|
+
{
|
|
44
|
+
id: '1',
|
|
45
|
+
example: '1-weight-and-mass',
|
|
46
|
+
gates: 'translatex/translatey/shear bone timelines; bone setup length; a skeleton with zero animations (drop)',
|
|
47
|
+
skeletons: [
|
|
48
|
+
{ label: 'balls', file: '1-weight-and-mass-balls-ess.json', atlas: '1-weight-and-mass.atlas', role: 'rung' },
|
|
49
|
+
{ label: 'drop', file: '1-weight-and-mass-drop-ess.json', atlas: '1-weight-and-mass.atlas', role: 'rung' },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: '2',
|
|
54
|
+
example: '2-the-12-principles',
|
|
55
|
+
gates: 'slot blend modes (4 additive + 4 multiply); bone inherit ≠ Normal',
|
|
56
|
+
skeletons: [
|
|
57
|
+
{ label: 'ess', file: '2-the-12-principles-ess.json', atlas: '2-the-12-principles.atlas', role: 'rung' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: '3',
|
|
62
|
+
example: '3-timing-and-spacing',
|
|
63
|
+
gates: 'nothing new — the smallest skeleton in the corpus, and the first rung to attempt',
|
|
64
|
+
skeletons: [
|
|
65
|
+
{ label: 'ess', file: '3-timing-and-spacing-ess.json', atlas: '3-timing-and-spacing.atlas', role: 'rung' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: '4',
|
|
70
|
+
example: '4-wave-principle',
|
|
71
|
+
gates: 'nothing structurally new — a volume test (9 bones, 9 slots, 3 animations, 470 bezier keys)',
|
|
72
|
+
skeletons: [{ label: 'ess', file: '4-wave-principle-ess.json', atlas: '4-wave-principle.atlas', role: 'rung' }],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: '5',
|
|
76
|
+
example: '5-squash-and-stretch',
|
|
77
|
+
gates: 'drawOrder timeline (first appearance); inherit: onlyTranslation; non-unit setup scale',
|
|
78
|
+
skeletons: [
|
|
79
|
+
{ label: 'ess', file: '5-squash-and-stretch-ess.json', atlas: '5-squash-and-stretch.atlas', role: 'rung' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: '6',
|
|
84
|
+
example: '6-arcs',
|
|
85
|
+
gates: 'transform constraints (first appearance, static); weighted meshes from authored geometry; mesh edges',
|
|
86
|
+
skeletons: [{ label: 'pro', file: '6-arcs-pro.json', atlas: '6-arcs.atlas', role: 'rung' }],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: '7',
|
|
90
|
+
example: '7-anticipation',
|
|
91
|
+
gates: 'physics timelines; a KEYED transform timeline; deform (first appearance); 20 physics constraints',
|
|
92
|
+
skeletons: [{ label: 'sack-pro', file: 'sack-pro.json', atlas: '7-anticipation.atlas', role: 'rung' }],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: '8',
|
|
96
|
+
example: '8-follow-through',
|
|
97
|
+
gates: 'nothing new — transform constraints and weighted meshes both arrived at rung 6',
|
|
98
|
+
skeletons: [
|
|
99
|
+
{ label: 'ball', file: '8-follow-through-pro-ball.json', atlas: '8-follow-through.atlas', role: 'rung' },
|
|
100
|
+
{ label: 'pendulum', file: '8-follow-through-pro-pendulum.json', atlas: '8-follow-through.atlas', role: 'rung' },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: 'spineboy',
|
|
105
|
+
example: 'spineboy',
|
|
106
|
+
gates: 'IK, events, bounding box, clipping, unweighted meshes — and scale: 67 bones, 52 slots, 11 animations',
|
|
107
|
+
skeletons: [
|
|
108
|
+
{ label: 'ess', file: 'spineboy-ess.json', atlas: 'spineboy.atlas', role: 'rung' },
|
|
109
|
+
// `-pro` is reported and does not count. It is a harder rig than the
|
|
110
|
+
// graduation exam itself, and folding it in would make the exam
|
|
111
|
+
// unpassable for a reason that has nothing to do with passing it.
|
|
112
|
+
{ label: 'pro', file: 'spineboy-pro.json', atlas: 'spineboy.atlas', role: 'stretch' },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
export function findRung(id: string): Rung | undefined {
|
|
118
|
+
return LADDER.find((r) => r.id === id);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const RUNG_IDS: readonly string[] = LADDER.map((r) => r.id);
|