spine-rigc 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/render.ts ADDED
@@ -0,0 +1,974 @@
1
+ /**
2
+ * The rasteriser — one code path for reference frames and for candidates.
3
+ *
4
+ * ⭐ Why this is a module and not a script. `bench/render_reference.ts` renders
5
+ * the official export to the PNG frames an authoring agent is allowed to see;
6
+ * `rigc check` renders the agent's own candidate and compares it against those
7
+ * frames. If those two drew pixels differently, every number `check` reports
8
+ * would carry the difference between two renderers on top of the difference
9
+ * between two rigs — and the second is the only one anybody wants to read. So
10
+ * there is exactly one rasteriser, and both callers are thin.
11
+ *
12
+ * ## What it draws
13
+ *
14
+ * Region and mesh attachments, in draw order, tinted by slot colour x attachment
15
+ * colour. Both are **affine** texture maps and neither divides by w:
16
+ *
17
+ * - a **region** is one quad. `spine-core` computes its four world vertices on
18
+ * the CPU, a destination pixel maps back into the region's rectangle by
19
+ * inverting one 2x2, and there is no triangle split at all;
20
+ * - a **mesh** is a triangle list. `MeshAttachment.computeWorldVertices` does the
21
+ * work — it is the runtime's own routine, so weighted vertices resolve through
22
+ * their bones and a `deform` timeline's offsets are applied, exactly the way a
23
+ * real runtime would. Each triangle is then filled with barycentric UV
24
+ * interpolation.
25
+ *
26
+ * ⭐ **Sampling is bilinear on both paths, and the source is straight alpha.**
27
+ * One filter rather than two is not a detail: `check` measures a candidate
28
+ * against reference frames, and a mesh triangle sampled nearest against a
29
+ * reference sampled bilinear would put a filter difference into the residual
30
+ * where only a rig difference belongs. Bilinear rather than nearest because the
31
+ * region path was already bilinear and the five committed rungs are rendered
32
+ * with it — see `bilinear`.
33
+ *
34
+ * ⚠️ **Region rasterising is untouched by the mesh path**, deliberately. A region
35
+ * could be drawn as two triangles and very nearly the same pixels would come out;
36
+ * "very nearly" would have silently rewritten five rungs of committed reference
37
+ * frames. `rasteriseQuad` still owns regions, `rasteriseMesh` owns meshes, and
38
+ * `rasterisePiece` picks.
39
+ *
40
+ * ## The fill rule, and why a mesh needs one
41
+ *
42
+ * Two triangles that share an edge must cover the pixels along it exactly once.
43
+ * Include the boundary in both and every interior edge of a mesh blends twice —
44
+ * a visible lattice of seams wherever the art is not opaque. Exclude it in both
45
+ * and the seams become holes.
46
+ *
47
+ * So `rasteriseMesh` normalises each triangle's winding and applies the standard
48
+ * **top-left rule**: a pixel centre exactly on an edge belongs to the triangle
49
+ * only when that edge is a top or a left one. The two triangles sharing an edge
50
+ * traverse it in opposite directions, so exactly one of them calls it top-left —
51
+ * which is the property that makes the rule watertight without an epsilon.
52
+ *
53
+ * ## Two conventions this file owns
54
+ *
55
+ * - **Spine world is y up; an image is y down.** The projection from world to
56
+ * frame pixels lives in `projector` and nowhere else.
57
+ * - **The framing box is measured at `FRAMING_FPS`, whatever rate frames are
58
+ * written at.** The union of the posed vertices depends on WHICH TIMES you
59
+ * sample, so taking it at the output rate made the viewport a property of the
60
+ * rate: rung 1's `balls` framed to 256x240 at 12 fps and 256x239 at 24 fps.
61
+ * One pixel is enough to be a trap — the two sets look comparable, an author
62
+ * measures a distance in one and a time in the other, and the scale between
63
+ * them is silently off.
64
+ *
65
+ * ⚠️ Two notes on where this sits. It imports `spine-core`, which `src/` is
66
+ * otherwise careful about: posing a skeleton *is* running the runtime, and there
67
+ * is no honest way to render one without it. The rule that matters is unchanged
68
+ * — `src/compile.ts` must stay independent of the runtime so the compiler and
69
+ * the gate are not checking each other's assumptions — and this file is neither.
70
+ * It also imports `tools/plate.ts` for the PNG codec, which is dependency-free.
71
+ */
72
+ import {
73
+ AnimationState,
74
+ AnimationStateData,
75
+ AtlasAttachmentLoader,
76
+ MeshAttachment,
77
+ Physics,
78
+ RegionAttachment,
79
+ Skeleton,
80
+ SkeletonJson,
81
+ TextureAtlas,
82
+ TextureAtlasRegion,
83
+ type SkeletonData,
84
+ } from '@esotericsoftware/spine-core';
85
+ import { readFileSync } from 'node:fs';
86
+ import { join } from 'node:path';
87
+ import { Plate, readPlate, type RGBA } from '../tools/plate.ts';
88
+
89
+ /** Opaque, and light: both of rung 3's parts are dark slate, so is every ground. */
90
+ export const BACKGROUND: RGBA = [232, 232, 232, 255];
91
+ /** Padding around the union bounding box, as a fraction of its long side. */
92
+ export const PAD = 0.04;
93
+ /**
94
+ * Directory a skeleton with no animation writes its one frame into.
95
+ *
96
+ * It cannot collide with an animation's directory, because an animation named
97
+ * `setup` would have to live in a skeleton that has at least one animation, and
98
+ * this name is only ever used when there are none.
99
+ */
100
+ export const SETUP_POSE_DIR = 'setup';
101
+ /**
102
+ * The sampling rate the ladder's briefs are written against.
103
+ *
104
+ * It is a constant rather than a bare `12` in the default because it is also the
105
+ * rate at which the directory name says nothing: a rung rendered at the protocol
106
+ * rate writes `<animation>/`, and any other rate writes `<animation>@<fps>fps/`.
107
+ */
108
+ export const PROTOCOL_FPS = 12;
109
+ /** The rate the framing box is measured at, whatever `--fps` writes frames at. */
110
+ export const FRAMING_FPS = 60;
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // the frame-set sidecar
114
+ // ---------------------------------------------------------------------------
115
+ //
116
+ // ⭐ A rendered frame set is a picture of a world box, and the box used to be
117
+ // nowhere. That cost two things. An author measuring a distance in pixels had no
118
+ // way to turn it into the units a rig is authored in except by finding something
119
+ // of a known size in the shot; and nothing could render a SECOND skeleton onto
120
+ // the same pixel grid, because the grid was a number that existed only inside one
121
+ // run of `render_reference.ts`. `frames.json` writes it down.
122
+
123
+ /** The sidecar's file name and format tag. */
124
+ export const FRAMES_SIDECAR = 'frames.json';
125
+ export const FRAMES_SPEC = 'rigc-frames/1';
126
+
127
+ /** One rendered frame directory: which animation, at what rate, and what is on disk. */
128
+ export interface FrameSet {
129
+ /** Directory name under the skeleton root — `heavy`, or `heavy@24fps`. */
130
+ dir: string;
131
+ /** The animation these frames show, or `null` for a skeleton with none. */
132
+ animation: string | null;
133
+ fps: number;
134
+ /** How many frames the animation sampled to at this rate. */
135
+ sampled: number;
136
+ /** How many were actually written (a stride writes fewer). */
137
+ written: number;
138
+ stride: number;
139
+ /**
140
+ * The last sampled frame's time, in seconds.
141
+ *
142
+ * ⚠️ Which indices are on disk is deliberately NOT recorded here. The
143
+ * directory is the only author of that fact, and a second copy of it in this
144
+ * file could only ever be the stale one.
145
+ */
146
+ duration: number;
147
+ }
148
+
149
+ export interface FramesSidecar {
150
+ spec: string;
151
+ example?: string;
152
+ rung?: string;
153
+ skeleton?: string;
154
+ /** The colour the frames were cleared to, straight RGBA 0..255. */
155
+ background: RGBA;
156
+ viewport: {
157
+ /** World box, y up, matching Spine's own coordinates. */
158
+ x: number;
159
+ y: number;
160
+ width: number;
161
+ height: number;
162
+ /** Frame pixels per world unit. */
163
+ scale: number;
164
+ pixelWidth: number;
165
+ pixelHeight: number;
166
+ };
167
+ sets: FrameSet[];
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // posing
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /** What every drawable has in common, whatever shape it is. */
175
+ export interface PieceCommon {
176
+ /**
177
+ * World-space vertex positions, `x, y` per vertex.
178
+ *
179
+ * ⭐ The one field the framing code reads, and the reason it is spelled the
180
+ * same on both shapes: a union over "every posed point" is a loop over this
181
+ * array in steps of two, and it does not need to know whether four numbers are
182
+ * a rectangle's corners or two hundred are a mesh's hull.
183
+ */
184
+ world: number[];
185
+ /** Slot colour x attachment colour, straight alpha, 0..1. */
186
+ tint: [number, number, number, number];
187
+ /** The slot this was drawn for — what per-slot tracking is keyed by. */
188
+ slot: string;
189
+ /** The atlas page name this samples, so a multi-page atlas resolves. */
190
+ page: string;
191
+ }
192
+
193
+ export interface Quad extends PieceCommon {
194
+ kind: 'region';
195
+ /** World-space corners, in spine-core's region order: br, bl, ul, ur. */
196
+ world: number[];
197
+ /** Page UVs for the same four corners. */
198
+ uvs: ArrayLike<number>;
199
+ }
200
+
201
+ /**
202
+ * A posed mesh attachment: world vertices, page UVs, and the triangulation.
203
+ *
204
+ * The vertices arrive from `MeshAttachment.computeWorldVertices`, which is the
205
+ * runtime's own routine and therefore the only place the weighting and deform
206
+ * arithmetic lives. Reimplementing either here would give `check` a second
207
+ * opinion about where a vertex is, and a second opinion is exactly what a gate
208
+ * must not have.
209
+ */
210
+ export interface Mesh extends PieceCommon {
211
+ kind: 'mesh';
212
+ /** Page UVs, `u, v` per vertex, parallel to `world`. */
213
+ uvs: ArrayLike<number>;
214
+ /** Vertex index triplets. */
215
+ triangles: ArrayLike<number>;
216
+ }
217
+
218
+ /** One drawable in a posed frame. */
219
+ export type Piece = Quad | Mesh;
220
+
221
+ export interface Frame {
222
+ /** Index within the sampled sequence — the number in `f0000.png`. */
223
+ index: number;
224
+ time: number;
225
+ /**
226
+ * Everything the frame draws, in draw order.
227
+ *
228
+ * Named `pieces` rather than `quads` since meshes joined it: a mesh is not a
229
+ * quad, and a field that says otherwise is the kind of name a reader trusts
230
+ * and then indexes `world[6]` through.
231
+ */
232
+ pieces: Piece[];
233
+ }
234
+
235
+ /** Where the world sits in a frame: the four world numbers plus the scale. */
236
+ export interface Viewport {
237
+ minX: number;
238
+ minY: number;
239
+ maxX: number;
240
+ maxY: number;
241
+ /** Frame pixels per world unit. */
242
+ scale: number;
243
+ /** Frame size in pixels. */
244
+ width: number;
245
+ height: number;
246
+ }
247
+
248
+ /** A loaded skeleton and every atlas page it can sample, keyed by page name. */
249
+ export interface Posable {
250
+ data: SkeletonData;
251
+ pages: Map<string, Plate>;
252
+ }
253
+
254
+ /**
255
+ * Load a skeleton, its atlas and every page the atlas declares.
256
+ *
257
+ * Every page, not the first: rigc emits **one part per page**, so a rigc
258
+ * candidate for rung 1 has eight of them. `render_reference.ts` used to insist
259
+ * on exactly one because an editor export packs into one — that assumption is
260
+ * true of the reference and false of every candidate, and a renderer both sides
261
+ * share cannot hold it.
262
+ */
263
+ export function loadPosable(skeletonPath: string, atlasPath: string, atlasDir: string): Posable {
264
+ return posableFromText(readFileSync(skeletonPath, 'utf8'), readFileSync(atlasPath, 'utf8'), atlasDir);
265
+ }
266
+
267
+ /** Same, for artifacts held in memory rather than on disk. */
268
+ export function posableFromText(skeletonText: string, atlasText: string, atlasDir: string): Posable {
269
+ const atlas = new TextureAtlas(atlasText);
270
+ const pages = new Map<string, Plate>();
271
+ for (const page of atlas.pages) pages.set(page.name, readPlate(join(atlasDir, page.name)));
272
+ const data = new SkeletonJson(new AtlasAttachmentLoader(atlas)).readSkeletonData(JSON.parse(skeletonText));
273
+ return { data, pages };
274
+ }
275
+
276
+ /**
277
+ * Sample one animation at a fixed rate and collect the posed pieces per frame.
278
+ *
279
+ * The pose is driven through `AnimationState` rather than `Animation.apply`
280
+ * because that is the path a runtime actually takes, and 4.3's `Animation.apply`
281
+ * takes a `MixFrom` that only the state machine has any business choosing.
282
+ */
283
+ export function sampleAnimation(data: SkeletonData, name: string, fps: number): Frame[] {
284
+ const animation = data.findAnimation(name);
285
+ if (!animation) {
286
+ throw new Error(
287
+ `no animation "${name}" in this skeleton; it has [${data.animations.map((a) => a.name).join(', ') || 'none'}]`,
288
+ );
289
+ }
290
+ const skeleton = new Skeleton(data);
291
+ const state = new AnimationState(new AnimationStateData(data));
292
+ // Not looping: the last frame sits at the animation's duration, and a looping
293
+ // entry would wrap it back onto the first pose.
294
+ state.setAnimation(0, name, false);
295
+ skeleton.setupPose();
296
+
297
+ const step = 1 / fps;
298
+ const count = Math.round(animation.duration * fps);
299
+ const frames: Frame[] = [];
300
+ for (let i = 0; i <= count; i++) {
301
+ if (i > 0) {
302
+ state.update(step);
303
+ state.apply(skeleton);
304
+ skeleton.update(step);
305
+ skeleton.updateWorldTransform(Physics.update);
306
+ } else {
307
+ state.apply(skeleton);
308
+ skeleton.update(0);
309
+ skeleton.updateWorldTransform(Physics.reset);
310
+ }
311
+ frames.push({ index: i, time: i * step, pieces: piecesOf(skeleton) });
312
+ }
313
+ return frames;
314
+ }
315
+
316
+ /**
317
+ * The setup pose as a single frame — what a skeleton with **no animation at all**
318
+ * looks like.
319
+ *
320
+ * ⭐ Not a degenerate case to be tolerated: a static rig is a deliverable. The
321
+ * ladder's first rung ships one (`1-weight-and-mass`'s second export), and its
322
+ * whole content is the setup pose.
323
+ */
324
+ export function sampleSetupPose(data: SkeletonData): Frame[] {
325
+ const skeleton = new Skeleton(data);
326
+ skeleton.setupPose();
327
+ skeleton.update(0);
328
+ skeleton.updateWorldTransform(Physics.reset);
329
+ return [{ index: 0, time: 0, pieces: piecesOf(skeleton) }];
330
+ }
331
+
332
+ /**
333
+ * Every animation of one skeleton at one rate, keyed by the name its frames are
334
+ * filed under. A skeleton with no animation at all contributes its setup pose
335
+ * under `SETUP_POSE_DIR`.
336
+ */
337
+ export function sampleAll(data: SkeletonData, fps: number): Map<string, Frame[]> {
338
+ const out = new Map<string, Frame[]>();
339
+ if (data.animations.length === 0) out.set(SETUP_POSE_DIR, sampleSetupPose(data));
340
+ else for (const animation of data.animations) out.set(animation.name, sampleAnimation(data, animation.name, fps));
341
+ return out;
342
+ }
343
+
344
+ /**
345
+ * The posed drawables of one frame, in draw order.
346
+ *
347
+ * Regions and meshes take the same three steps — resolve the sequence index,
348
+ * ask `spine-core` for the world vertices, read the page UVs back off the same
349
+ * sequence — and differ only in which runtime call does step two. An attachment
350
+ * type that is neither is skipped rather than refused: a bounding box, a point
351
+ * and a clipping attachment are all things a rig legitimately carries and none
352
+ * of them draws a pixel.
353
+ */
354
+ export function piecesOf(skeleton: Skeleton): Piece[] {
355
+ const pieces: Piece[] = [];
356
+ for (const slot of skeleton.drawOrder.appliedPose) {
357
+ const pose = slot.appliedPose;
358
+ const attachment = pose.attachment;
359
+ if (!attachment) continue;
360
+ const isMesh = attachment instanceof MeshAttachment;
361
+ if (!isMesh && !(attachment instanceof RegionAttachment)) continue;
362
+
363
+ const index = attachment.sequence.resolveIndex(pose);
364
+ const region = attachment.sequence.regions[index];
365
+ if (!(region instanceof TextureAtlasRegion)) {
366
+ throw new Error(
367
+ `slot "${slot.data.name}" attachment "${attachment.name}" resolved to no atlas region; ` +
368
+ 'the attachment names a region the atlas does not have',
369
+ );
370
+ }
371
+ const colour = pose.color;
372
+ const own = attachment.color;
373
+ const tint: [number, number, number, number] = [
374
+ colour.r * own.r,
375
+ colour.g * own.g,
376
+ colour.b * own.b,
377
+ colour.a * own.a,
378
+ ];
379
+ const common = { tint, slot: slot.data.name, page: region.page.name };
380
+
381
+ if (isMesh) {
382
+ // `worldVerticesLength` is 2 per vertex whether or not the mesh is
383
+ // weighted — the weight runs live in `vertices`, not here — so this is the
384
+ // full output length and the whole mesh is computed in one call. Deform
385
+ // offsets, if the pose carries any, are applied inside it.
386
+ const world = new Array<number>(attachment.worldVerticesLength).fill(0);
387
+ attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, world, 0, 2);
388
+ pieces.push({ kind: 'mesh', ...common, world, uvs: attachment.sequence.getUVs(index), triangles: attachment.triangles });
389
+ continue;
390
+ }
391
+
392
+ const world = new Array<number>(8).fill(0);
393
+ attachment.computeWorldVertices(slot, attachment.getOffsets(pose), world, 0, 2);
394
+ pieces.push({ kind: 'region', ...common, world, uvs: attachment.sequence.getUVs(index) });
395
+ }
396
+ return pieces;
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // framing
401
+ // ---------------------------------------------------------------------------
402
+
403
+ /**
404
+ * The world-space box every posed vertex of these frames fits inside.
405
+ *
406
+ * `world.length` rather than a literal 8: a region contributes its four corners
407
+ * and a mesh every one of its vertices, and the loop does not need to know which
408
+ * it is holding.
409
+ */
410
+ export function unionBounds(frameSets: Iterable<Frame[]>): { minX: number; minY: number; maxX: number; maxY: number } {
411
+ let minX = Infinity;
412
+ let minY = Infinity;
413
+ let maxX = -Infinity;
414
+ let maxY = -Infinity;
415
+ for (const frames of frameSets) {
416
+ for (const frame of frames) {
417
+ for (const piece of frame.pieces) {
418
+ for (let i = 0; i < piece.world.length; i += 2) {
419
+ minX = Math.min(minX, piece.world[i]);
420
+ maxX = Math.max(maxX, piece.world[i]);
421
+ minY = Math.min(minY, piece.world[i + 1]);
422
+ maxY = Math.max(maxY, piece.world[i + 1]);
423
+ }
424
+ }
425
+ }
426
+ }
427
+ return { minX, minY, maxX, maxY };
428
+ }
429
+
430
+ /**
431
+ * The opaque sub-rectangle of one quad's region, in the quad's own `(s, t)`.
432
+ *
433
+ * `(0,0)` is the region's bottom-left corner and `(1,1)` its top-right, so a trim
434
+ * of `{0, 0, 1, 1}` is a region whose art fills it and anything smaller is the
435
+ * transparent margin the art was exported with.
436
+ */
437
+ export interface RegionTrim {
438
+ minS: number;
439
+ minT: number;
440
+ maxS: number;
441
+ maxT: number;
442
+ }
443
+
444
+ /**
445
+ * Where a quad's artwork actually is, as opposed to where its rectangle is.
446
+ *
447
+ * ⭐ This is what stops an invisible margin from being able to move anything. A
448
+ * region attachment's quad is the whole PNG, transparent border included, so two
449
+ * exports of the same drawing with different margins pose to different quads and
450
+ * frame themselves differently — which is how rung 5 reported MAE 39.00 for a rig
451
+ * whose every key was right (issue #34). Trimming to the opaque texels makes the
452
+ * box a property of the drawing.
453
+ *
454
+ * Alpha above zero rather than the rasteriser's coverage threshold, deliberately:
455
+ * this is the box that has to CONTAIN the drawing, and a box that is a texel too
456
+ * generous costs nothing while one that is a texel short clips.
457
+ *
458
+ * `cache` is keyed by page and region rectangle, because a scan per quad per frame
459
+ * would be a scan per quad per frame.
460
+ */
461
+ export function regionTrim(page: Plate, quad: Quad, cache: Map<string, RegionTrim | null>): RegionTrim | null {
462
+ const [ubr, vbr, ubl, vbl, uul, vul] = [quad.uvs[0], quad.uvs[1], quad.uvs[2], quad.uvs[3], quad.uvs[4], quad.uvs[5]];
463
+ const key = `${quad.page}|${ubr},${vbr},${ubl},${vbl},${uul},${vul}`;
464
+ const seen = cache.get(key);
465
+ if (seen !== undefined) return seen;
466
+
467
+ const ox = ubl * page.width;
468
+ const oy = vbl * page.height;
469
+ const ex = [(ubr - ubl) * page.width, (vbr - vbl) * page.height];
470
+ const ey = [(uul - ubl) * page.width, (vul - vbl) * page.height];
471
+ const det = ex[0] * ey[1] - ex[1] * ey[0];
472
+ if (Math.abs(det) < 1e-9) {
473
+ cache.set(key, null);
474
+ return null;
475
+ }
476
+ const corners = [
477
+ [ox, oy],
478
+ [ox + ex[0], oy + ex[1]],
479
+ [ox + ey[0], oy + ey[1]],
480
+ [ox + ex[0] + ey[0], oy + ex[1] + ey[1]],
481
+ ];
482
+ const x0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
483
+ const x1 = Math.min(page.width - 1, Math.ceil(Math.max(...corners.map((c) => c[0]))));
484
+ const y0 = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
485
+ const y1 = Math.min(page.height - 1, Math.ceil(Math.max(...corners.map((c) => c[1]))));
486
+
487
+ let minS = Infinity;
488
+ let minT = Infinity;
489
+ let maxS = -Infinity;
490
+ let maxT = -Infinity;
491
+ for (let y = y0; y <= y1; y++) {
492
+ for (let x = x0; x <= x1; x++) {
493
+ if (page.get(x, y)[3] === 0) continue;
494
+ const rx = x + 0.5 - ox;
495
+ const ry = y + 0.5 - oy;
496
+ const s = (rx * ey[1] - ry * ey[0]) / det;
497
+ const t = (ex[0] * ry - ex[1] * rx) / det;
498
+ if (s < 0 || s > 1 || t < 0 || t > 1) continue;
499
+ if (s < minS) minS = s;
500
+ if (s > maxS) maxS = s;
501
+ if (t < minT) minT = t;
502
+ if (t > maxT) maxT = t;
503
+ }
504
+ }
505
+ const trim = Number.isFinite(minS) ? { minS, minT, maxS, maxT } : null;
506
+ cache.set(key, trim);
507
+ return trim;
508
+ }
509
+
510
+ /**
511
+ * The world box every piece's **artwork** fits inside, over these frames.
512
+ *
513
+ * The same union as `unionBounds`, taken over the trimmed rectangles instead of
514
+ * the quads. It is a starting box for `check`'s framing and nothing more — the
515
+ * framing itself is fitted on rendered pixels — but the start has to be free of
516
+ * transparent margins too, or the path the fit takes still depends on them.
517
+ *
518
+ * ⚠️ **A mesh contributes its raw vertices and is not trimmed.** The trim exists
519
+ * because a region attachment's quad is the whole PNG, transparent border and
520
+ * all, so its corners sit where no pixel is. A mesh's hull is authored *onto the
521
+ * drawing* — that is what makes it a mesh — so its vertices already are where the
522
+ * artwork is, and there is no rectangle to invert a margin out of. Passing a
523
+ * triangle fan through the rectangle trim would not be a better estimate of the
524
+ * same box; it would be a different box, computed from a rectangle the mesh does
525
+ * not have.
526
+ */
527
+ export function trimmedUnionBounds(
528
+ frameSets: Iterable<Frame[]>,
529
+ pages: Map<string, Plate>,
530
+ ): { minX: number; minY: number; maxX: number; maxY: number } {
531
+ const cache = new Map<string, RegionTrim | null>();
532
+ let minX = Infinity;
533
+ let minY = Infinity;
534
+ let maxX = -Infinity;
535
+ let maxY = -Infinity;
536
+ const see = (x: number, y: number): void => {
537
+ if (x < minX) minX = x;
538
+ if (x > maxX) maxX = x;
539
+ if (y < minY) minY = y;
540
+ if (y > maxY) maxY = y;
541
+ };
542
+ for (const frames of frameSets) {
543
+ for (const frame of frames) {
544
+ for (const piece of frame.pieces) {
545
+ if (piece.kind === 'mesh') {
546
+ for (let i = 0; i < piece.world.length; i += 2) see(piece.world[i], piece.world[i + 1]);
547
+ continue;
548
+ }
549
+ const quad = piece;
550
+ const [brx, bry, blx, bly, ulx, uly] = quad.world;
551
+ const trim = regionTrim(pageFor(pages, quad), quad, cache);
552
+ if (!trim) {
553
+ for (let i = 0; i < 8; i += 2) see(quad.world[i], quad.world[i + 1]);
554
+ continue;
555
+ }
556
+ const ex = [brx - blx, bry - bly];
557
+ const ey = [ulx - blx, uly - bly];
558
+ for (const [s, t] of [
559
+ [trim.minS, trim.minT],
560
+ [trim.maxS, trim.minT],
561
+ [trim.minS, trim.maxT],
562
+ [trim.maxS, trim.maxT],
563
+ ]) {
564
+ see(blx + s * ex[0] + t * ey[0], bly + s * ex[1] + t * ey[1]);
565
+ }
566
+ }
567
+ }
568
+ }
569
+ return { minX, minY, maxX, maxY };
570
+ }
571
+
572
+ /**
573
+ * The viewport a skeleton is framed to: its union box at `FRAMING_FPS`, padded,
574
+ * scaled so the long side is `maxSide` pixels.
575
+ *
576
+ * Measuring the box densely and once makes the framing a property of the SHOT,
577
+ * so every rate of one skeleton lands on the same pixels.
578
+ */
579
+ export function framingViewport(data: SkeletonData, maxSide: number): Viewport | null {
580
+ const sets =
581
+ data.animations.length === 0
582
+ ? [sampleSetupPose(data)]
583
+ : data.animations.map((a) => sampleAnimation(data, a.name, FRAMING_FPS));
584
+ const box = unionBounds(sets);
585
+ if (!Number.isFinite(box.minX)) return null;
586
+ const pad = Math.max(box.maxX - box.minX, box.maxY - box.minY) * PAD;
587
+ return viewportFor(box.minX - pad, box.minY - pad, box.maxX + pad, box.maxY + pad, maxSide);
588
+ }
589
+
590
+ /** A viewport over an explicit world box, scaled so its long side is `maxSide`. */
591
+ export function viewportFor(minX: number, minY: number, maxX: number, maxY: number, maxSide: number): Viewport {
592
+ const scale = maxSide / Math.max(maxX - minX, maxY - minY);
593
+ return {
594
+ minX,
595
+ minY,
596
+ maxX,
597
+ maxY,
598
+ scale,
599
+ width: Math.max(1, Math.round((maxX - minX) * scale)),
600
+ height: Math.max(1, Math.round((maxY - minY) * scale)),
601
+ };
602
+ }
603
+
604
+ /**
605
+ * A viewport over an explicit world box whose pixel size is already known.
606
+ *
607
+ * This is the shape `check` needs: the frames on disk fix the pixel size, and
608
+ * re-deriving it from the box would round to a different integer and silently
609
+ * shift every measurement by up to half a pixel.
610
+ */
611
+ export function viewportOfSize(
612
+ minX: number,
613
+ minY: number,
614
+ width: number,
615
+ height: number,
616
+ scale: number,
617
+ pixelWidth: number,
618
+ pixelHeight: number,
619
+ ): Viewport {
620
+ return { minX, minY, maxX: minX + width, maxY: minY + height, scale, width: pixelWidth, height: pixelHeight };
621
+ }
622
+
623
+ /** World (y up) to frame pixels (y down). The only place that conversion lives. */
624
+ export function projector(v: Viewport): (wx: number, wy: number) => [number, number] {
625
+ return (wx, wy) => [(wx - v.minX) * v.scale, (v.maxY - wy) * v.scale];
626
+ }
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // rasterising
630
+ // ---------------------------------------------------------------------------
631
+
632
+ /**
633
+ * Walk the destination pixels one affine quad covers, sampling the page.
634
+ *
635
+ * The quad is an affine image of the region's rectangle, so a destination pixel
636
+ * maps back to a (s, t) inside it by inverting one 2x2 — no perspective divide,
637
+ * no triangle split. `emit` is called for every covered pixel whose composited
638
+ * alpha clears the coverage threshold, which is what makes "draw it" and
639
+ * "measure where it landed" the same traversal rather than two that can drift.
640
+ */
641
+ export function rasteriseQuad(
642
+ page: Plate,
643
+ quad: Quad,
644
+ project: (wx: number, wy: number) => [number, number],
645
+ clip: { width: number; height: number },
646
+ emit: (px: number, py: number, r: number, g: number, b: number, a: number) => void,
647
+ ): void {
648
+ // spine-core's region order is br, bl, ul, ur.
649
+ const [brx, bry, blx, bly, ulx, uly] = quad.world;
650
+ const bl = project(blx, bly);
651
+ const br = project(brx, bry);
652
+ const ul = project(ulx, uly);
653
+ const ex = [br[0] - bl[0], br[1] - bl[1]];
654
+ const ey = [ul[0] - bl[0], ul[1] - bl[1]];
655
+ const det = ex[0] * ey[1] - ex[1] * ey[0];
656
+ if (Math.abs(det) < 1e-9) return; // degenerate: zero scale, nothing to draw
657
+ const [ubr, vbr, ubl, vbl, uul, vul] = [quad.uvs[0], quad.uvs[1], quad.uvs[2], quad.uvs[3], quad.uvs[4], quad.uvs[5]];
658
+
659
+ const corners = [bl, br, ul, [br[0] + ey[0], br[1] + ey[1]]];
660
+ const minX = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[0]))));
661
+ const maxX = Math.min(clip.width - 1, Math.ceil(Math.max(...corners.map((c) => c[0]))));
662
+ const minY = Math.max(0, Math.floor(Math.min(...corners.map((c) => c[1]))));
663
+ const maxY = Math.min(clip.height - 1, Math.ceil(Math.max(...corners.map((c) => c[1]))));
664
+
665
+ for (let py = minY; py <= maxY; py++) {
666
+ for (let px = minX; px <= maxX; px++) {
667
+ const rx = px + 0.5 - bl[0];
668
+ const ry = py + 0.5 - bl[1];
669
+ const s = (rx * ey[1] - ry * ey[0]) / det;
670
+ const t = (ex[0] * ry - ex[1] * rx) / det;
671
+ if (s < 0 || s > 1 || t < 0 || t > 1) continue;
672
+ const u = ubl + s * (ubr - ubl) + t * (uul - ubl);
673
+ const v = vbl + s * (vbr - vbl) + t * (vul - vbl);
674
+ const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
675
+ const alpha = sample[3] * quad.tint[3];
676
+ if (alpha <= 0.5) continue;
677
+ emit(
678
+ px,
679
+ py,
680
+ Math.round(sample[0] * quad.tint[0]),
681
+ Math.round(sample[1] * quad.tint[1]),
682
+ Math.round(sample[2] * quad.tint[2]),
683
+ Math.round(alpha),
684
+ );
685
+ }
686
+ }
687
+ }
688
+
689
+ /** A destination pixel and the straight-alpha colour a piece put there. */
690
+ export type EmitPixel = (px: number, py: number, r: number, g: number, b: number, a: number) => void;
691
+
692
+ /**
693
+ * Is this edge a top or a left one, for the winding `rasteriseMesh` normalises to?
694
+ *
695
+ * Derived rather than copied, because the answer depends on the sign convention
696
+ * of the edge function and the direction of y. With `edge(p) = dx·(py−y0) −
697
+ * dy·(px−x0)` and y pointing **down**, the triangle `(0,0) → (1,0) → (0,1)` has
698
+ * positive area, and its horizontal edge `(0,0) → (1,0)` — `dx > 0`, `dy = 0` —
699
+ * is the one along its top. Its `(0,1) → (0,0)` edge — `dy < 0`, going up — is
700
+ * the one down its left.
701
+ *
702
+ * What actually makes the rule watertight needs neither of those facts: the two
703
+ * triangles sharing an edge traverse it in opposite directions, so `dy < 0` holds
704
+ * for exactly one of them, and when `dy` is 0 for both, `dx > 0` holds for
705
+ * exactly one. Every shared edge is therefore claimed once. Getting the
706
+ * orientation right on top of that is what keeps the classic meaning — a pixel
707
+ * centre on a boundary belongs to the triangle below-right of it.
708
+ */
709
+ function isTopLeftEdge(dx: number, dy: number): boolean {
710
+ return dy < 0 || (dy === 0 && dx > 0);
711
+ }
712
+
713
+ /**
714
+ * Walk the destination pixels one posed mesh covers, sampling the page.
715
+ *
716
+ * Each triangle is filled independently with barycentric UV interpolation and no
717
+ * perspective divide — a Spine mesh is a flat 2D deformation, so its UVs are
718
+ * affine in screen space and there is no `w` to divide by. The winding is
719
+ * normalised per triangle (a mesh's triangles are not guaranteed to agree, and a
720
+ * bone with negative scale flips them all anyway), and the top-left rule then
721
+ * makes every interior edge belong to exactly one of the two triangles that
722
+ * share it.
723
+ *
724
+ * `emit` has the same contract as `rasteriseQuad`'s — every covered pixel whose
725
+ * composited alpha clears the same 0.5 threshold — so "draw it" and "measure
726
+ * where it landed" stay one traversal for meshes exactly as they are for regions.
727
+ */
728
+ export function rasteriseMesh(
729
+ page: Plate,
730
+ mesh: Mesh,
731
+ project: (wx: number, wy: number) => [number, number],
732
+ clip: { width: number; height: number },
733
+ emit: EmitPixel,
734
+ ): void {
735
+ const count = mesh.world.length / 2;
736
+ // Project once per vertex, not once per triangle: an interior vertex of a
737
+ // 40-vertex hull belongs to half a dozen triangles, and projecting it six times
738
+ // invites six answers the moment anything about `project` stops being exact.
739
+ const px = new Float64Array(count);
740
+ const py = new Float64Array(count);
741
+ for (let i = 0; i < count; i++) {
742
+ const [x, y] = project(mesh.world[i * 2], mesh.world[i * 2 + 1]);
743
+ px[i] = x;
744
+ py[i] = y;
745
+ }
746
+
747
+ for (let t = 0; t + 2 < mesh.triangles.length; t += 3) {
748
+ let i0 = mesh.triangles[t];
749
+ let i1 = mesh.triangles[t + 1];
750
+ const i2 = mesh.triangles[t + 2];
751
+ let area = (px[i1] - px[i0]) * (py[i2] - py[i0]) - (py[i1] - py[i0]) * (px[i2] - px[i0]);
752
+ if (area === 0) continue; // degenerate: a zero-height triangle covers nothing
753
+ if (area < 0) {
754
+ const swap = i0;
755
+ i0 = i1;
756
+ i1 = swap;
757
+ area = -area;
758
+ }
759
+
760
+ const x0 = px[i0];
761
+ const y0 = py[i0];
762
+ const x1 = px[i1];
763
+ const y1 = py[i1];
764
+ const x2 = px[i2];
765
+ const y2 = py[i2];
766
+ const minX = Math.max(0, Math.floor(Math.min(x0, x1, x2)));
767
+ const maxX = Math.min(clip.width - 1, Math.ceil(Math.max(x0, x1, x2)));
768
+ const minY = Math.max(0, Math.floor(Math.min(y0, y1, y2)));
769
+ const maxY = Math.min(clip.height - 1, Math.ceil(Math.max(y0, y1, y2)));
770
+ if (maxX < minX || maxY < minY) continue;
771
+
772
+ // Edge `k` is the one opposite vertex `k`, so its edge function IS the
773
+ // unnormalised barycentric weight of that vertex.
774
+ const topLeft0 = isTopLeftEdge(x2 - x1, y2 - y1);
775
+ const topLeft1 = isTopLeftEdge(x0 - x2, y0 - y2);
776
+ const topLeft2 = isTopLeftEdge(x1 - x0, y1 - y0);
777
+
778
+ const u0 = mesh.uvs[i0 * 2];
779
+ const v0 = mesh.uvs[i0 * 2 + 1];
780
+ const u1 = mesh.uvs[i1 * 2];
781
+ const v1 = mesh.uvs[i1 * 2 + 1];
782
+ const u2 = mesh.uvs[i2 * 2];
783
+ const v2 = mesh.uvs[i2 * 2 + 1];
784
+
785
+ for (let y = minY; y <= maxY; y++) {
786
+ const sy = y + 0.5;
787
+ for (let x = minX; x <= maxX; x++) {
788
+ const sx = x + 0.5;
789
+ const w0 = (x2 - x1) * (sy - y1) - (y2 - y1) * (sx - x1);
790
+ if (topLeft0 ? w0 < 0 : w0 <= 0) continue;
791
+ const w1 = (x0 - x2) * (sy - y2) - (y0 - y2) * (sx - x2);
792
+ if (topLeft1 ? w1 < 0 : w1 <= 0) continue;
793
+ const w2 = (x1 - x0) * (sy - y0) - (y1 - y0) * (sx - x0);
794
+ if (topLeft2 ? w2 < 0 : w2 <= 0) continue;
795
+
796
+ const b0 = w0 / area;
797
+ const b1 = w1 / area;
798
+ const b2 = w2 / area;
799
+ const u = b0 * u0 + b1 * u1 + b2 * u2;
800
+ const v = b0 * v0 + b1 * v1 + b2 * v2;
801
+ const sample = bilinear(page, u * page.width - 0.5, v * page.height - 0.5);
802
+ const alpha = sample[3] * mesh.tint[3];
803
+ if (alpha <= 0.5) continue;
804
+ emit(
805
+ x,
806
+ y,
807
+ Math.round(sample[0] * mesh.tint[0]),
808
+ Math.round(sample[1] * mesh.tint[1]),
809
+ Math.round(sample[2] * mesh.tint[2]),
810
+ Math.round(alpha),
811
+ );
812
+ }
813
+ }
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Rasterise whichever shape this piece is.
819
+ *
820
+ * ⭐ Every caller that used to reach for `rasteriseQuad` goes through here, so
821
+ * "what counts as a covered pixel" has one definition for both shapes — which is
822
+ * what lets `frameGeometry`, the framing box and the drawn frame agree about a
823
+ * mesh without any of them knowing what a triangle is.
824
+ */
825
+ export function rasterisePiece(
826
+ page: Plate,
827
+ piece: Piece,
828
+ project: (wx: number, wy: number) => [number, number],
829
+ clip: { width: number; height: number },
830
+ emit: EmitPixel,
831
+ ): void {
832
+ if (piece.kind === 'mesh') rasteriseMesh(page, piece, project, clip, emit);
833
+ else rasteriseQuad(page, piece, project, clip, emit);
834
+ }
835
+
836
+ /** Blit one piece onto the plate, source-over. */
837
+ export function blitPiece(
838
+ dst: Plate,
839
+ page: Plate,
840
+ piece: Piece,
841
+ project: (wx: number, wy: number) => [number, number],
842
+ ): void {
843
+ rasterisePiece(page, piece, project, dst, (px, py, r, g, b, a) => dst.blend(px, py, [r, g, b, a]));
844
+ }
845
+
846
+ export function bilinear(page: Plate, x: number, y: number): [number, number, number, number] {
847
+ const x0 = Math.floor(x);
848
+ const y0 = Math.floor(y);
849
+ const fx = x - x0;
850
+ const fy = y - y0;
851
+ const at = (ix: number, iy: number): RGBA => {
852
+ const cx = Math.max(0, Math.min(page.width - 1, ix));
853
+ const cy = Math.max(0, Math.min(page.height - 1, iy));
854
+ return page.get(cx, cy);
855
+ };
856
+ const c00 = at(x0, y0);
857
+ const c10 = at(x0 + 1, y0);
858
+ const c01 = at(x0, y0 + 1);
859
+ const c11 = at(x0 + 1, y0 + 1);
860
+ const out: [number, number, number, number] = [0, 0, 0, 0];
861
+ for (let c = 0; c < 4; c++) {
862
+ const top = c00[c] + (c10[c] - c00[c]) * fx;
863
+ const bottom = c01[c] + (c11[c] - c01[c]) * fx;
864
+ out[c] = top + (bottom - top) * fy;
865
+ }
866
+ return out;
867
+ }
868
+
869
+ export function fill(plate: Plate, colour: RGBA): void {
870
+ for (let y = 0; y < plate.height; y++) for (let x = 0; x < plate.width; x++) plate.set(x, y, colour);
871
+ }
872
+
873
+ /** Look a page up by name, with a failure that names what the atlas did declare. */
874
+ export function pageFor(pages: Map<string, Plate>, piece: Piece): Plate {
875
+ const page = pages.get(piece.page);
876
+ if (!page) {
877
+ throw new Error(
878
+ `slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`,
879
+ );
880
+ }
881
+ return page;
882
+ }
883
+
884
+ /** One frame, composited over `background`, at the viewport's pixel size. */
885
+ export function renderFrame(frame: Frame, pages: Map<string, Plate>, viewport: Viewport, background: RGBA): Plate {
886
+ const plate = new Plate(viewport.width, viewport.height);
887
+ fill(plate, background);
888
+ const project = projector(viewport);
889
+ for (const piece of frame.pieces) blitPiece(plate, pageFor(pages, piece), piece, project);
890
+ return plate;
891
+ }
892
+
893
+ /** Where one thing landed in a frame, in frame pixels. */
894
+ export interface Footprint {
895
+ /** Alpha-weighted count of covered pixels. 0 means nothing was drawn. */
896
+ pixels: number;
897
+ cx: number;
898
+ cy: number;
899
+ minX: number;
900
+ minY: number;
901
+ maxX: number;
902
+ maxY: number;
903
+ }
904
+
905
+ export const EMPTY_FOOTPRINT: Footprint = { pixels: 0, cx: 0, cy: 0, minX: 0, minY: 0, maxX: 0, maxY: 0 };
906
+
907
+ /** Where a frame's pixels went: the coverage mask, and each slot's own footprint. */
908
+ export interface FrameGeometry {
909
+ /** 1 where any piece drew, in `viewport.width * viewport.height` row-major order. */
910
+ coverage: Uint8Array;
911
+ footprints: Map<string, Footprint>;
912
+ }
913
+
914
+ /**
915
+ * Rasterise one frame for measurement rather than for looking at: which pixels
916
+ * it covers, and where each slot landed.
917
+ *
918
+ * ⚠️ A slot's footprint is measured on the pixels **that slot draws**, ignoring
919
+ * what is drawn over it. That is deliberate. A slot hidden behind another still
920
+ * has a position, and it is the position the rig gives it; measuring it on the
921
+ * composite would report the occluder's geometry instead and call the rig wrong
922
+ * for being covered up. What the composite costs is on the reference side, where
923
+ * an occluded part merges into its occluder's component — and that is what the
924
+ * matcher reports as ambiguity rather than as drift.
925
+ */
926
+ export function frameGeometry(frame: Frame, pages: Map<string, Plate>, viewport: Viewport): FrameGeometry {
927
+ const coverage = new Uint8Array(viewport.width * viewport.height);
928
+ const footprints = new Map<string, Footprint>();
929
+ const project = projector(viewport);
930
+ for (const piece of frame.pieces) {
931
+ let weight = 0;
932
+ let sx = 0;
933
+ let sy = 0;
934
+ let minX = Infinity;
935
+ let minY = Infinity;
936
+ let maxX = -Infinity;
937
+ let maxY = -Infinity;
938
+ rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, _r, _g, _b, a) => {
939
+ coverage[py * viewport.width + px] = 1;
940
+ const w = a / 255;
941
+ weight += w;
942
+ sx += (px + 0.5) * w;
943
+ sy += (py + 0.5) * w;
944
+ if (px < minX) minX = px;
945
+ if (px > maxX) maxX = px;
946
+ if (py < minY) minY = py;
947
+ if (py > maxY) maxY = py;
948
+ });
949
+ const previous = footprints.get(piece.slot);
950
+ const here: Footprint =
951
+ weight === 0
952
+ ? EMPTY_FOOTPRINT
953
+ : { pixels: weight, cx: sx / weight, cy: sy / weight, minX, minY, maxX: maxX + 1, maxY: maxY + 1 };
954
+ // A slot shows one attachment at a time, so this only merges when a caller
955
+ // hands us a frame with two pieces on one slot; merging is still the honest
956
+ // answer, and it keeps the map keyed by slot the way the report reads it.
957
+ footprints.set(piece.slot, previous && previous.pixels > 0 ? mergeFootprints(previous, here) : here);
958
+ }
959
+ return { coverage, footprints };
960
+ }
961
+
962
+ function mergeFootprints(a: Footprint, b: Footprint): Footprint {
963
+ if (b.pixels === 0) return a;
964
+ const pixels = a.pixels + b.pixels;
965
+ return {
966
+ pixels,
967
+ cx: (a.cx * a.pixels + b.cx * b.pixels) / pixels,
968
+ cy: (a.cy * a.pixels + b.cy * b.pixels) / pixels,
969
+ minX: Math.min(a.minX, b.minX),
970
+ minY: Math.min(a.minY, b.minY),
971
+ maxX: Math.max(a.maxX, b.maxX),
972
+ maxY: Math.max(a.maxY, b.maxY),
973
+ };
974
+ }