@rig-c/core 2.58.1 → 2.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mesh.ts CHANGED
@@ -2168,6 +2168,11 @@ function cellIslands(cells: Uint8Array, nx: number, ny: number): Array<Array<[nu
2168
2168
 
2169
2169
  /**
2170
2170
  * The lattice over one part's alpha: cells, one loop, triangles, outline first.
2171
+ * Promised through `rig-c/mesh` (issue #1450). `mask.alpha` is one byte per
2172
+ * source pixel, `threshold` is an alpha byte in 1..255, and `cell` is a whole
2173
+ * number of part-local pixels. Returns `SegmentsLattice`: points in part-local
2174
+ * pixels (y down), dimensionless UVs rounded to six decimals, triangle indices,
2175
+ * the outline vertex count and cell/pass/island counts. No weights are built.
2171
2176
  *
2172
2177
  * Refuses a part that keeps no cell and a cell that is not a whole number of
2173
2178
  * pixels of at least 1; everything else about the geometry follows from the art.
@@ -2377,6 +2382,10 @@ export interface SegmentsFalloff {
2377
2382
 
2378
2383
  /**
2379
2384
  * Distance from `p` to the segment `a -> b`, a zero-length segment being its point.
2385
+ * Promised through `rig-c/mesh` (issue #1450). All three points must use the
2386
+ * same coordinate frame and length unit; the returned non-negative distance
2387
+ * uses that unit. The segments compiler supplies setup-world points in part
2388
+ * pixels, measured through its exact setup frame.
2380
2389
  *
2381
2390
  * `sqrt(dx*dx + dy*dy)` rather than `Math.hypot`, whose last bit can differ:
2382
2391
  * a weight is written on a 6-decimal grid, and that is where one ulp becomes a
@@ -2507,6 +2516,14 @@ export type InfluenceShares =
2507
2516
 
2508
2517
  /**
2509
2518
  * The shares on one vertex under an `influence` block — see the section header.
2519
+ * The three-argument call is promised through `rig-c/mesh` (issue #1450). `p`
2520
+ * and every segment endpoint share one frame; `radius` uses that frame's length
2521
+ * unit. `bone` indexes the caller's distinct mesh-bone list and `entry` indexes
2522
+ * its authored segment entries. Returned mesh weights use `bone: 'control'`
2523
+ * and `control` for that same distinct-bone index. Returns weights closing at one
2524
+ * on the six-decimal grid, with the pre-cap in-reach bone count; or the nearest
2525
+ * bone and its distance in the input unit when no link reaches the vertex.
2526
+ * The compiler uses exact setup-world positions measured in part pixels.
2510
2527
  * `plant` exists for the selftest's negative controls: each key switches one
2511
2528
  * step off, so a control can show its own check going red on weights that
2512
2529
  * break exactly that step. A build never passes it.
@@ -238,7 +238,7 @@ export function texelSize(piece: Piece, vertices: ArrayLike<number>, page: Plate
238
238
  }
239
239
 
240
240
  /** The cells `piece` draws at half opacity or more over `viewport`, row major, 1 where drawn. */
241
- function drawnCells(piece: Piece, page: Plate, viewport: Viewport): Uint8Array {
241
+ export function drawnCells(piece: Piece, page: Plate, viewport: Viewport): Uint8Array {
242
242
  const cells = new Uint8Array(viewport.width * viewport.height);
243
243
  rasterisePiece(page, piece, projector(viewport), viewport, (px, py, _r, _g, _b, a) => {
244
244
  if (a >= DRAWN_ALPHA) cells[py * viewport.width + px] = 1;
@@ -251,7 +251,7 @@ function drawnCells(piece: Piece, page: Plate, viewport: Viewport): Uint8Array {
251
251
  * renderer's own traversal over a one-cell grid centred on the point, so the
252
252
  * test is the frame's pixel test and nothing re-derived beside it.
253
253
  */
254
- function drawsAt(piece: Piece, page: Plate, x: number, y: number, cell: number): boolean {
254
+ export function drawsAt(piece: Piece, page: Plate, x: number, y: number, cell: number): boolean {
255
255
  const viewport: Viewport = { minX: x - cell / 2, minY: y - cell / 2, maxX: x + cell / 2, maxY: y + cell / 2, scale: 1 / cell, width: 1, height: 1 };
256
256
  let drawn = false;
257
257
  rasterisePiece(page, piece, projector(viewport), viewport, (_px, _py, _r, _g, _b, a) => {
@@ -427,7 +427,7 @@ function runsAt(
427
427
  const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
428
428
 
429
429
  /** Whether two vertex lists are the same numbers — a piece drawn from its attachment whole, not cut by a clip. */
430
- function sameVertices(a: ArrayLike<number>, b: ArrayLike<number>): boolean {
430
+ export function sameVertices(a: ArrayLike<number>, b: ArrayLike<number>): boolean {
431
431
  if (a.length !== b.length) return false;
432
432
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
433
433
  return true;
package/src/seam.ts ADDED
@@ -0,0 +1,576 @@
1
+ /**
2
+ * Where two slots that meet stop meeting — the reading behind
3
+ * `rigc render --seam <a>:<b>[,…]` (issue #1456).
4
+ *
5
+ * ⭐ **A reading, not a gate**, as `--folds` and `--pass-through` are. A cut rig
6
+ * can tear in motion: two pieces that meet at a joint pull apart and the
7
+ * background shows between them, or one piece swings clear of the piece it was
8
+ * painted to tuck under and the overlap band it carries shows where nothing was.
9
+ * Either can be intended, so this names the frames and decides nothing — it
10
+ * never changes an exit code.
11
+ *
12
+ * ## The frame of reference
13
+ *
14
+ * A seam cannot be read in the world: a limb that swings moves both of its pieces
15
+ * away from where they were at the setup pose, and that is motion, not a tear.
16
+ * It is read **from each piece's own point of view**. A world point `q` of a
17
+ * frame is carried back into a piece's setup pose by the affine map of that
18
+ * piece's triangle holding `q` as the frame poses it — so the point a piece
19
+ * draws at `q` at the frame is the point it drew at the carried-back position at
20
+ * the setup pose, by construction. Outside the piece's mesh the map of the
21
+ * triangle `q` is least outside of (the largest smallest barycentric weight,
22
+ * the lowest index on a tie) is extended to it: exact for a region and for any
23
+ * mesh one bone moves, and the nearest affine piece of a skinned one.
24
+ *
25
+ * Two pieces that move together carry `q` back to one point, so whatever the
26
+ * pose does to the pair as a whole reads nothing.
27
+ *
28
+ * ## What the reading counts
29
+ *
30
+ * Every number is a cell of one world grid per frame: cells one texel of the
31
+ * coarser of the two drawings at the setup pose (`texelSize`, the step
32
+ * `--pass-through` already steps by), on multiples of that texel from the world
33
+ * origin. A cell is drawn by a piece when the renderer's own traversal paints
34
+ * its centre at `DRAWN_ALPHA` or more — the half-opacity line the pass-through
35
+ * reading draws outlines at; no other threshold enters.
36
+ *
37
+ * - **The band** is the cells both drawings draw at the setup pose — where the
38
+ * two were painted to overlap. A frame where the band is empty has no seam to
39
+ * read, and a pair whose band is empty on every frame both draw is a **SKIP**,
40
+ * never a pass. The band has no width of its own: it is the overlap the art
41
+ * states, dilated by nothing.
42
+ * - **gap** — background appears in the joint. The band's cells, carried to the
43
+ * frame by each piece (its centre through that piece's setup-pose triangle),
44
+ * span a convex hull: the region the joint occupies now, as both pieces place
45
+ * it. A cell inside that hull is a gap cell when neither piece draws it at the
46
+ * frame and at least one piece, carrying it back, finds it drawn by either
47
+ * drawing at the setup pose — the two no longer cover what they covered
48
+ * together. The hull is the joint's own extent, so a piece's far end swinging
49
+ * away from where it was is not read; the rest test is what keeps a band that
50
+ * is concave at the setup pose from reading its own notch on every frame.
51
+ * - **ghost** — alpha appears where the rest composite had none. A cell one
52
+ * piece draws at the frame and the other does not is a ghost cell when the
53
+ * drawing piece, carrying it back, lands it in the band, and the other piece,
54
+ * carrying it back, finds neither drawing there at the setup pose — the
55
+ * overlap band swung clear of its partner.
56
+ *
57
+ * Each frame's figures are the cell counts (`px`), the cell size, their area,
58
+ * and the world box of the counted cells (each cell's whole square).
59
+ *
60
+ * Known reach, stated rather than discovered: a piece folded right back over its
61
+ * partner carries the band across the region it vacated, so a hull that wide
62
+ * reads the vacated cells its partner's view still finds drawn; a mesh whose
63
+ * vertices stretch the art the band lies in reads the stretched band, which is
64
+ * what it draws. Draw order does not enter: a cell is covered or bare whichever
65
+ * piece is in front.
66
+ *
67
+ * Pure: no clock, no randomness, no runtime. The pieces, the attachments and the
68
+ * rest table are handed in by whichever poser drew the frames.
69
+ */
70
+
71
+ import type { Plate } from '../tools/plate.ts';
72
+ import { indexRuns } from './folds.ts';
73
+ import { DRAWN_ALPHA, drawnCells, drawsAt, sameVertices, texelSize } from './passthrough.ts';
74
+ import type { AttachmentPose, AttachmentRest, Piece, Viewport } from './render_shared.ts';
75
+
76
+ /** The reading's file name inside an animation's frame directory, and its format tag. */
77
+ export const SEAM_FILE = 'seam.json';
78
+ export const SEAM_SPEC = 'rigc-seam/1';
79
+
80
+ /** One pair as the flag names it. The reading is symmetric: `a:b` and `b:a` are one pair. */
81
+ export interface SeamPair {
82
+ a: string;
83
+ b: string;
84
+ }
85
+
86
+ /** A malformed `--seam` value, or a pair the poses cannot be read for — said by name. */
87
+ export class SeamError extends Error {}
88
+
89
+ /**
90
+ * `--seam`'s value — `<slot>:<slot>[,<slot>:<slot>…]` — read against the
91
+ * skeleton's slots, or refused naming the entry: an empty entry, one without
92
+ * exactly one `:`, an empty name, a slot the skeleton does not have (with the
93
+ * slots it does), a slot paired with itself (a seam is between two drawings), and
94
+ * a pair named twice in either order (the reading is symmetric, so `b:a` is the
95
+ * pair `a:b` again).
96
+ */
97
+ export function readSeamPairs(raw: string, slots: readonly string[]): SeamPair[] {
98
+ const form = '<slot>:<slot>[,<slot>:<slot>…]';
99
+ const pairs: SeamPair[] = [];
100
+ const entries = raw.split(',');
101
+ for (let i = 0; i < entries.length; i++) {
102
+ const entry = entries[i].trim();
103
+ if (entry === '') throw new SeamError(`--seam ${JSON.stringify(raw)}: entry ${i} is empty — it takes ${form}`);
104
+ const parts = entry.split(':');
105
+ if (parts.length !== 2) throw new SeamError(`--seam: entry ${i} ${JSON.stringify(entry)} has ${parts.length - 1} ":" — it takes ${form}, one ":" per pair`);
106
+ const [a, b] = parts.map((p) => p.trim());
107
+ for (const [role, name] of [['first', a], ['second', b]] as const) {
108
+ if (name === '') throw new SeamError(`--seam: entry ${i} ${JSON.stringify(entry)} names no ${role} slot — it takes ${form}`);
109
+ if (!slots.includes(name)) throw new SeamError(`--seam: entry ${i} names slot ${JSON.stringify(name)}, which this skeleton does not have; its slots are [${slots.join(', ')}]`);
110
+ }
111
+ if (a === b) throw new SeamError(`--seam: entry ${i} pairs slot "${a}" with itself — a seam is between two slots' drawings`);
112
+ const again = pairs.findIndex((p) => (p.a === a && p.b === b) || (p.a === b && p.b === a));
113
+ if (again >= 0) throw new SeamError(`--seam: entry ${i} names "${a}:${b}" again — entry ${again} already reads "${pairs[again].a}:${pairs[again].b}", and the reading is the same both ways`);
114
+ pairs.push({ a, b });
115
+ }
116
+ return pairs;
117
+ }
118
+
119
+ /** One sampled frame: its index, its time, the pieces it draws, and every slot's whole attachment. */
120
+ export interface SeamFrame {
121
+ index: number;
122
+ time: number;
123
+ pieces: readonly Piece[];
124
+ attachments: readonly AttachmentPose[];
125
+ }
126
+
127
+ /** A world box `[minX, minY, maxX, maxY]`, world units, y up. */
128
+ export type SeamBox = [number, number, number, number];
129
+
130
+ /** What one frame reads of one kind. */
131
+ export interface SeamHit {
132
+ index: number;
133
+ /** Seconds. */
134
+ time: number;
135
+ /** Cells counted: one cell is one texel of the coarser drawing at the setup pose. */
136
+ px: number;
137
+ /** The cell's side, world units. */
138
+ cell: number;
139
+ /** `px × cell²`, world units². */
140
+ area: number;
141
+ /** The counted cells' whole squares, world units, y up. */
142
+ box: SeamBox;
143
+ }
144
+
145
+ /** A run of consecutive frames of one kind — first and last frame, their times, the most cells on any of them. */
146
+ export interface SeamSpan {
147
+ frames: [number, number];
148
+ /** Seconds. */
149
+ times: [number, number];
150
+ maxPx: number;
151
+ }
152
+
153
+ /** One kind over one frame set. */
154
+ export interface SeamKindReading {
155
+ /** Every frame with a cell of this kind, in frame order; empty when none has. */
156
+ frames: SeamHit[];
157
+ /** `frames` as runs of consecutive frame indices. */
158
+ spans: SeamSpan[];
159
+ /** The frame with the most cells — the earliest on a tie — or `null` when none has any. */
160
+ worst: SeamHit | null;
161
+ }
162
+
163
+ /** One pair's reading over one frame set. */
164
+ export interface SeamPairReading {
165
+ a: string;
166
+ b: string;
167
+ /** Frames on which both slots draw. */
168
+ drawn: number;
169
+ /** Of those, frames whose two drawings overlap at the setup pose — the frames read. */
170
+ banded: number;
171
+ /** `skip` when no frame is read: nothing to measure, which is not a pass. */
172
+ status: 'read' | 'skip';
173
+ /** Every frame with a gap or a ghost, ascending. */
174
+ opens: number[];
175
+ /** The most cells of either kind on any frame; 0 when none opens. */
176
+ maxPx: number;
177
+ gap: SeamKindReading;
178
+ ghost: SeamKindReading;
179
+ }
180
+
181
+ /** `seam.json`: one frame set's reading, every pair the flag names, in the flag's order. */
182
+ export interface SeamFile {
183
+ spec: typeof SEAM_SPEC;
184
+ /** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
185
+ animation: string | null;
186
+ skin?: string;
187
+ fps: number;
188
+ /** What the numbers mean, stated in the file so a reader does not need this module. */
189
+ reading: { read: string; cell: string; drawn: string; band: string; carried: string; gap: string; ghost: string; box: string; status: string };
190
+ pairs: SeamPairReading[];
191
+ }
192
+
193
+ /**
194
+ * A planted defect, for the suite's mutants only (`SM` controls in
195
+ * `selftest.ts`). Production passes none.
196
+ */
197
+ export interface SeamPlant {
198
+ /** Compare the frame with the setup pose in the world, carrying nothing back. */
199
+ world?: boolean;
200
+ /** Read gap cells anywhere on the grid, not only inside the band's hull. */
201
+ noHull?: boolean;
202
+ /** Count a gap cell only when BOTH pieces' views find it drawn at the setup pose, rather than either. */
203
+ bothViews?: boolean;
204
+ /** Count a ghost cell wherever a piece draws what its partner's view had none of, not only from the band. */
205
+ ghostAnyArt?: boolean;
206
+ /** Report a pair with no band as read, rather than as a SKIP. */
207
+ skipAsRead?: boolean;
208
+ /** Count nothing. */
209
+ never?: boolean;
210
+ }
211
+
212
+ /** Twice the signed area of (a, b, c), each a point index into `xy`. */
213
+ function cross(xy: ArrayLike<number>, a: number, b: number, c: number): number {
214
+ return (xy[2 * b] - xy[2 * a]) * (xy[2 * c + 1] - xy[2 * a + 1]) - (xy[2 * c] - xy[2 * a]) * (xy[2 * b + 1] - xy[2 * a + 1]);
215
+ }
216
+
217
+ /** Twice the signed area of (a, b, q), a and b point indices into `xy`, q a point. */
218
+ function crossAt(xy: ArrayLike<number>, a: number, b: number, qx: number, qy: number): number {
219
+ return (xy[2 * b] - xy[2 * a]) * (qy - xy[2 * a + 1]) - (qx - xy[2 * a]) * (xy[2 * b + 1] - xy[2 * a + 1]);
220
+ }
221
+
222
+ /** The world grid one frame of one pair is read on. */
223
+ interface Grid {
224
+ viewport: Viewport;
225
+ cell: number;
226
+ }
227
+
228
+ const centreX = (g: Grid, px: number): number => g.viewport.minX + (px + 0.5) * g.cell;
229
+ const centreY = (g: Grid, py: number): number => g.viewport.maxY - (py + 0.5) * g.cell;
230
+
231
+ /**
232
+ * For every cell of `grid`, the lowest-index triangle of `triangles` over `xy`
233
+ * holding the cell's centre (on an edge counts), or -1. A triangle with no area
234
+ * holds nothing.
235
+ */
236
+ function triangleIds(triangles: readonly number[], xy: ArrayLike<number>, grid: Grid): Int32Array {
237
+ const { width, height, minX, maxY } = grid.viewport;
238
+ const ids = new Int32Array(width * height).fill(-1);
239
+ for (let t = 0; 3 * t + 2 < triangles.length; t++) {
240
+ const [a, b, c] = [triangles[3 * t], triangles[3 * t + 1], triangles[3 * t + 2]];
241
+ const area = cross(xy, a, b, c);
242
+ if (area === 0) continue;
243
+ const xs = [xy[2 * a], xy[2 * b], xy[2 * c]];
244
+ const ys = [xy[2 * a + 1], xy[2 * b + 1], xy[2 * c + 1]];
245
+ const px0 = Math.max(0, Math.floor((Math.min(...xs) - minX) / grid.cell));
246
+ const px1 = Math.min(width - 1, Math.floor((Math.max(...xs) - minX) / grid.cell));
247
+ const py0 = Math.max(0, Math.floor((maxY - Math.max(...ys)) / grid.cell));
248
+ const py1 = Math.min(height - 1, Math.floor((maxY - Math.min(...ys)) / grid.cell));
249
+ for (let py = py0; py <= py1; py++) {
250
+ for (let px = px0; px <= px1; px++) {
251
+ const k = py * width + px;
252
+ if (ids[k] >= 0) continue;
253
+ const qx = centreX(grid, px);
254
+ const qy = centreY(grid, py);
255
+ const s = [crossAt(xy, a, b, qx, qy), crossAt(xy, b, c, qx, qy), crossAt(xy, c, a, qx, qy)];
256
+ if (area > 0 ? s.every((v) => v >= 0) : s.every((v) => v <= 0)) ids[k] = t;
257
+ }
258
+ }
259
+ }
260
+ return ids;
261
+ }
262
+
263
+ /** The barycentric weights of (qx, qy) in triangle `t` over `xy`, or `null` when it has no area. */
264
+ function weightsIn(triangles: readonly number[], xy: ArrayLike<number>, t: number, qx: number, qy: number): [number, number, number] | null {
265
+ const [a, b, c] = [triangles[3 * t], triangles[3 * t + 1], triangles[3 * t + 2]];
266
+ const area = cross(xy, a, b, c);
267
+ if (area === 0) return null;
268
+ return [crossAt(xy, b, c, qx, qy) / area, crossAt(xy, c, a, qx, qy) / area, crossAt(xy, a, b, qx, qy) / area];
269
+ }
270
+
271
+ /**
272
+ * Carry the point (qx, qy) from `from` to `to` — the same vertices in two poses —
273
+ * by the affine map of the `from` triangle `id` names, or, when `id` is -1, of
274
+ * the triangle the point is least outside of (the largest smallest weight, the
275
+ * lowest index on a tie). `null` when every triangle has collapsed in `from`.
276
+ */
277
+ function carry(triangles: readonly number[], from: ArrayLike<number>, to: ArrayLike<number>, id: number, qx: number, qy: number): [number, number] | null {
278
+ let best = id;
279
+ let w = id >= 0 ? weightsIn(triangles, from, id, qx, qy) : null;
280
+ if (w === null) {
281
+ best = -1;
282
+ let most = -Infinity;
283
+ for (let t = 0; 3 * t + 2 < triangles.length; t++) {
284
+ const k = weightsIn(triangles, from, t, qx, qy);
285
+ if (k === null) continue;
286
+ const least = Math.min(k[0], k[1], k[2]);
287
+ if (least > most) {
288
+ most = least;
289
+ best = t;
290
+ w = k;
291
+ }
292
+ }
293
+ }
294
+ if (w === null || best < 0) return null;
295
+ const [a, b, c] = [triangles[3 * best], triangles[3 * best + 1], triangles[3 * best + 2]];
296
+ return [w[0] * to[2 * a] + w[1] * to[2 * b] + w[2] * to[2 * c], w[0] * to[2 * a + 1] + w[1] * to[2 * b + 1] + w[2] * to[2 * c + 1]];
297
+ }
298
+
299
+ /** The convex hull of `points`, counter-clockwise, collinear points dropped (Andrew's monotone chain). */
300
+ function hullOf(points: Array<[number, number]>): Array<[number, number]> {
301
+ const sorted = [...points].sort((p, q) => p[0] - q[0] || p[1] - q[1]);
302
+ const turn = (o: [number, number], a: [number, number], b: [number, number]): number => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
303
+ const half = (list: Array<[number, number]>): Array<[number, number]> => {
304
+ const out: Array<[number, number]> = [];
305
+ for (const p of list) {
306
+ while (out.length >= 2 && turn(out[out.length - 2], out[out.length - 1], p) <= 0) out.pop();
307
+ out.push(p);
308
+ }
309
+ return out;
310
+ };
311
+ if (sorted.length <= 1) return sorted;
312
+ const lower = half(sorted);
313
+ const upper = half([...sorted].reverse());
314
+ return [...lower.slice(0, -1), ...upper.slice(0, -1)];
315
+ }
316
+
317
+ /** Whether (qx, qy) is inside or on the counter-clockwise hull `h`; a hull of one point or two holds the point or the segment. */
318
+ function inHull(h: ReadonlyArray<[number, number]>, qx: number, qy: number): boolean {
319
+ if (h.length === 0) return false;
320
+ if (h.length === 1) return h[0][0] === qx && h[0][1] === qy;
321
+ if (h.length === 2) {
322
+ const [[x0, y0], [x1, y1]] = h;
323
+ if ((x1 - x0) * (qy - y0) - (qx - x0) * (y1 - y0) !== 0) return false;
324
+ return qx >= Math.min(x0, x1) && qx <= Math.max(x0, x1) && qy >= Math.min(y0, y1) && qy <= Math.max(y0, y1);
325
+ }
326
+ for (let i = 0; i < h.length; i++) {
327
+ const [x0, y0] = h[i];
328
+ const [x1, y1] = h[(i + 1) % h.length];
329
+ if ((x1 - x0) * (qy - y0) - (qx - x0) * (y1 - y0) < 0) return false;
330
+ }
331
+ return true;
332
+ }
333
+
334
+ /** One side of the pair at one frame: its piece as drawn, its setup-pose vertices, its triangles and its page. */
335
+ interface Side {
336
+ piece: Piece;
337
+ rest: readonly number[];
338
+ triangles: readonly number[];
339
+ page: Plate;
340
+ }
341
+
342
+ /** The cells counted of one kind on one frame, before they become a hit. */
343
+ interface Cells {
344
+ px: number;
345
+ box: SeamBox | null;
346
+ }
347
+
348
+ const restPiece = (side: Side): Piece => ({ ...side.piece, world: Array.from(side.rest) });
349
+
350
+ /** One frame's two counts, or `null` when the two drawings share no cell at the setup pose. See the module comment. */
351
+ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant): { gap: Cells; ghost: Cells } | null {
352
+ // One grid over both drawings at the setup pose and at the frame, on multiples of the cell from the world origin.
353
+ let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
354
+ for (const xy of [A.rest, B.rest, A.piece.world, B.piece.world]) {
355
+ for (let i = 0; i + 1 < xy.length; i += 2) {
356
+ x0 = Math.min(x0, xy[i]);
357
+ y0 = Math.min(y0, xy[i + 1]);
358
+ x1 = Math.max(x1, xy[i]);
359
+ y1 = Math.max(y1, xy[i + 1]);
360
+ }
361
+ }
362
+ const minX = (Math.floor(x0 / cell) - 1) * cell;
363
+ const maxY = (Math.ceil(y1 / cell) + 1) * cell;
364
+ const width = Math.ceil((x1 - minX) / cell) + 1;
365
+ const height = Math.ceil((maxY - y0) / cell) + 1;
366
+ const grid: Grid = { viewport: { minX, minY: maxY - height * cell, maxX: minX + width * cell, maxY, scale: 1 / cell, width, height }, cell };
367
+ const restA = restPiece(A);
368
+ const restB = restPiece(B);
369
+ const bandA = drawnCells(restA, A.page, grid.viewport);
370
+ const bandB = drawnCells(restB, B.page, grid.viewport);
371
+ // The band: both drawings at the setup pose. Carried to the frame by each piece, its centres span the joint's hull.
372
+ const restIdsA = triangleIds(A.triangles, A.rest, grid);
373
+ const restIdsB = triangleIds(B.triangles, B.rest, grid);
374
+ const carried: Array<[number, number]> = [];
375
+ let band = 0;
376
+ let [bx0, by0, bx1, by1] = [Infinity, Infinity, -Infinity, -Infinity];
377
+ for (let py = 0; py < height; py++) {
378
+ for (let px = 0; px < width; px++) {
379
+ const k = py * width + px;
380
+ if (bandA[k] === 0 || bandB[k] === 0) continue;
381
+ band++;
382
+ const qx = centreX(grid, px);
383
+ const qy = centreY(grid, py);
384
+ [bx0, by0, bx1, by1] = [Math.min(bx0, qx), Math.min(by0, qy), Math.max(bx1, qx), Math.max(by1, qy)];
385
+ for (const [side, ids] of [[A, restIdsA], [B, restIdsB]] as const) {
386
+ const p = plant.world ? ([qx, qy] as [number, number]) : carry(side.triangles, side.rest, side.piece.world, ids[k], qx, qy);
387
+ if (p !== null) carried.push(p);
388
+ }
389
+ }
390
+ }
391
+ if (band === 0) return null;
392
+ const hull = hullOf(carried);
393
+ const drawnA = drawnCells(A.piece, A.page, grid.viewport);
394
+ const drawnB = drawnCells(B.piece, B.page, grid.viewport);
395
+ const posedIdsA = triangleIds(A.triangles, A.piece.world, grid);
396
+ const posedIdsB = triangleIds(B.triangles, B.piece.world, grid);
397
+ // A frame point carried back into a side's setup pose.
398
+ const back = (side: Side, ids: Int32Array, k: number, qx: number, qy: number): [number, number] | null =>
399
+ plant.world ? [qx, qy] : carry(side.triangles, side.piece.world, side.rest, ids[k], qx, qy);
400
+ const drawnAtRest = (p: [number, number] | null, which: Piece, page: Plate): boolean => p !== null && drawsAt(which, page, p[0], p[1], cell);
401
+ const composite = (p: [number, number] | null): boolean => drawnAtRest(p, restA, A.page) || drawnAtRest(p, restB, B.page);
402
+ const gap: Cells = { px: 0, box: null };
403
+ const ghost: Cells = { px: 0, box: null };
404
+ const take = (into: Cells, qx: number, qy: number): void => {
405
+ into.px++;
406
+ const h = cell / 2;
407
+ into.box ??= [Infinity, Infinity, -Infinity, -Infinity];
408
+ into.box[0] = Math.min(into.box[0], qx - h);
409
+ into.box[1] = Math.min(into.box[1], qy - h);
410
+ into.box[2] = Math.max(into.box[2], qx + h);
411
+ into.box[3] = Math.max(into.box[3], qy + h);
412
+ };
413
+ if (plant.never) return { gap, ghost };
414
+ const inBand = (p: [number, number] | null): boolean => p !== null && p[0] >= bx0 - cell / 2 && p[0] <= bx1 + cell / 2 && p[1] >= by0 - cell / 2 && p[1] <= by1 + cell / 2;
415
+ for (let py = 0; py < height; py++) {
416
+ for (let px = 0; px < width; px++) {
417
+ const k = py * width + px;
418
+ const qx = centreX(grid, px);
419
+ const qy = centreY(grid, py);
420
+ const a = drawnA[k] !== 0;
421
+ const b = drawnB[k] !== 0;
422
+ if (!a && !b) {
423
+ if (!plant.noHull && !inHull(hull, qx, qy)) continue;
424
+ const fromA = composite(back(A, posedIdsA, k, qx, qy));
425
+ const fromB = composite(back(B, posedIdsB, k, qx, qy));
426
+ if (plant.bothViews ? fromA && fromB : fromA || fromB) take(gap, qx, qy);
427
+ continue;
428
+ }
429
+ if (a && b) continue;
430
+ // One piece draws the cell and its partner does not.
431
+ const [mine, myIds, myRest, other, otherIds] = a ? [A, posedIdsA, restA, B, posedIdsB] : [B, posedIdsB, restB, A, posedIdsA];
432
+ const otherRest = a ? restB : restA;
433
+ const p = back(mine, myIds, k, qx, qy);
434
+ if (!plant.ghostAnyArt && !(inBand(p) && drawnAtRest(p, myRest, mine.page) && drawnAtRest(p, otherRest, other.page))) continue;
435
+ if (!composite(back(other, otherIds, k, qx, qy))) take(ghost, qx, qy);
436
+ }
437
+ }
438
+ return { gap, ghost };
439
+ }
440
+
441
+ const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
442
+
443
+ /** `frames` as spans of consecutive indices, each with its first and last time and its most cells. */
444
+ function spansOf(frames: readonly SeamHit[]): SeamSpan[] {
445
+ const spans: SeamSpan[] = [];
446
+ for (const f of frames) {
447
+ const last = spans[spans.length - 1];
448
+ if (last !== undefined && last.frames[1] === f.index - 1) {
449
+ last.frames[1] = f.index;
450
+ last.times[1] = f.time;
451
+ last.maxPx = Math.max(last.maxPx, f.px);
452
+ } else spans.push({ frames: [f.index, f.index], times: [f.time, f.time], maxPx: f.px });
453
+ }
454
+ return spans;
455
+ }
456
+
457
+ function kindOf(frames: SeamHit[]): SeamKindReading {
458
+ let worst: SeamHit | null = null;
459
+ for (const f of frames) if (worst === null || f.px > worst.px) worst = f;
460
+ return { frames, spans: spansOf(frames), worst };
461
+ }
462
+
463
+ /**
464
+ * Every pair's reading over one frame set: `rest` the rest table, `frames` the
465
+ * frames as the sampler returned them (pieces in draw order, attachments whole),
466
+ * `pages` the atlas pages the pieces sample. Refused by name: a slot a clip cuts
467
+ * at a frame, whose drawing is no longer its attachment's, and a drawing with no
468
+ * area at its setup pose, which has no texel to grid by.
469
+ */
470
+ export function seamsOf(
471
+ pairs: readonly SeamPair[],
472
+ rest: readonly AttachmentRest[],
473
+ frames: readonly SeamFrame[],
474
+ pages: ReadonlyMap<string, Plate>,
475
+ plant: SeamPlant = {},
476
+ ): SeamPairReading[] {
477
+ const restOf = new Map(rest.map((r) => [restKey(r.slot, r.attachment), r]));
478
+ const sideOf = (frame: SeamFrame, slot: string): Side | null => {
479
+ const piece = frame.pieces.find((p) => p.slot === slot);
480
+ if (piece === undefined) return null;
481
+ const pose = frame.attachments.find((p) => p.slot === slot);
482
+ if (pose === undefined) throw new SeamError(`frame ${frame.index}: slot "${slot}" draws, and the frame records no attachment for it`);
483
+ if (!sameVertices(piece.world, pose.vertices)) {
484
+ throw new SeamError(`frame ${frame.index}: a clip cuts slot "${slot}" (attachment "${pose.attachment}"), so its drawing is no longer its attachment's and cannot be carried back to its setup pose`);
485
+ }
486
+ const entry = restOf.get(restKey(slot, pose.attachment));
487
+ if (entry === undefined) throw new SeamError(`frame ${frame.index}: the rest table has no entry for slot "${slot}" attachment "${pose.attachment}"`);
488
+ const page = pages.get(piece.page);
489
+ if (page === undefined) throw new SeamError(`slot "${slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`);
490
+ return { piece, rest: entry.vertices, triangles: entry.triangles, page };
491
+ };
492
+ return pairs.map(({ a, b }) => {
493
+ let drawn = 0;
494
+ let banded = 0;
495
+ const gaps: SeamHit[] = [];
496
+ const ghosts: SeamHit[] = [];
497
+ for (const frame of frames) {
498
+ const A = sideOf(frame, a);
499
+ const B = sideOf(frame, b);
500
+ if (A === null || B === null) continue;
501
+ drawn++;
502
+ const cellA = texelSize(A.piece, A.rest, A.page);
503
+ const cellB = texelSize(B.piece, B.rest, B.page);
504
+ const flat = cellA === null ? a : cellB === null ? b : null;
505
+ if (cellA === null || cellB === null) throw new SeamError(`frame ${frame.index}: slot "${flat}" draws no area at its setup pose, so it has no texel to grid the seam by`);
506
+ const cell = Math.max(cellA, cellB);
507
+ const read = readFrame(A, B, cell, plant);
508
+ if (read === null) continue;
509
+ banded++;
510
+ for (const [cells, into] of [[read.gap, gaps], [read.ghost, ghosts]] as const) {
511
+ if (cells.px > 0 && cells.box !== null) into.push({ index: frame.index, time: frame.time, px: cells.px, cell, area: cells.px * cell * cell, box: cells.box });
512
+ }
513
+ }
514
+ const gap = kindOf(gaps);
515
+ const ghost = kindOf(ghosts);
516
+ const opens = [...new Set([...gaps, ...ghosts].map((f) => f.index))].sort((x, y) => x - y);
517
+ const maxPx = Math.max(0, ...gaps.map((f) => f.px), ...ghosts.map((f) => f.px));
518
+ const status = banded === 0 && !plant.skipAsRead ? 'skip' : 'read';
519
+ return { a, b, drawn, banded, status, opens, maxPx, gap, ghost };
520
+ });
521
+ }
522
+
523
+ /** What `seam.json` states its numbers mean — the one wording the file and the docs share. */
524
+ export const SEAM_READING: SeamFile['reading'] = {
525
+ read: 'a frame is read when both slots draw and their two drawings share a cell at the setup pose (the band); draw order does not enter',
526
+ cell: "every count is of cells one texel of the coarser of the two drawings at the setup pose (the rest table's vertices), in world units, on multiples of that texel from the world origin; px is a count of cells, cell the cell's side in world units, area px × cell² in world units²",
527
+ drawn: `a cell is drawn by a piece when the renderer paints its centre at ${DRAWN_ALPHA} of 255 or more (half opacity) — the slot's art through its page, the frame's tint included, at the setup pose drawn at the same tint`,
528
+ band: "the band is the cells both drawings draw at the setup pose — where the two were painted to overlap, dilated by nothing",
529
+ carried:
530
+ "a frame point is carried into a piece's setup pose by the affine map of the piece's triangle holding it as the frame poses it, or, outside the piece's mesh, of the triangle it is least outside of (the largest smallest barycentric weight, the lowest index on a tie); a band cell is carried to the frame the other way",
531
+ gap: "a gap cell lies inside the convex hull of the band's cell centres carried to the frame by both pieces, is drawn by neither piece at the frame, and at least one piece, carrying it back, finds it drawn by either drawing at the setup pose",
532
+ ghost: "a ghost cell is drawn at the frame by one piece and not the other; the drawing piece, carrying it back, lands it in the band, and the other piece, carrying it back, finds neither drawing there at the setup pose",
533
+ box: "a frame's box is [minX, minY, maxX, maxY] in world units (y up) over the whole squares of that kind's counted cells",
534
+ status: 'a pair is skip when no frame is read — the two never draw together, or never share a cell at the setup pose — which is nothing measured, not a seam that held',
535
+ };
536
+
537
+ /** The file for one frame set. */
538
+ export function seamFileOf(animation: string | null, fps: number, skin: string | undefined, pairs: SeamPairReading[]): SeamFile {
539
+ return { spec: SEAM_SPEC, animation, ...(skin === undefined ? {} : { skin }), fps, reading: SEAM_READING, pairs };
540
+ }
541
+
542
+ /** The file's text: two-space JSON and a newline, so one build and one request are one byte sequence. */
543
+ export function seamText(file: SeamFile): string {
544
+ return `${JSON.stringify(file, null, 2)}\n`;
545
+ }
546
+
547
+ /** `x` rounded to hundredths away from the box's inside, so the printed box holds what the exact one does. */
548
+ const outward = (x: number, up: boolean): string => ((up ? Math.ceil(x * 100) : Math.floor(x * 100)) / 100).toFixed(2);
549
+
550
+ /** One kind's clause: `gap on N frame(s) [runs], worst frame i (t=…s): P px (A units² at C units a px) in world box …`, or `no gap`. */
551
+ function kindText(kind: 'gap' | 'ghost', reading: SeamKindReading): string {
552
+ const w = reading.worst;
553
+ if (w === null) return `no ${kind}`;
554
+ const [x0, y0, x1, y1] = w.box;
555
+ return (
556
+ `${kind} on ${reading.frames.length} frame(s) [${indexRuns(reading.frames.map((f) => f.index))}], worst frame ${w.index} (t=${w.time.toFixed(4)}s): ` +
557
+ `${w.px} px (${w.area.toFixed(2)} units² at ${w.cell.toFixed(4)} units a px) in world box x [${outward(x0, false)}, ${outward(x1, true)}] y [${outward(y0, false)}, ${outward(y1, true)}] ` +
558
+ `(${(x1 - x0).toFixed(2)} × ${(y1 - y0).toFixed(2)})`
559
+ );
560
+ }
561
+
562
+ /**
563
+ * The line for one pair over one set — printed for every pair, so a pair that
564
+ * stays closed says so, and a pair with nothing to read says SKIP and why.
565
+ */
566
+ export function seamLine(animation: string | null, fps: number, pair: SeamPairReading): string {
567
+ const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
568
+ const head = `${where}: seam "${pair.a}" | "${pair.b}"`;
569
+ if (pair.drawn === 0) return `${head}: SKIP — the two slots draw together on no frame, so there is no seam to read`;
570
+ if (pair.status === 'skip') return `${head}: SKIP — the two drawings share no cell at the setup pose on any of the ${pair.drawn} frame(s) both draw, so there is no band to read`;
571
+ if (pair.opens.length === 0) return `${head} stays closed on all ${pair.banded} frame(s) read at ${fps} fps — no gap, no ghost`;
572
+ return (
573
+ `${head} opens on ${pair.opens.length} of ${pair.banded} frame(s) read [${indexRuns(pair.opens)}] at ${fps} fps, max ${pair.maxPx} px — ` +
574
+ `${kindText('gap', pair.gap)}; ${kindText('ghost', pair.ghost)}`
575
+ );
576
+ }