@rig-c/core 2.82.1 → 2.83.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/seam.ts CHANGED
@@ -39,34 +39,29 @@
39
39
  * read, and a pair whose band is empty on every frame both draw is a **SKIP**,
40
40
  * never a pass. The band has no width of its own: it is the overlap the art
41
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.
42
+ * - **gap** — neither drawing paints the cell, and at least one inverse
43
+ * attachment map finds setup art there. The candidate must lie in both the
44
+ * outer hull of the transported band centres AND a local sweep joining the
45
+ * SAME setup band cell through the two attachment maps. Local sweeps are
46
+ * convex hulls of that cell's eight carried corners; they do not bridge
47
+ * unrelated overlap islands or concave notches. The rig's alpha and triangle
48
+ * maps supply this joint support, with no pivot radius. This is an explicit
49
+ * geometric reading, not an inference of artistic intent or all possible tears.
50
+ * - **ghost** — a one-sided drawing carried from the setup band whose partner's
51
+ * inverse map finds no setup art. Count it only if that slot still contributes
52
+ * after source-over in frame draw order: later opaque art hides it, partial
53
+ * alpha retains a contribution. All fragments of later slots participate.
54
+ * - **exposureA/B** — whole-drawing positive-alpha cells of each named slot
55
+ * where removing all fragments of that slot leaves alpha zero. Other slots
56
+ * exclude exposure regardless of draw order, even below half opacity. Exposure
57
+ * is independent of the band and is measured with an absent partner too; it
58
+ * never enters opens/maxPx. It is not a difference from setup.
56
59
  *
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
- * The pair's gap is partitioned into gapOpen (the transparent skeleton frame
60
- * has alpha 0 there) and gapCovered (positive rendered alpha from any piece,
61
- * including a faint pair member). This does not change the half-opacity pair
62
- * predicate: another drawing can cover what the pair no longer covers.
63
- *
64
- * Known reach, stated rather than discovered: a piece folded right back over its
65
- * partner carries the band across the region it vacated, so a hull that wide
66
- * reads the vacated cells its partner's view still finds drawn; a mesh whose
67
- * vertices stretch the art the band lies in reads the stretched band, which is
68
- * what it draws. Draw order does not enter: a cell is covered or bare whichever
69
- * piece is in front.
60
+ * Hits carry cell count, area and whole-cell world box; sparse spans and earliest
61
+ * worst-frame summaries share one derivation. gapOpen/gapCovered still partition
62
+ * gap by zero/positive alpha of the transparent whole-skeleton composite.
63
+ * Shared transforms read closed. The outer-hull reach remains bounded: tears
64
+ * outside the transported setup overlap are not certified absent by a zero.
70
65
  *
71
66
  * Pure: no clock, no randomness, no runtime. The pieces, the attachments and the
72
67
  * rest table are handed in by whichever poser drew the frames.
@@ -188,6 +183,9 @@ export interface SeamPairReading {
188
183
  gapOpen: SeamKindReading;
189
184
  /** Gap cells whose skeleton composite has positive rendered alpha, even below half opacity. */
190
185
  gapCovered: SeamKindReading;
186
+ /** Positive-alpha cells contributed by the named slot where all OTHER slots have zero alpha. Independent of the band. */
187
+ exposureA: SeamKindReading;
188
+ exposureB: SeamKindReading;
191
189
  }
192
190
 
193
191
  /** `seam.json`: one frame set's reading, every pair the flag names, in the flag's order. */
@@ -198,7 +196,7 @@ export interface SeamFile {
198
196
  skin?: string;
199
197
  fps: number;
200
198
  /** What the numbers mean, stated in the file so a reader does not need this module. */
201
- reading: { read: string; cell: string; drawn: string; band: string; carried: string; gap: string; ghost: string; box: string; status: string; ghostA: string; ghostB: string; gapOpen: string; gapCovered: string };
199
+ reading: { read: string; cell: string; drawn: string; band: string; carried: string; gap: string; ghost: string; box: string; status: string; ghostA: string; ghostB: string; gapOpen: string; gapCovered: string; exposureA: string; exposureB: string };
202
200
  pairs: SeamPairReading[];
203
201
  }
204
202
 
@@ -217,6 +215,10 @@ export interface SeamPlant {
217
215
  ghostAnyArt?: boolean;
218
216
  /** Attribute every ghost to a, regardless of which piece draws it. */
219
217
  ghostOnlyA?: boolean;
218
+ /** Ignore later drawings when reading ghosts. */
219
+ hiddenGhost?: boolean;
220
+ /** Bridge unrelated overlap cells with the old global hull. */
221
+ globalHull?: boolean;
220
222
  /** Wrong composite readings: half opacity, only the pair, or every gap forced to one kind. */
221
223
  gapCoverage?: 'half' | 'pair' | 'open' | 'covered';
222
224
  /** Report a pair with no band as read, rather than as a SKIP. */
@@ -387,11 +389,10 @@ function frameAlphaCells(pieces: readonly Piece[], pages: ReadonlyMap<string, Pl
387
389
  return cells;
388
390
  }
389
391
 
390
- /** One frame's counts, or `null` when the two drawings share no cell at the setup pose. See the module comment. */
391
- function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: SeamFrame, pages: ReadonlyMap<string, Plate>): { gap: Cells; ghost: Cells; ghostA: Cells; ghostB: Cells; gapOpen: Cells; gapCovered: Cells } | null {
392
- // One grid over both drawings at the setup pose and at the frame, on multiples of the cell from the world origin.
392
+ /** One origin-aligned texel grid, also used when only one named slot draws. */
393
+ function gridOf(sides: readonly Side[], cell: number, extra: readonly Piece[] = []): Grid {
393
394
  let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
394
- for (const xy of [A.rest, B.rest, A.piece.world, B.piece.world]) {
395
+ for (const xy of [...sides.flatMap((side) => [side.rest, side.piece.world]), ...extra.map((piece) => piece.world)]) {
395
396
  for (let i = 0; i + 1 < xy.length; i += 2) {
396
397
  x0 = Math.min(x0, xy[i]);
397
398
  y0 = Math.min(y0, xy[i + 1]);
@@ -403,7 +404,78 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
403
404
  const maxY = (Math.ceil(y1 / cell) + 1) * cell;
404
405
  const width = Math.ceil((x1 - minX) / cell) + 1;
405
406
  const height = Math.ceil((maxY - y0) / cell) + 1;
406
- const grid: Grid = { viewport: { minX, minY: maxY - height * cell, maxX: minX + width * cell, maxY, scale: 1 / cell, width, height }, cell };
407
+ return { viewport: { minX, minY: maxY - height * cell, maxX: minX + width * cell, maxY, scale: 1 / cell, width, height }, cell };
408
+ }
409
+
410
+ const emptyCells = (): Cells => ({ px: 0, box: null });
411
+ function takeCell(into: Cells, qx: number, qy: number, cell: number): void {
412
+ into.px++;
413
+ const h = cell / 2;
414
+ into.box ??= [Infinity, Infinity, -Infinity, -Infinity];
415
+ into.box[0] = Math.min(into.box[0], qx - h);
416
+ into.box[1] = Math.min(into.box[1], qy - h);
417
+ into.box[2] = Math.max(into.box[2], qx + h);
418
+ into.box[3] = Math.max(into.box[3], qy + h);
419
+ }
420
+
421
+ /**
422
+ * Symbolic nonzero source-over contribution per named slot. Later alpha 255
423
+ * erases earlier contributions; any positive emission of that slot adds one.
424
+ * Partial alpha cannot erase a nonzero contribution. Traverse EVERY fragment,
425
+ * including multiple pieces of the same slot and clipped source meshes.
426
+ * Exposure asks a separate counterfactual: would removing this slot leave
427
+ * alpha zero? It therefore excludes even faint art of any other slot, before
428
+ * OR after the named slot. No setup comparison or seam band enters.
429
+ */
430
+ function slotCells(frame: SeamFrame, pages: ReadonlyMap<string, Plate>, grid: Grid, slots: readonly string[]): { visible: Uint8Array; exposure: Cells }[] {
431
+ const n = grid.viewport.width * grid.viewport.height;
432
+ const masks = slots.map(() => ({ visible: new Uint8Array(n), own: new Uint8Array(n), other: new Uint8Array(n), exposure: emptyCells() }));
433
+ const project = projector(grid.viewport);
434
+ for (const piece of frame.pieces) {
435
+ const page = pages.get(piece.page);
436
+ if (page === undefined) throw new SeamError(`slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`);
437
+ rasterisePiece(page, piece, project, grid.viewport, (x, y, _r, _g, _b, alpha) => {
438
+ const k = y * grid.viewport.width + x;
439
+ for (let i = 0; i < slots.length; i++) {
440
+ const m = masks[i];
441
+ if (alpha === 255) m.visible[k] = 0;
442
+ if (alpha > 0) {
443
+ if (piece.slot === slots[i]) m.own[k] = m.visible[k] = 1;
444
+ else m.other[k] = 1;
445
+ }
446
+ }
447
+ });
448
+ }
449
+ for (const m of masks) for (let k = 0; k < n; k++) if (m.own[k] && !m.other[k]) {
450
+ takeCell(m.exposure, centreX(grid, k % grid.viewport.width), centreY(grid, Math.floor(k / grid.viewport.width)), grid.cell);
451
+ }
452
+ return masks;
453
+ }
454
+
455
+ /** Rasterise a convex swept cell by scanline; never fill between unrelated band cells. */
456
+ function markHull(hull: ReadonlyArray<[number, number]>, grid: Grid, cells: Uint8Array): void {
457
+ if (hull.length === 0) return;
458
+ const { minX, maxY, width, height } = grid.viewport;
459
+ const lo = Math.max(0, Math.ceil((maxY - Math.max(...hull.map((p) => p[1]))) / grid.cell - 0.5));
460
+ const hi = Math.min(height - 1, Math.floor((maxY - Math.min(...hull.map((p) => p[1]))) / grid.cell - 0.5));
461
+ for (let py = lo; py <= hi; py++) {
462
+ const y = centreY(grid, py);
463
+ let left = Infinity, right = -Infinity;
464
+ for (let i = 0; i < hull.length; i++) {
465
+ const [x0, y0] = hull[i], [x1, y1] = hull[(i + 1) % hull.length];
466
+ if (y < Math.min(y0, y1) || y > Math.max(y0, y1)) continue;
467
+ if (y0 === y1) { left = Math.min(left, x0, x1); right = Math.max(right, x0, x1); }
468
+ else { const x = x0 + (y - y0) * (x1 - x0) / (y1 - y0); left = Math.min(left, x); right = Math.max(right, x); }
469
+ }
470
+ const x0 = Math.max(0, Math.ceil((left - minX) / grid.cell - 0.5));
471
+ const x1 = Math.min(width - 1, Math.floor((right - minX) / grid.cell - 0.5));
472
+ for (let px = x0; px <= x1; px++) cells[py * width + px] = 1;
473
+ }
474
+ }
475
+
476
+ /** One frame's counts, or `null` when the two drawings share no cell at the setup pose. See the module comment. */
477
+ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: SeamFrame, pages: ReadonlyMap<string, Plate>, grid: Grid, visibility: readonly Uint8Array[]): { gap: Cells; ghost: Cells; ghostA: Cells; ghostB: Cells; gapOpen: Cells; gapCovered: Cells } | null {
478
+ const { width, height } = grid.viewport;
407
479
  const restA = restPiece(A);
408
480
  const restB = restPiece(B);
409
481
  const bandA = drawnCells(restA, A.page, grid.viewport);
@@ -411,7 +483,13 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
411
483
  // The band: both drawings at the setup pose. Carried to the frame by each piece, its centres span the joint's hull.
412
484
  const restIdsA = triangleIds(A.triangles, A.rest, grid);
413
485
  const restIdsB = triangleIds(B.triangles, B.rest, grid);
486
+ // The corner lattice is offset by half a cell. Reuse the same lowest-index
487
+ // containing-triangle rule as centres, including overlapping setup meshes.
488
+ const corners: Grid = { cell, viewport: { ...grid.viewport, minX: grid.viewport.minX - cell / 2, minY: grid.viewport.minY - cell / 2, maxX: grid.viewport.maxX + cell / 2, maxY: grid.viewport.maxY + cell / 2, width: width + 1, height: height + 1 } };
489
+ const cornerIdsA = triangleIds(A.triangles, A.rest, corners);
490
+ const cornerIdsB = triangleIds(B.triangles, B.rest, corners);
414
491
  const carried: Array<[number, number]> = [];
492
+ const joint = new Uint8Array(width * height);
415
493
  let band = 0;
416
494
  let [bx0, by0, bx1, by1] = [Infinity, Infinity, -Infinity, -Infinity];
417
495
  for (let py = 0; py < height; py++) {
@@ -422,16 +500,27 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
422
500
  const qx = centreX(grid, px);
423
501
  const qy = centreY(grid, py);
424
502
  [bx0, by0, bx1, by1] = [Math.min(bx0, qx), Math.min(by0, qy), Math.max(bx1, qx), Math.max(by1, qy)];
425
- for (const [side, ids] of [[A, restIdsA], [B, restIdsB]] as const) {
503
+ const local: Array<[number, number]> = [];
504
+ for (const [side, ids, cornerIds] of [[A, restIdsA, cornerIdsA], [B, restIdsB, cornerIdsB]] as const) {
426
505
  const p = plant.world ? ([qx, qy] as [number, number]) : carry(side.triangles, side.rest, side.piece.world, ids[k], qx, qy);
427
506
  if (p !== null) carried.push(p);
507
+ // The same material cell through BOTH attachment maps. Corners use
508
+ // their own triangle when the cell crosses a skinned mesh edge.
509
+ for (const ox of [0, 1]) for (const oy of [0, 1]) {
510
+ const x = grid.viewport.minX + (px + ox) * cell, y = grid.viewport.maxY - (py + oy) * cell;
511
+ const id = cornerIds[(py + oy) * (width + 1) + px + ox];
512
+ const corner = plant.world ? [x, y] as [number, number] : carry(side.triangles, side.rest, side.piece.world, id, x, y);
513
+ if (corner !== null) local.push(corner);
514
+ }
428
515
  }
516
+ markHull(hullOf(local), grid, joint);
429
517
  }
430
518
  }
431
519
  if (band === 0) return null;
432
520
  const hull = hullOf(carried);
433
521
  const drawnA = drawnCells(A.piece, A.page, grid.viewport);
434
522
  const drawnB = drawnCells(B.piece, B.page, grid.viewport);
523
+ const [visibleA, visibleB] = visibility;
435
524
  const posedIdsA = triangleIds(A.triangles, A.piece.world, grid);
436
525
  const posedIdsB = triangleIds(B.triangles, B.piece.world, grid);
437
526
  // A frame point carried back into a side's setup pose.
@@ -446,15 +535,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
446
535
  const gapOpen: Cells = { px: 0, box: null };
447
536
  const gapCovered: Cells = { px: 0, box: null };
448
537
  let alphaCells: Uint8Array | null = null;
449
- const take = (into: Cells, qx: number, qy: number): void => {
450
- into.px++;
451
- const h = cell / 2;
452
- into.box ??= [Infinity, Infinity, -Infinity, -Infinity];
453
- into.box[0] = Math.min(into.box[0], qx - h);
454
- into.box[1] = Math.min(into.box[1], qy - h);
455
- into.box[2] = Math.max(into.box[2], qx + h);
456
- into.box[3] = Math.max(into.box[3], qy + h);
457
- };
538
+ const take = (into: Cells, qx: number, qy: number): void => takeCell(into, qx, qy, cell);
458
539
  if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
459
540
  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;
460
541
  for (let py = 0; py < height; py++) {
@@ -465,7 +546,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
465
546
  const a = drawnA[k] !== 0;
466
547
  const b = drawnB[k] !== 0;
467
548
  if (!a && !b) {
468
- if (!plant.noHull && !inHull(hull, qx, qy)) continue;
549
+ if (!plant.noHull && (!inHull(hull, qx, qy) || (!plant.globalHull && !joint[k]))) continue;
469
550
  const fromA = composite(back(A, posedIdsA, k, qx, qy));
470
551
  const fromB = composite(back(B, posedIdsB, k, qx, qy));
471
552
  if (plant.bothViews ? fromA && fromB : fromA || fromB) {
@@ -477,6 +558,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
477
558
  continue;
478
559
  }
479
560
  if (a && b) continue;
561
+ if (!plant.hiddenGhost && !(a ? visibleA[k] : visibleB[k])) continue;
480
562
  // One piece draws the cell and its partner does not.
481
563
  const [mine, myIds, myRest, other, otherIds] = a ? [A, posedIdsA, restA, B, posedIdsB] : [B, posedIdsB, restB, A, posedIdsA];
482
564
  const otherRest = a ? restB : restA;
@@ -551,17 +633,25 @@ export function seamsOf(
551
633
  const ghostsB: SeamHit[] = [];
552
634
  const openGaps: SeamHit[] = [];
553
635
  const coveredGaps: SeamHit[] = [];
636
+ const exposuresA: SeamHit[] = [], exposuresB: SeamHit[] = [];
554
637
  for (const frame of frames) {
555
638
  const A = sideOf(frame, a);
556
639
  const B = sideOf(frame, b);
640
+ const sides = [A, B].filter((side): side is Side => side !== null);
641
+ if (sides.length === 0) continue;
642
+ const sizes = sides.map((side) => texelSize(side.piece, side.rest, side.page));
643
+ if (sizes.some((size) => size === null)) throw new SeamError(`frame ${frame.index}: slot "${sides[sizes.indexOf(null)].piece.slot}" draws no area at its setup pose, so it has no texel to grid the seam by`);
644
+ const exposureCell = Math.max(...sizes.filter((size): size is number => size !== null));
645
+ const exposureGrid = gridOf(sides, exposureCell, frame.pieces.filter((piece) => piece.slot === a || piece.slot === b));
646
+ const exposure = slotCells(frame, pages, exposureGrid, [a, b]);
647
+ for (const [i, into] of [[0, exposuresA], [1, exposuresB]] as const) {
648
+ const cells = exposure[i].exposure;
649
+ if (cells.px > 0 && cells.box !== null) into.push({ index: frame.index, time: frame.time, px: cells.px, cell: exposureCell, area: cells.px * exposureCell * exposureCell, box: cells.box });
650
+ }
557
651
  if (A === null || B === null) continue;
558
652
  drawn++;
559
- const cellA = texelSize(A.piece, A.rest, A.page);
560
- const cellB = texelSize(B.piece, B.rest, B.page);
561
- const flat = cellA === null ? a : cellB === null ? b : null;
562
- 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`);
563
- const cell = Math.max(cellA, cellB);
564
- const read = readFrame(A, B, cell, plant, frame, pages);
653
+ const cell = exposureCell;
654
+ const read = readFrame(A, B, cell, plant, frame, pages, exposureGrid, exposure.map((m) => m.visible));
565
655
  if (read === null) continue;
566
656
  banded++;
567
657
  for (const [cells, into] of [[read.gap, gaps], [read.ghost, ghosts], [read.ghostA, ghostsA], [read.ghostB, ghostsB], [read.gapOpen, openGaps], [read.gapCovered, coveredGaps]] as const) {
@@ -573,25 +663,27 @@ export function seamsOf(
573
663
  const opens = [...new Set([...gaps, ...ghosts].map((f) => f.index))].sort((x, y) => x - y);
574
664
  const maxPx = Math.max(0, ...gaps.map((f) => f.px), ...ghosts.map((f) => f.px));
575
665
  const status = banded === 0 && !plant.skipAsRead ? 'skip' : 'read';
576
- return { a, b, drawn, banded, status, opens, maxPx, gap, ghost, ghostA: kindOf(ghostsA), ghostB: kindOf(ghostsB), gapOpen: kindOf(openGaps), gapCovered: kindOf(coveredGaps) };
666
+ return { a, b, drawn, banded, status, opens, maxPx, gap, ghost, ghostA: kindOf(ghostsA), ghostB: kindOf(ghostsB), gapOpen: kindOf(openGaps), gapCovered: kindOf(coveredGaps), exposureA: kindOf(exposuresA), exposureB: kindOf(exposuresB) };
577
667
  });
578
668
  }
579
669
 
580
670
  /** What `seam.json` states its numbers mean — the one wording the file and the docs share. */
581
671
  export const SEAM_READING: SeamFile['reading'] = {
582
- 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',
583
- 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²",
672
+ read: 'a frame is read when both slots draw and their two drawings share a cell at the setup pose (the band); ghost visibility follows frame draw order; exposure is also measured without a band',
673
+ cell: "every count is of cells one texel of the coarser of the two drawings at the setup pose (the rest table's vertices; the present member alone for exposure when its partner is absent), 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²",
584
674
  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`,
585
675
  band: "the band is the cells both drawings draw at the setup pose — where the two were painted to overlap, dilated by nothing",
586
676
  carried:
587
677
  "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",
588
- 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",
589
- 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",
678
+ gap: "a gap cell lies inside the convex hull of the band's cell centres carried to the frame by both pieces, is inside the union of local sweeps of the SAME setup-overlap cell through both attachment maps, 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",
679
+ ghost: "a ghost cell has nonzero source-over contribution from its named slot after all later drawings (opaque later art hides it; partial alpha does not), 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",
590
680
  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",
591
681
  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',
592
682
  ghostA: 'the ghost cells drawn by a only, with the same frames, spans and worst shape as ghost; ghostA and ghostB partition ghost on every frame',
593
683
  ghostB: 'the ghost cells drawn by b only; a and b are the named pair order, not which piece is in front — reversing the pair exchanges ghostA and ghostB',
594
684
  gapOpen: 'the gap cells whose centre has alpha 0 in the transparent frame composite of every skeleton drawing, using the renderer on the same cell grid; gapOpen and gapCovered partition gap',
685
+ exposureA: 'positive-rendered-alpha cells from named slot a where removing every fragment of that slot leaves alpha 0 from all other skeleton slots, regardless of draw order; measured over the whole drawing even without a seam band or partner; same hit, span and worst units as gap',
686
+ exposureB: 'the same background exposure for named slot b; swapping the pair exchanges exposureA and exposureB; exposure does not enter opens or maxPx and is not a change-from-setup measurement',
595
687
  gapCovered: 'the gap cells whose centre has positive rendered alpha from any skeleton drawing, including the pair; alpha 1..127 covers too — this is not the pair\'s half-opacity test, and no presentation background enters',
596
688
  };
597
689
 
@@ -609,7 +701,7 @@ export function seamText(file: SeamFile): string {
609
701
  const outward = (x: number, up: boolean): string => ((up ? Math.ceil(x * 100) : Math.floor(x * 100)) / 100).toFixed(2);
610
702
 
611
703
  /** 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`. */
612
- function kindText(kind: 'gap' | 'ghost', reading: SeamKindReading): string {
704
+ function kindText(kind: string, reading: SeamKindReading): string {
613
705
  const w = reading.worst;
614
706
  if (w === null) return `no ${kind}`;
615
707
  const [x0, y0, x1, y1] = w.box;
@@ -624,7 +716,7 @@ function kindText(kind: 'gap' | 'ghost', reading: SeamKindReading): string {
624
716
  * The line for one pair over one set — printed for every pair, so a pair that
625
717
  * stays closed says so, and a pair with nothing to read says SKIP and why.
626
718
  */
627
- export function seamLine(animation: string | null, fps: number, pair: SeamPairReading): string {
719
+ function seamSummary(animation: string | null, fps: number, pair: SeamPairReading): string {
628
720
  const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
629
721
  const head = `${where}: seam "${pair.a}" | "${pair.b}"`;
630
722
  if (pair.drawn === 0) return `${head}: SKIP — the two slots draw together on no frame, so there is no seam to read`;
@@ -635,3 +727,8 @@ export function seamLine(animation: string | null, fps: number, pair: SeamPairRe
635
727
  `${kindText('gap', pair.gap)}; ${kindText('ghost', pair.ghost)}`
636
728
  );
637
729
  }
730
+
731
+ /** The seam summary plus whole-drawing background exposures, including on SKIP pairs. */
732
+ export function seamLine(animation: string | null, fps: number, pair: SeamPairReading): string {
733
+ return `${seamSummary(animation, fps, pair)}; ${kindText(`exposure ${JSON.stringify(pair.a)}`, pair.exposureA)}; ${kindText(`exposure ${JSON.stringify(pair.b)}`, pair.exposureB)}`;
734
+ }