@rig-c/core 2.67.2 → 2.68.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/README.md CHANGED
@@ -606,7 +606,7 @@ commands take it and what its default is.
606
606
  | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
607
607
  | `split --rig … --motion … --slot <name> --out <dir>` | the answer to a mesh refused because its triangles are **several pieces that share no vertex** — two legs after the triangles bridging them were deleted. It reads the two specs and writes them back into an absent or empty `--out` with one mesh per piece: piece 1 stays in the slot, piece k gets a new slot `<slot>_<k>` after it, each with the original's uvs and weights unchanged, its triangles renumbered outline first where the hull rule needs it, and every deform key re-sliced to the new indices — plus `split.json`, where every original vertex and triangle went. One piece is said so and nothing is written; what it cannot carry is refused by name ([AUTHORING §3.4.11](docs/AUTHORING.md)) |
608
608
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
609
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`, one directory per set: `<anim>@<fps>fps/` rather than `<anim>/` at any rate but the default — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box — a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box — a reading, not a gate; the exit status never moves. `--overlaps` adds an `overlaps.json` per set and prints one `OVERLAP "<under>" under "<over>"` line per pair of slots where the one drawn later covers the earlier one and the motion moves it — the frames, and the most pixels covered and the frame of them — with an outline plate per covered frame; the render reports what was drawn, and the draw-order key is the author's remedy — a reading, not a gate. `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on — a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time — the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene |
609
+ | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`, one directory per set: `<anim>@<fps>fps/` rather than `<anim>/` at any rate but the default — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box — a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box; `ghostA` and `ghostB` partition the same ghost by which named pair member drew it, each with its own frames, spans and worst; `gapOpen` and `gapCovered` partition `gap` by zero versus positive rendered alpha from the whole skeleton, including faint and clipped drawings ([AUTHORING §8.8](docs/AUTHORING.md#88-where-two-slots-stop-meeting--render---seam)) — a reading, not a gate; the exit status never moves. `--overlaps` adds an `overlaps.json` per set and prints one `OVERLAP "<under>" under "<over>"` line per pair of slots where the one drawn later covers the earlier one and the motion moves it — the frames, and the most pixels covered and the frame of them — with an outline plate per covered frame; the render reports what was drawn, and the draw-order key is the author's remedy — a reading, not a gate. `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on — a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time — the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene |
610
610
  | `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
611
611
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
612
612
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
package/docs/AUTHORING.md CHANGED
@@ -8933,7 +8933,8 @@ table's vertices, and the atlas alpha:
8933
8933
  - **Drawn** is the renderer painting a cell's centre at **128 of 255** or more —
8934
8934
  the half-opacity line `--pass-through` draws outlines at, the frame's tint
8935
8935
  included, the setup pose drawn at the same tint (so a slot faded out is not a
8936
- tear: it is faded in both). No other alpha enters.
8936
+ tear: it is faded in both). This threshold defines the pair's band, gap and
8937
+ ghost; the composite coverage partition below asks a different question.
8937
8938
  - **The band** is the cells both drawings draw at the setup pose — where the two
8938
8939
  were painted to overlap. It has **no width of its own**: it is the overlap the
8939
8940
  art states, dilated by nothing. A frame whose band is empty is not read; a pair
@@ -8949,8 +8950,30 @@ table's vertices, and the atlas alpha:
8949
8950
  the drawing piece, carrying it back, lands in the band, and which the other,
8950
8951
  carrying it back, finds neither drawing at at the setup pose: the overlap swung
8951
8952
  clear of its partner.
8953
+ - **ghostA**, **ghostB** — that same ghost partitioned by the piece drawing the
8954
+ cell: `a` only or `b` only, respectively, in the flag's named pair order. On
8955
+ each frame `ghost.px = ghostA.px + ghostB.px` (an absent hit contributes 0),
8956
+ and each owner's box lies inside the union's. Reversing the pair exchanges
8957
+ the complete owner readings and preserves `gap` and `ghost`.
8958
+ - **gapOpen**, **gapCovered** — the same gap cells partitioned by the actual
8959
+ skeleton composite at their centres, on the same cell grid, over a transparent
8960
+ background: alpha **0** is open; alpha **>0** is covered. Every skeleton drawing
8961
+ enters, including the pair itself, with the frame's tint, UVs and clipping.
8962
+ Alpha **1..127 covers too**: this is not the pair's half-opacity outline test.
8963
+ The renderer's existing byte quantization remains in force: it emits rounded
8964
+ alpha only when the sampled, tinted value exceeds 0.5 on the 0..255 scale;
8965
+ a value that produces no rendered alpha byte is open. No display background
8966
+ enters. Source-over cannot erase positive alpha, so the renderer's positive
8967
+ emissions give exactly the nonzero alpha of its transparent composite.
8968
+
8969
+ On every frame `gap.px = gapOpen.px + gapCovered.px` (missing hit means 0).
8970
+ Each partition's box lies in the union's. A gap in the pair can be covered by a
8971
+ third drawing and need not be a visible opening. Reversing the pair preserves
8972
+ both gap coverage readings.
8952
8973
 
8953
8974
  Draw order does not enter: a cell is covered or bare whichever piece is in front.
8975
+ An owner says which piece drew a ghost cell; it does not say which piece is over
8976
+ or under the other, or whether that exposure should fail a consumer's policy.
8954
8977
 
8955
8978
  **Its reach, stated.** A piece folded right back over its partner carries the band
8956
8979
  across the region it vacated, so a hull that wide reads the vacated cells its
@@ -8970,7 +8993,7 @@ block carries the definitions above in words.
8970
8993
  | `animation` | string or `null` | — | the set's animation; `null` for a skeleton with none (its one frame is the setup pose) |
8971
8994
  | `skin` | string | — | the skin asked for; **absent** when none was |
8972
8995
  | `fps` | number | frames per second | the rate the set was sampled at |
8973
- | `reading` | object | — | `read`, `cell`, `drawn`, `band`, `carried`, `gap`, `ghost`, `box`, `status`: the definitions, in words |
8996
+ | `reading` | object | — | `read`, `cell`, `drawn`, `band`, `carried`, `gap`, `ghost`, `box`, `status`, `ghostA`, `ghostB`, `gapOpen`, `gapCovered`: the definitions, in words |
8974
8997
  | `pairs[]` | array | — | one per pair, in the flag's order |
8975
8998
  | `pairs[].a`, `pairs[].b` | string | — | the two slots, as the flag names them |
8976
8999
  | `pairs[].drawn` | integer | frames | frames on which both slots draw |
@@ -8979,6 +9002,8 @@ block carries the definitions above in words.
8979
9002
  | `pairs[].opens` | integer[] | frame indices | every frame with a gap or a ghost, ascending |
8980
9003
  | `pairs[].maxPx` | integer | px (cells) | the most cells of either kind on any frame; 0 when none opens |
8981
9004
  | `pairs[].gap`, `pairs[].ghost` | object | — | one per kind, each `{ frames, spans, worst }` |
9005
+ | `pairs[].ghostA`, `pairs[].ghostB` | object | — | the same `{ frames, spans, worst }` shape for ghost cells drawn by `a` only and `b` only; an empty owner has `frames: []`, `spans: []`, `worst: null` |
9006
+ | `pairs[].gapOpen`, `pairs[].gapCovered` | object | — | the same `{ frames, spans, worst }` shape for gap cells with zero or positive rendered skeleton-composite alpha, respectively; an empty kind has `frames: []`, `spans: []`, `worst: null` |
8982
9007
  | `<kind>.frames[]` | hit[] | — | only the frames with a cell of that kind, in frame order |
8983
9008
  | `<kind>.spans[]` | array | — | `<kind>.frames` as runs of consecutive indices: `{ frames: [first, last], times: [first, last], maxPx }` — frame indices, seconds, px |
8984
9009
  | `<kind>.worst` | hit or `null` | — | the frame with the most cells, the earliest on a tie; `null` when none |
@@ -8995,6 +9020,16 @@ span.times, maxPx: span.maxPx, kind }`, and one pair over one animation is
8995
9020
  `opens.length` frame(s) `[opens]`, max `maxPx` px — with `status: "skip"` the case
8996
9021
  to say SKIP for.
8997
9022
 
9023
+ The owner keys and their two legend entries are additive in `rigc-seam/1`.
9024
+ Removing just those four keys leaves the previous file's bytes after the same
9025
+ two-space JSON serialization and newline. The union, its spans and worst, and
9026
+ the printed summary retain their existing meanings; an owner may have different
9027
+ spans or a different worst frame from the union.
9028
+ Likewise `gapOpen`, `gapCovered` and their two legend entries are additive:
9029
+ removing only those entries leaves the previous file's bytes after the same
9030
+ serialization, including `gap`, `ghostA` and `ghostB`. The coverage partition may
9031
+ have different spans and worst frames from the union; it changes no seam policy.
9032
+
8998
9033
  **Refused, nothing written:** a slot the skeleton does not have (with the slots it
8999
9034
  does), an entry without exactly one `:`, an empty entry or name, a slot paired with
9000
9035
  itself (a seam is between two drawings), a pair named twice in either order (the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.67.2",
3
+ "version": "2.68.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/seam.ts CHANGED
@@ -56,6 +56,10 @@
56
56
  *
57
57
  * Each frame's figures are the cell counts (`px`), the cell size, their area,
58
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.
59
63
  *
60
64
  * Known reach, stated rather than discovered: a piece folded right back over its
61
65
  * partner carries the band across the region it vacated, so a hull that wide
@@ -71,13 +75,13 @@
71
75
  import type { Plate } from '../tools/plate.ts';
72
76
  import { indexRuns } from './folds.ts';
73
77
  import { DRAWN_ALPHA, drawnCells, drawsAt, sameVertices, texelSize } from './passthrough.ts';
74
- import type { AttachmentPose, AttachmentRest, Piece, Viewport } from './render_shared.ts';
78
+ import { type AttachmentPose, type AttachmentRest, type Piece, projector, rasterisePiece, type Viewport } from './render_shared.ts';
75
79
 
76
80
  /** The reading's file name inside an animation's frame directory, and its format tag. */
77
81
  export const SEAM_FILE = 'seam.json';
78
82
  export const SEAM_SPEC = 'rigc-seam/1';
79
83
 
80
- /** One pair as the flag names it. The reading is symmetric: `a:b` and `b:a` are one pair. */
84
+ /** One pair as the flag names it. Reversing it preserves the union and exchanges the ghost owners. */
81
85
  export interface SeamPair {
82
86
  a: string;
83
87
  b: string;
@@ -176,6 +180,14 @@ export interface SeamPairReading {
176
180
  maxPx: number;
177
181
  gap: SeamKindReading;
178
182
  ghost: SeamKindReading;
183
+ /** Ghost cells drawn by `a` only; together with ghostB, an exact partition of ghost. */
184
+ ghostA: SeamKindReading;
185
+ /** Ghost cells drawn by `b` only. Ownership is the flag's order, not draw order. */
186
+ ghostB: SeamKindReading;
187
+ /** Gap cells whose skeleton composite has no rendered alpha at the cell centre. */
188
+ gapOpen: SeamKindReading;
189
+ /** Gap cells whose skeleton composite has positive rendered alpha, even below half opacity. */
190
+ gapCovered: SeamKindReading;
179
191
  }
180
192
 
181
193
  /** `seam.json`: one frame set's reading, every pair the flag names, in the flag's order. */
@@ -186,12 +198,12 @@ export interface SeamFile {
186
198
  skin?: string;
187
199
  fps: number;
188
200
  /** 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 };
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 };
190
202
  pairs: SeamPairReading[];
191
203
  }
192
204
 
193
205
  /**
194
- * A planted defect, for the suite's mutants only (`SM` controls in
206
+ * A planted defect, for the suite's mutants only (`SE` controls in
195
207
  * `selftest.ts`). Production passes none.
196
208
  */
197
209
  export interface SeamPlant {
@@ -203,6 +215,10 @@ export interface SeamPlant {
203
215
  bothViews?: boolean;
204
216
  /** Count a ghost cell wherever a piece draws what its partner's view had none of, not only from the band. */
205
217
  ghostAnyArt?: boolean;
218
+ /** Attribute every ghost to a, regardless of which piece draws it. */
219
+ ghostOnlyA?: boolean;
220
+ /** Wrong composite readings: half opacity, only the pair, or every gap forced to one kind. */
221
+ gapCoverage?: 'half' | 'pair' | 'open' | 'covered';
206
222
  /** Report a pair with no band as read, rather than as a SKIP. */
207
223
  skipAsRead?: boolean;
208
224
  /** Count nothing. */
@@ -347,8 +363,28 @@ interface Cells {
347
363
 
348
364
  const restPiece = (side: Side): Piece => ({ ...side.piece, world: Array.from(side.rest) });
349
365
 
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 {
366
+ /**
367
+ * Positive alpha of the transparent skeleton composite, on the pair's cell grid.
368
+ * The renderer emits rounded alpha bytes through this same traversal, including
369
+ * tint, UV windows and already-clipped pieces. Source-over cannot erase positive
370
+ * alpha: OR of positive emissions is exactly composite alpha > 0. No background
371
+ * enters, and the pair's half-opacity outline is not this test.
372
+ */
373
+ function frameAlphaCells(pieces: readonly Piece[], pages: ReadonlyMap<string, Plate>, viewport: Viewport, half: boolean): Uint8Array {
374
+ const cells = new Uint8Array(viewport.width * viewport.height);
375
+ const project = projector(viewport);
376
+ for (const piece of pieces) {
377
+ const page = pages.get(piece.page);
378
+ if (page === undefined) throw new SeamError(`slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`);
379
+ rasterisePiece(page, piece, project, viewport, (x, y, _r, _g, _b, alpha) => {
380
+ if (half ? alpha >= DRAWN_ALPHA : alpha > 0) cells[y * viewport.width + x] = 1;
381
+ });
382
+ }
383
+ return cells;
384
+ }
385
+
386
+ /** One frame's counts, or `null` when the two drawings share no cell at the setup pose. See the module comment. */
387
+ 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 {
352
388
  // One grid over both drawings at the setup pose and at the frame, on multiples of the cell from the world origin.
353
389
  let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
354
390
  for (const xy of [A.rest, B.rest, A.piece.world, B.piece.world]) {
@@ -401,6 +437,11 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant): { gap: Cel
401
437
  const composite = (p: [number, number] | null): boolean => drawnAtRest(p, restA, A.page) || drawnAtRest(p, restB, B.page);
402
438
  const gap: Cells = { px: 0, box: null };
403
439
  const ghost: Cells = { px: 0, box: null };
440
+ const ghostA: Cells = { px: 0, box: null };
441
+ const ghostB: Cells = { px: 0, box: null };
442
+ const gapOpen: Cells = { px: 0, box: null };
443
+ const gapCovered: Cells = { px: 0, box: null };
444
+ let alphaCells: Uint8Array | null = null;
404
445
  const take = (into: Cells, qx: number, qy: number): void => {
405
446
  into.px++;
406
447
  const h = cell / 2;
@@ -410,7 +451,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant): { gap: Cel
410
451
  into.box[2] = Math.max(into.box[2], qx + h);
411
452
  into.box[3] = Math.max(into.box[3], qy + h);
412
453
  };
413
- if (plant.never) return { gap, ghost };
454
+ if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
414
455
  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
456
  for (let py = 0; py < height; py++) {
416
457
  for (let px = 0; px < width; px++) {
@@ -423,7 +464,12 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant): { gap: Cel
423
464
  if (!plant.noHull && !inHull(hull, qx, qy)) continue;
424
465
  const fromA = composite(back(A, posedIdsA, k, qx, qy));
425
466
  const fromB = composite(back(B, posedIdsB, k, qx, qy));
426
- if (plant.bothViews ? fromA && fromB : fromA || fromB) take(gap, qx, qy);
467
+ if (plant.bothViews ? fromA && fromB : fromA || fromB) {
468
+ take(gap, qx, qy);
469
+ const covered = plant.gapCoverage === 'covered' || (plant.gapCoverage !== 'open' &&
470
+ (alphaCells ??= frameAlphaCells(plant.gapCoverage === 'pair' ? [A.piece, B.piece] : frame.pieces, pages, grid.viewport, plant.gapCoverage === 'half'))[k] !== 0);
471
+ take(covered ? gapCovered : gapOpen, qx, qy);
472
+ }
427
473
  continue;
428
474
  }
429
475
  if (a && b) continue;
@@ -432,10 +478,13 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant): { gap: Cel
432
478
  const otherRest = a ? restB : restA;
433
479
  const p = back(mine, myIds, k, qx, qy);
434
480
  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);
481
+ if (!composite(back(other, otherIds, k, qx, qy))) {
482
+ take(ghost, qx, qy);
483
+ take(a || plant.ghostOnlyA ? ghostA : ghostB, qx, qy);
484
+ }
436
485
  }
437
486
  }
438
- return { gap, ghost };
487
+ return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
439
488
  }
440
489
 
441
490
  const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
@@ -494,6 +543,10 @@ export function seamsOf(
494
543
  let banded = 0;
495
544
  const gaps: SeamHit[] = [];
496
545
  const ghosts: SeamHit[] = [];
546
+ const ghostsA: SeamHit[] = [];
547
+ const ghostsB: SeamHit[] = [];
548
+ const openGaps: SeamHit[] = [];
549
+ const coveredGaps: SeamHit[] = [];
497
550
  for (const frame of frames) {
498
551
  const A = sideOf(frame, a);
499
552
  const B = sideOf(frame, b);
@@ -504,10 +557,10 @@ export function seamsOf(
504
557
  const flat = cellA === null ? a : cellB === null ? b : null;
505
558
  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
559
  const cell = Math.max(cellA, cellB);
507
- const read = readFrame(A, B, cell, plant);
560
+ const read = readFrame(A, B, cell, plant, frame, pages);
508
561
  if (read === null) continue;
509
562
  banded++;
510
- for (const [cells, into] of [[read.gap, gaps], [read.ghost, ghosts]] as const) {
563
+ 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) {
511
564
  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
565
  }
513
566
  }
@@ -516,7 +569,7 @@ export function seamsOf(
516
569
  const opens = [...new Set([...gaps, ...ghosts].map((f) => f.index))].sort((x, y) => x - y);
517
570
  const maxPx = Math.max(0, ...gaps.map((f) => f.px), ...ghosts.map((f) => f.px));
518
571
  const status = banded === 0 && !plant.skipAsRead ? 'skip' : 'read';
519
- return { a, b, drawn, banded, status, opens, maxPx, gap, ghost };
572
+ return { a, b, drawn, banded, status, opens, maxPx, gap, ghost, ghostA: kindOf(ghostsA), ghostB: kindOf(ghostsB), gapOpen: kindOf(openGaps), gapCovered: kindOf(coveredGaps) };
520
573
  });
521
574
  }
522
575
 
@@ -532,6 +585,10 @@ export const SEAM_READING: SeamFile['reading'] = {
532
585
  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
586
  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
587
  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',
588
+ 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',
589
+ 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',
590
+ 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',
591
+ 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',
535
592
  };
536
593
 
537
594
  /** The file for one frame set. */