spine-rigc 2.54.0 → 2.55.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.
@@ -0,0 +1,575 @@
1
+ /**
2
+ * Where a cloth slot passes through a leg slot — the reading behind
3
+ * `rigc render --pass-through <cloth>:<leg>[,…]` (issue #1413).
4
+ *
5
+ * ⭐ **A reading, not a gate**, as `--folds` is. A skirt drawn over a thigh can
6
+ * swing its side straight through the thigh on a build that passes every
7
+ * assertion and every check, and until this it was found by a person looking at
8
+ * the right frame. A real skirt cannot do that while it is in front of the leg:
9
+ * the leg is inside it and leaves through the hem. So this names the frames where
10
+ * the cloth's drawn side lies inside the leg's drawn outline, and decides nothing.
11
+ *
12
+ * ## What "passes through" means here
13
+ *
14
+ * At a frame where both slots draw and the cloth slot draws **over** the leg slot
15
+ * (the frame's own draw order, so a draw-order key is honoured — a leg drawn over
16
+ * the cloth is not read at that frame):
17
+ *
18
+ * 1. **The cloth's rows are its setup pose's.** The cloth is drawn onto a grid in
19
+ * its own setup-pose geometry (the rest table's vertices: the setup pose's
20
+ * bones, no deform), at the frame's page UVs and tint. A cell is drawn when the
21
+ * renderer's own traversal (`rasterisePiece`) paints its centre at half
22
+ * opacity or more — the half-opacity line is where an antialiased edge sits.
23
+ * The cell is one texel of the cloth's drawing as the setup pose lays it out
24
+ * (`texelSize`), so the grid is the drawing's own resolution, not a number
25
+ * chosen here. Reading the rows in the setup pose's frame rather than the
26
+ * world's is what keeps a cloth swung or bent by the pose readable: its side
27
+ * is still its side. Read in world rows, a waist or hem the pose tilts becomes
28
+ * a "side" on the rows it spans, and a leg under it reads as passing through —
29
+ * measured on the suite's probe, where a 2.5° swing read a run of 16 off the
30
+ * tilted waist (`PX11`).
31
+ * 2. **The hem's corner bounds the rows.** On each side, the corner is the
32
+ * outermost drawn cell in the lower half of the cloth's drawn rows, the lowest
33
+ * on a tie. Rows from the corner up to the cloth's top are read; rows below it
34
+ * are the hem's own curve, under which a leg is meant to show.
35
+ * 3. **The run.** On a read row, from the centre of the outermost drawn cell as
36
+ * the frame poses it, step outward in **world units** — along the setup pose's
37
+ * outward direction carried to the frame by the affine map of the cloth
38
+ * triangle that holds that centre, normalised — one step at a time, and count
39
+ * the steps whose world point the leg's drawing covers at half opacity, until
40
+ * the first it does not. A step is one texel of the **coarser** of the two
41
+ * drawings at the setup pose, so the run is in the outlines' own resolution
42
+ * and in the units the picture shows. That count is how far the leg's outline
43
+ * passes the cloth's outline on that row, in steps.
44
+ * 4. **A frame passes through** when some read row's run is more than one step
45
+ * (`PASS_THROUGH_RUN` = 2): one step is the resolution of the outlines themselves.
46
+ *
47
+ * ⚠️ The run is in world steps, not in the cloth's setup-pose cells, for a
48
+ * reason that was measured (issue #1413). Stepped in setup cells, a cloth
49
+ * squashed at a frame reads a longer run than the picture holds: the line
50
+ * printed a depth in units over a box narrower than that depth. The rows are the
51
+ * setup pose's because they say *which* part of the cloth is its side; the run
52
+ * is the world's because it says *how far* the leg shows.
53
+ *
54
+ * Its known blind spots, stated rather than discovered: a cloth whose side widens
55
+ * all the way to its waist has its corner at mid-height and the rows below it
56
+ * unread; a leg the cloth's side never meets on a read row (the leg wholly beside
57
+ * the cloth, entering above its top) has no run to count; and the rows are the
58
+ * setup pose's, so a cloth authored lying on its side reads its side as a hem.
59
+ *
60
+ * Pure: no clock, no randomness, no runtime. The pieces, the attachments and the
61
+ * rest table are handed in by whichever poser drew the frames.
62
+ */
63
+
64
+ import type { Plate } from '../tools/plate.ts';
65
+ import { type AttachmentPose, type AttachmentRest, type Piece, projector, rasterisePiece, type Viewport } from './render_shared.ts';
66
+ import { hypot2 } from './numeric.ts';
67
+
68
+ /** The reading's file name inside an animation's frame directory, and its format tag. */
69
+ export const PASS_THROUGH_FILE = 'pass-through.json';
70
+ export const PASS_THROUGH_SPEC = 'rigc-pass-through/1';
71
+
72
+ /**
73
+ * How many steps a run must reach for a frame to pass through: more than one.
74
+ * One step is the outlines' own resolution — the cloth's outermost drawn cell and
75
+ * the leg's first covered step can sit one step apart on two edges that only
76
+ * touch — so a run of one is the outlines meeting, and two is the leg showing.
77
+ */
78
+ export const PASS_THROUGH_RUN = 2;
79
+
80
+ /**
81
+ * The opacity, of 255, a cell's centre must be painted at to count as drawn: the
82
+ * half-opacity line, where an antialiased edge is. The renderer paints anything
83
+ * above 0.5 of 255, which would put the outline on the faintest fringe of the
84
+ * art; `rasterisePiece` reports the painted alpha rounded, and this reads it.
85
+ */
86
+ export const DRAWN_ALPHA = 128;
87
+
88
+ /** One pair as the flag names it: the cloth that should hold the leg, and the leg. */
89
+ export interface PassPair {
90
+ cloth: string;
91
+ leg: string;
92
+ }
93
+
94
+ /** A malformed `--pass-through` value, or a pair the poses cannot be read for — said by name. */
95
+ export class PassThroughError extends Error {}
96
+
97
+ /**
98
+ * `--pass-through`'s value — `<cloth>:<leg>[,<cloth>:<leg>…]` — read against
99
+ * the skeleton's slots, or refused naming the entry: an empty entry, one without
100
+ * exactly one `:`, an empty name, a slot the skeleton does not have (with the
101
+ * slots it does), and a pair named twice. A cloth paired with itself is read, not
102
+ * refused: a slot never draws over itself, so it reads 0 frames and says why.
103
+ *
104
+ * The order is the declaration: the first name is the cloth, which must draw over
105
+ * the second for a frame to be read. `a:b` and `b:a` are two readings, never one
106
+ * read both ways.
107
+ */
108
+ export function readPassPairs(raw: string, slots: readonly string[]): PassPair[] {
109
+ const form = '<cloth slot>:<leg slot>[,<cloth slot>:<leg slot>…]';
110
+ const pairs: PassPair[] = [];
111
+ const entries = raw.split(',');
112
+ for (let i = 0; i < entries.length; i++) {
113
+ const entry = entries[i].trim();
114
+ if (entry === '') throw new PassThroughError(`--pass-through ${JSON.stringify(raw)}: entry ${i} is empty — it takes ${form}`);
115
+ const parts = entry.split(':');
116
+ if (parts.length !== 2) {
117
+ throw new PassThroughError(`--pass-through: entry ${i} ${JSON.stringify(entry)} has ${parts.length - 1} ":" — it takes ${form}, one ":" per pair`);
118
+ }
119
+ const [cloth, leg] = parts.map((p) => p.trim());
120
+ for (const [role, name] of [['cloth', cloth], ['leg', leg]] as const) {
121
+ if (name === '') throw new PassThroughError(`--pass-through: entry ${i} ${JSON.stringify(entry)} names no ${role} slot — it takes ${form}`);
122
+ if (!slots.includes(name)) {
123
+ throw new PassThroughError(`--pass-through: entry ${i} names ${role} slot ${JSON.stringify(name)}, which this skeleton does not have; its slots are [${slots.join(', ')}]`);
124
+ }
125
+ }
126
+ const again = pairs.findIndex((p) => p.cloth === cloth && p.leg === leg);
127
+ if (again >= 0) throw new PassThroughError(`--pass-through: entry ${i} names "${cloth}:${leg}" again — entry ${again} already reads it`);
128
+ pairs.push({ cloth, leg });
129
+ }
130
+ return pairs;
131
+ }
132
+
133
+ /** One sampled frame: its index, its time, the pieces it draws in draw order, and every slot's whole attachment. */
134
+ export interface PassFrame {
135
+ index: number;
136
+ time: number;
137
+ pieces: readonly Piece[];
138
+ attachments: readonly AttachmentPose[];
139
+ }
140
+
141
+ /** A world box `[minX, minY, maxX, maxY]`, world units, y up. */
142
+ export type PassBox = [number, number, number, number];
143
+
144
+ /** The side of the cloth a run is read on — its setup pose's left (−x) or right (+x). */
145
+ export type PassSide = 'left' | 'right';
146
+
147
+ /** One read frame's figures. */
148
+ export interface PassDepth {
149
+ index: number;
150
+ time: number;
151
+ /** The longest run on the frame, in steps. */
152
+ cells: number;
153
+ /** `cells` in world units: `cells × cell`. */
154
+ depth: number;
155
+ /** The step: one texel of the coarser of the two drawings at the setup pose, world units. */
156
+ cell: number;
157
+ /** The side the longest run is on; the left on a tie. */
158
+ side: PassSide;
159
+ }
160
+
161
+ /** A frame on which the cloth passes through the leg. */
162
+ export interface PassFrameReading extends PassDepth {
163
+ /** The cloth's attachment at the frame. */
164
+ attachment: string;
165
+ /** Read rows whose run reaches `PASS_THROUGH_RUN`, over both sides. */
166
+ rows: number;
167
+ /** The world box of every run of a row whose run reaches `PASS_THROUGH_RUN`: from the cloth's outline to the run's far end. */
168
+ box: PassBox;
169
+ }
170
+
171
+ /** One pair's reading over one frame set. */
172
+ export interface PairReading {
173
+ cloth: string;
174
+ leg: string;
175
+ /** Frames on which both slots draw. */
176
+ drawn: number;
177
+ /** Of those, frames on which the cloth draws over the leg — the frames read. */
178
+ over: number;
179
+ /** Every frame that passes through, in frame order; empty when none does. */
180
+ frames: PassFrameReading[];
181
+ /** The read frame with the longest run — the earliest on a tie — or `null` when no frame is read. */
182
+ deepest: PassDepth | null;
183
+ }
184
+
185
+ /** `pass-through.json`: one frame set's reading, every pair the flag names, in the flag's order. */
186
+ export interface PassThroughFile {
187
+ spec: typeof PASS_THROUGH_SPEC;
188
+ /** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
189
+ animation: string | null;
190
+ skin?: string;
191
+ fps: number;
192
+ /** What the numbers mean, stated in the file so a reader does not need this module. */
193
+ reading: { read: string; drawn: string; rows: string; run: string; passes: string; box: string };
194
+ pairs: PairReading[];
195
+ }
196
+
197
+ /**
198
+ * A planted defect, for the suite's mutants only (`PT` controls in
199
+ * `selftest.ts`). Production passes none.
200
+ */
201
+ export interface PassPlant {
202
+ /** Read a frame whichever slot draws over the other. */
203
+ ignoreDrawOrder?: boolean;
204
+ /** The run a frame needs to pass through, in steps, rather than `PASS_THROUGH_RUN`. */
205
+ run?: number;
206
+ /** Read the cloth's rows in the frame's world rather than in its setup pose. */
207
+ rowsInWorld?: boolean;
208
+ /** Step the run in the cloth's setup-pose cells carried to the frame, rather than in world units. */
209
+ cellSteps?: boolean;
210
+ }
211
+
212
+ /** Twice the signed area of triangle (a, b, c) of `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
+ /** A piece's triangles: a mesh's own, a region's the runtime's `0 1 2 2 3 0`. */
218
+ function trianglesOf(piece: Piece): ArrayLike<number> {
219
+ return piece.kind === 'mesh' ? piece.triangles : [0, 1, 2, 2, 3, 0];
220
+ }
221
+
222
+ /**
223
+ * One texel of a drawing as `vertices` lay it out, in world units: the square
224
+ * root of the drawing's world area over its texel area, summed over its
225
+ * triangles — the page UVs times the page's size. `null` when either is 0.
226
+ */
227
+ export function texelSize(piece: Piece, vertices: ArrayLike<number>, page: Plate): number | null {
228
+ const triangles = trianglesOf(piece);
229
+ let world = 0;
230
+ let texels = 0;
231
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
232
+ const [a, b, c] = [triangles[t], triangles[t + 1], triangles[t + 2]];
233
+ world += Math.abs(cross(vertices, a, b, c));
234
+ texels += Math.abs(cross(piece.uvs, a, b, c)) * page.width * page.height;
235
+ }
236
+ if (!(world > 0) || !(texels > 0)) return null;
237
+ return Math.sqrt(world / texels);
238
+ }
239
+
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 {
242
+ const cells = new Uint8Array(viewport.width * viewport.height);
243
+ rasterisePiece(page, piece, projector(viewport), viewport, (px, py, _r, _g, _b, a) => {
244
+ if (a >= DRAWN_ALPHA) cells[py * viewport.width + px] = 1;
245
+ });
246
+ return cells;
247
+ }
248
+
249
+ /**
250
+ * Whether `piece` draws the world point (x, y) at half opacity or more — the
251
+ * renderer's own traversal over a one-cell grid centred on the point, so the
252
+ * test is the frame's pixel test and nothing re-derived beside it.
253
+ */
254
+ function drawsAt(piece: Piece, page: Plate, x: number, y: number, cell: number): boolean {
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
+ let drawn = false;
257
+ rasterisePiece(page, piece, projector(viewport), viewport, (_px, _py, _r, _g, _b, a) => {
258
+ if (a >= DRAWN_ALPHA) drawn = true;
259
+ });
260
+ return drawn;
261
+ }
262
+
263
+ /** The world box of `xy`. */
264
+ function boxOf(xy: ArrayLike<number>): PassBox {
265
+ const box: PassBox = [Infinity, Infinity, -Infinity, -Infinity];
266
+ for (let i = 0; i + 1 < xy.length; i += 2) {
267
+ box[0] = Math.min(box[0], xy[i]);
268
+ box[1] = Math.min(box[1], xy[i + 1]);
269
+ box[2] = Math.max(box[2], xy[i]);
270
+ box[3] = Math.max(box[3], xy[i + 1]);
271
+ }
272
+ return box;
273
+ }
274
+
275
+ /**
276
+ * The affine map of the cloth triangle holding the setup-pose point (x, y):
277
+ * setup pose → frame, extended past the triangle's edges by the same map. The
278
+ * triangle is the first, by index, whose barycentric weights are all at least
279
+ * −1e-9 — a point on a shared edge is in both, and the lower index is chosen so
280
+ * one build reads one way. `null` when no triangle holds it, or the one that does
281
+ * has collapsed at the setup pose or at the frame.
282
+ */
283
+ function mapAt(
284
+ triangles: ArrayLike<number>,
285
+ rest: ArrayLike<number>,
286
+ posed: ArrayLike<number>,
287
+ x: number,
288
+ y: number,
289
+ ): ((px: number, py: number) => [number, number]) | null {
290
+ for (let t = 0; t + 2 < triangles.length; t += 3) {
291
+ const [a, b, c] = [triangles[t], triangles[t + 1], triangles[t + 2]];
292
+ const area = cross(rest, a, b, c);
293
+ if (area === 0) continue;
294
+ const weights = (px: number, py: number): [number, number, number] => {
295
+ const wa = ((rest[2 * b] - px) * (rest[2 * c + 1] - py) - (rest[2 * c] - px) * (rest[2 * b + 1] - py)) / area;
296
+ const wb = ((rest[2 * c] - px) * (rest[2 * a + 1] - py) - (rest[2 * a] - px) * (rest[2 * c + 1] - py)) / area;
297
+ return [wa, wb, 1 - wa - wb];
298
+ };
299
+ const [wa, wb, wc] = weights(x, y);
300
+ if (wa < -1e-9 || wb < -1e-9 || wc < -1e-9) continue;
301
+ if (cross(posed, a, b, c) === 0) return null;
302
+ return (px, py) => {
303
+ const [ka, kb, kc] = weights(px, py);
304
+ return [ka * posed[2 * a] + kb * posed[2 * b] + kc * posed[2 * c], ka * posed[2 * a + 1] + kb * posed[2 * b + 1] + kc * posed[2 * c + 1]];
305
+ };
306
+ }
307
+ return null;
308
+ }
309
+
310
+ /** What one read frame found: its longest run, its side, and the runs that reach the line. */
311
+ interface FrameRuns {
312
+ cells: number;
313
+ side: PassSide;
314
+ rows: number;
315
+ box: PassBox | null;
316
+ }
317
+
318
+ /**
319
+ * One frame's runs: `cloth` drawn over `leg`, `rest` the cloth attachment's
320
+ * setup-pose vertices, `cell` its texel at the setup pose. See the module comment.
321
+ */
322
+ function runsAt(
323
+ cloth: Piece,
324
+ clothPage: Plate,
325
+ rest: ArrayLike<number>,
326
+ cell: number,
327
+ stepSize: number,
328
+ leg: Piece,
329
+ legPage: Plate,
330
+ need: number,
331
+ plant: PassPlant,
332
+ ): FrameRuns {
333
+ const [x0, y0, x1, y1] = boxOf(rest);
334
+ // On multiples of the cell from the world origin, one cell of margin all round, so one build grids one way.
335
+ const minX = (Math.floor(x0 / cell) - 1) * cell;
336
+ const maxY = (Math.ceil(y1 / cell) + 1) * cell;
337
+ const width = Math.ceil((x1 - minX) / cell) + 1;
338
+ const height = Math.ceil((maxY - y0) / cell) + 1;
339
+ const viewport: Viewport = { minX, minY: maxY - height * cell, maxX: minX + width * cell, maxY, scale: 1 / cell, width, height };
340
+ const drawn = drawnCells({ ...cloth, world: Array.from(rest) }, clothPage, viewport);
341
+ const extent: Array<[number, number] | null> = [];
342
+ let top = -1;
343
+ let bottom = -1;
344
+ for (let py = 0; py < height; py++) {
345
+ let lo = -1;
346
+ let hi = -1;
347
+ for (let px = 0; px < width; px++) {
348
+ if (drawn[py * width + px] === 0) continue;
349
+ if (lo < 0) lo = px;
350
+ hi = px;
351
+ }
352
+ extent.push(lo < 0 ? null : [lo, hi]);
353
+ if (lo >= 0) {
354
+ if (top < 0) top = py;
355
+ bottom = py;
356
+ }
357
+ }
358
+ const found: FrameRuns = { cells: 0, side: 'left', rows: 0, box: null };
359
+ if (top < 0) return found;
360
+ const triangles = trianglesOf(cloth);
361
+ const legBox = boxOf(leg.world);
362
+ const reach = hypot2(legBox[2] - legBox[0], legBox[3] - legBox[1]);
363
+ let box: PassBox | null = null;
364
+ const take = (p: [number, number]): void => {
365
+ box ??= [Infinity, Infinity, -Infinity, -Infinity];
366
+ box[0] = Math.min(box[0], p[0]);
367
+ box[1] = Math.min(box[1], p[1]);
368
+ box[2] = Math.max(box[2], p[0]);
369
+ box[3] = Math.max(box[3], p[1]);
370
+ };
371
+ const runs: Record<PassSide, Array<{ cells: number; ends: [[number, number], [number, number]] }>> = { left: [], right: [] };
372
+ for (const side of ['left', 'right'] as const) {
373
+ const out = side === 'left' ? -1 : 1;
374
+ // The corner: the outermost drawn cell in the lower half of the drawn rows, the lowest on a tie.
375
+ const half = bottom - 0.5 * (bottom - top);
376
+ let corner = -1;
377
+ for (let py = bottom; py >= top && py >= half; py--) {
378
+ const row = extent[py];
379
+ if (row === null) continue;
380
+ const edge = side === 'left' ? row[0] : row[1];
381
+ const best = corner < 0 ? null : (extent[corner] as [number, number]);
382
+ if (best === null || (side === 'left' ? edge < best[0] : edge > best[1])) corner = py;
383
+ }
384
+ for (let py = corner; py >= top; py--) {
385
+ const row = extent[py];
386
+ if (row === null) continue;
387
+ const cx = minX + ((side === 'left' ? row[0] : row[1]) + 0.5) * cell;
388
+ const cy = maxY - (py + 0.5) * cell;
389
+ const map = mapAt(triangles, rest, cloth.world, cx, cy);
390
+ if (map === null) continue;
391
+ const [ox, oy] = map(cx, cy);
392
+ const [sx, sy] = map(cx + out * cell, cy);
393
+ const length = hypot2(sx - ox, sy - oy);
394
+ if (!(length > 0)) continue;
395
+ // Step k's world point: `stepSize` world units at a time along the outward direction, normalised — or, planted,
396
+ // one setup-pose cell at a time through the triangle's map, which a squashed cloth shortens.
397
+ const at = (k: number): [number, number] =>
398
+ plant.cellSteps ? map(cx + out * k * cell, cy) : [ox + ((sx - ox) / length) * k * stepSize, oy + ((sy - oy) / length) * k * stepSize];
399
+ const stride = plant.cellSteps ? length : stepSize;
400
+ const limit = Math.ceil(reach / stride) + 1;
401
+ let k = 0;
402
+ while (k < limit) {
403
+ const [wx, wy] = at(k + 1);
404
+ if (!drawsAt(leg, legPage, wx, wy, stride)) break;
405
+ k++;
406
+ }
407
+ runs[side].push({ cells: k, ends: [map(cx + out * 0.5 * cell, cy), at(k + 0.5)] });
408
+ }
409
+ }
410
+ for (const side of ['left', 'right'] as const) {
411
+ for (const run of runs[side]) {
412
+ if (run.cells > found.cells) {
413
+ found.cells = run.cells;
414
+ found.side = side;
415
+ }
416
+ if (run.cells >= need) {
417
+ found.rows++;
418
+ take(run.ends[0]);
419
+ take(run.ends[1]);
420
+ }
421
+ }
422
+ }
423
+ found.box = box;
424
+ return found;
425
+ }
426
+
427
+ const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
428
+
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 {
431
+ if (a.length !== b.length) return false;
432
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
433
+ return true;
434
+ }
435
+
436
+ /**
437
+ * Every pair's reading over one frame set: `rest` the rest table, `frames` the
438
+ * frames as the sampler returned them (pieces in draw order, attachments whole),
439
+ * `pages` the atlas pages the pieces sample. Refused by name: a cloth a clip cuts
440
+ * at a frame, whose outline is no longer its attachment's, and a cloth whose
441
+ * drawing has no area.
442
+ */
443
+ export function passThroughOf(
444
+ pairs: readonly PassPair[],
445
+ rest: readonly AttachmentRest[],
446
+ frames: readonly PassFrame[],
447
+ pages: ReadonlyMap<string, Plate>,
448
+ plant: PassPlant = {},
449
+ ): PairReading[] {
450
+ const restOf = new Map(rest.map((r) => [restKey(r.slot, r.attachment), r]));
451
+ const need = plant.run ?? PASS_THROUGH_RUN;
452
+ const pageOf = (piece: Piece): Plate => {
453
+ const page = pages.get(piece.page);
454
+ if (page === undefined) throw new PassThroughError(`slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`);
455
+ return page;
456
+ };
457
+ return pairs.map(({ cloth, leg }) => {
458
+ const reading: PairReading = { cloth, leg, drawn: 0, over: 0, frames: [], deepest: null };
459
+ for (const frame of frames) {
460
+ const ci = frame.pieces.findIndex((p) => p.slot === cloth);
461
+ const li = frame.pieces.findIndex((p) => p.slot === leg);
462
+ if (ci < 0 || li < 0) continue;
463
+ reading.drawn++;
464
+ if (!plant.ignoreDrawOrder && ci <= li) continue;
465
+ reading.over++;
466
+ const piece = frame.pieces[ci];
467
+ const pose = frame.attachments.find((a) => a.slot === cloth);
468
+ if (pose === undefined) throw new PassThroughError(`frame ${frame.index}: slot "${cloth}" draws, and the frame records no attachment for it`);
469
+ if (!sameVertices(piece.world, pose.vertices)) {
470
+ throw new PassThroughError(
471
+ `frame ${frame.index}: a clip cuts cloth slot "${cloth}" (attachment "${pose.attachment}"), so its drawn outline is no longer its attachment's and its rows cannot be read in its setup pose`,
472
+ );
473
+ }
474
+ const restEntry = restOf.get(restKey(cloth, pose.attachment));
475
+ if (restEntry === undefined) throw new PassThroughError(`frame ${frame.index}: the rest table has no entry for slot "${cloth}" attachment "${pose.attachment}"`);
476
+ const restVertices = plant.rowsInWorld ? piece.world : restEntry.vertices;
477
+ const clothPage = pageOf(piece);
478
+ const cell = texelSize(piece, restVertices, clothPage);
479
+ if (cell === null) throw new PassThroughError(`frame ${frame.index}: cloth slot "${cloth}" (attachment "${pose.attachment}") draws no area at its setup pose, so it has no texel to grid its rows by`);
480
+ // The leg's texel at its setup pose, off its own rest entry — so a leg a clip cuts is refused as the cloth is.
481
+ const legPiece = frame.pieces[li];
482
+ const legPose = frame.attachments.find((a) => a.slot === leg);
483
+ if (legPose === undefined) throw new PassThroughError(`frame ${frame.index}: slot "${leg}" draws, and the frame records no attachment for it`);
484
+ if (!sameVertices(legPiece.world, legPose.vertices)) {
485
+ throw new PassThroughError(`frame ${frame.index}: a clip cuts leg slot "${leg}" (attachment "${legPose.attachment}"), so its drawing's texel cannot be read off its setup pose`);
486
+ }
487
+ const legRest = restOf.get(restKey(leg, legPose.attachment));
488
+ if (legRest === undefined) throw new PassThroughError(`frame ${frame.index}: the rest table has no entry for slot "${leg}" attachment "${legPose.attachment}"`);
489
+ const legPage = pageOf(legPiece);
490
+ const legCell = texelSize(legPiece, legRest.vertices, legPage);
491
+ if (legCell === null) throw new PassThroughError(`frame ${frame.index}: leg slot "${leg}" (attachment "${legPose.attachment}") draws no area at its setup pose, so it has no texel to step by`);
492
+ const step = Math.max(cell, legCell);
493
+ const runs = runsAt(piece, clothPage, restVertices, cell, step, legPiece, legPage, need, plant);
494
+ const depth: PassDepth = { index: frame.index, time: frame.time, cells: runs.cells, depth: runs.cells * step, cell: step, side: runs.side };
495
+ if (reading.deepest === null || depth.cells > reading.deepest.cells) reading.deepest = depth;
496
+ if (runs.cells >= need && runs.box !== null) reading.frames.push({ ...depth, attachment: pose.attachment, rows: runs.rows, box: runs.box });
497
+ }
498
+ return reading;
499
+ });
500
+ }
501
+
502
+ /** What `pass-through.json` states its numbers mean — the one wording the file and the docs share. */
503
+ export const PASS_THROUGH_READING: PassThroughFile['reading'] = {
504
+ read: "a frame is read when both slots draw and the cloth slot draws over the leg slot in that frame's draw order; a frame where the leg draws over the cloth is not read",
505
+ drawn: `a point is drawn when the renderer paints it at ${DRAWN_ALPHA} of 255 or more (half opacity) — the slot's art through its page, tint included`,
506
+ rows:
507
+ "the cloth's rows are cells of one texel of its drawing at its setup pose (the rest table's vertices), on multiples of that texel from the world origin; on each side its corner is the outermost drawn cell in the lower half of its drawn rows, the lowest on a tie, and the rows from the corner up to its top are read",
508
+ run: "on a read row, the number of steps outward from the outermost drawn cell's centre as the frame poses it — in world units, along the setup pose's outward direction carried to the frame by the cloth triangle holding that centre — whose world point the leg draws, up to the first it does not: how far the leg's outline passes the cloth's. A step (cell) is one texel of the coarser of the two drawings at the setup pose, in world units; depth is the run times the step",
509
+ passes: `a frame passes through when some read row's run is ${PASS_THROUGH_RUN} steps or more — more than the one step an outline is resolved to`,
510
+ box: "a passing frame's box is [minX, minY, maxX, maxY] in world units (y up) over both ends of every run of that frame that reaches the line — the cloth's outline and the run's far end",
511
+ };
512
+
513
+ /** The file for one frame set. */
514
+ export function passThroughFileOf(animation: string | null, fps: number, skin: string | undefined, pairs: PairReading[]): PassThroughFile {
515
+ return { spec: PASS_THROUGH_SPEC, animation, ...(skin === undefined ? {} : { skin }), fps, reading: PASS_THROUGH_READING, pairs };
516
+ }
517
+
518
+ /** The file's text: two-space JSON and a newline, so one build and one request are one byte sequence. */
519
+ export function passThroughText(file: PassThroughFile): string {
520
+ return `${JSON.stringify(file, null, 2)}\n`;
521
+ }
522
+
523
+ /** Ascending indices as runs: `[3, 4, 5, 9]` → `3-5, 9`. */
524
+ function indexRuns(indices: readonly number[]): string {
525
+ const out: string[] = [];
526
+ for (let i = 0; i < indices.length; ) {
527
+ let j = i;
528
+ while (j + 1 < indices.length && indices[j + 1] === indices[j] + 1) j++;
529
+ out.push(j === i ? String(indices[i]) : `${indices[i]}-${indices[j]}`);
530
+ i = j + 1;
531
+ }
532
+ return out.join(', ');
533
+ }
534
+
535
+ /** `x` rounded to hundredths away from the box's inside, so the printed box holds what the exact one does. */
536
+ const outward = (x: number, up: boolean): string => ((up ? Math.ceil(x * 100) : Math.floor(x * 100)) / 100).toFixed(2);
537
+
538
+ /** A box as the lines print it, rounded outward to hundredths: `x [x0, x1] y [y0, y1] (w × h)`. */
539
+ function boxText([x0, y0, x1, y1]: PassBox): string {
540
+ return `x [${outward(x0, false)}, ${outward(x1, true)}] y [${outward(y0, false)}, ${outward(y1, true)}] (${(x1 - x0).toFixed(2)} × ${(y1 - y0).toFixed(2)})`;
541
+ }
542
+
543
+ /** A depth as the lines print it: `D units (C cells of T)`. */
544
+ function depthText(d: PassDepth): string {
545
+ return `${d.depth.toFixed(2)} units (${d.cells} step(s) of ${d.cell.toFixed(4)}) on its ${d.side} side`;
546
+ }
547
+
548
+ /**
549
+ * The summary line for one pair over one set — printed for every pair, so a pair
550
+ * that reads 0 says so and says whether it was read at all.
551
+ */
552
+ export function passSummaryLine(animation: string | null, fps: number, pair: PairReading): string {
553
+ const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
554
+ const head = `${where}: cloth "${pair.cloth}" over leg "${pair.leg}"`;
555
+ if (pair.drawn === 0) return `${head}: the two slots draw together on no frame — nothing read`;
556
+ if (pair.over === 0) {
557
+ const why = pair.cloth === pair.leg ? 'a slot does not draw over itself' : `"${pair.leg}" draws over "${pair.cloth}" wherever both draw`;
558
+ return `${head}: the cloth draws over the leg on 0 of ${pair.drawn} frame(s) both draw (${why}) — nothing read`;
559
+ }
560
+ const frames = pair.frames.map((f) => f.index);
561
+ const deepest = pair.deepest as PassDepth;
562
+ return (
563
+ `${head} passes through on ${frames.length} of ${pair.over} frame(s) read${frames.length === 0 ? '' : ` [${indexRuns(frames)}]`} at ${fps} fps; ` +
564
+ `deepest frame ${deepest.index} (t=${deepest.time.toFixed(4)}s): ${depthText(deepest)}`
565
+ );
566
+ }
567
+
568
+ /** The line for one frame that passes through: the frame, the pair, the depth and the world box. */
569
+ export function passFrameLine(animation: string | null, pair: PairReading, frame: PassFrameReading): string {
570
+ const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
571
+ return (
572
+ `${where} frame ${frame.index} (t=${frame.time.toFixed(4)}s): cloth "${pair.cloth}" over leg "${pair.leg}" by ${depthText(frame)}, ` +
573
+ `${frame.rows} row(s), in world box ${boxText(frame.box)}`
574
+ );
575
+ }