@rig-c/core 2.83.0 → 2.84.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/docs/AUTHORING.md CHANGED
@@ -9186,7 +9186,7 @@ inside it, and a second copy of the leg on the same bone):
9186
9186
  bun cli.ts render --candidate path/to/spine --animation swing --seam skirt:leg,leg_front:leg [--skin …] [--fps 12]
9187
9187
  # rigc render
9188
9188
  # One seam line per pair, including no gap/no ghost or SKIP, followed by
9189
- # exposure "<slot>" for each named member: frames, worst cell count and box.
9189
+ # exposure "<slot>" and joint contact: joined/separated/unavailable frames.
9190
9190
  ```
9191
9191
 
9192
9192
  The skirt swinging off the leg opens background between the leg's right edge and
@@ -9241,6 +9241,33 @@ table's vertices, and the atlas alpha:
9241
9241
  The rectangle controls derive eight real tear cells; a notched branch derives
9242
9242
  sixteen adjacent background cells excluded by this rule, including when both
9243
9243
  kinds occur in the same frame.
9244
+ - **jointContact** — a separate observation of **pair material connectivity**,
9245
+ on that same half-opacity texel grid. It requires a setup band and exactly
9246
+ one nonempty edge-connected material component for each named drawing, at
9247
+ both setup and frame. `joined` means the two posed components overlap or
9248
+ share a cell edge; `separated` means their union is disconnected. Sharing
9249
+ only a corner is not edge contact. Every sampled frame is present, including
9250
+ `unavailable` with a reason when a member is absent, the setup band is empty,
9251
+ material is empty or has multiple islands, or a named slot draws multiple
9252
+ fragments. No samples yields an empty list, not a successful observation.
9253
+
9254
+ **Raw gap measures swept support, not certified tearing.** An attached limb
9255
+ can rotate well away from its parent and leave an external crescent of
9256
+ background inside that support: `gap` can be positive while `jointContact`
9257
+ is `joined`. Removing enough proximal material can leave initial overlap but
9258
+ disconnect the same two drawings under the same attachment maps; that is
9259
+ operational material separation, reported as `separated`. This uses no pivot
9260
+ radius, anatomical assumption or new opacity threshold. The synthetic
9261
+ rectangular controls use an independent endpoint set oracle and actual gated
9262
+ builds rendered through both posers.
9263
+
9264
+ Contact does **not** infer anatomical joint intent. A joined pair can have a
9265
+ local notch or hole, including a genuine local tear while other material
9266
+ remains connected; contact cannot classify individual gap cells as harmless
9267
+ background or tearing. A separated pair need not be artistically wrong and
9268
+ can have zero raw gap outside the existing swept support. The grid cannot
9269
+ resolve sub-cell contact. Other skeleton drawings and draw order do not
9270
+ enter this pair-material observation. `unavailable` is never zero or PASS.
9244
9271
  - **ghost** — a cell one piece draws at the frame and the other does not, which
9245
9272
  the drawing piece, carrying it back, lands in the band, and which the other,
9246
9273
  carrying it back, finds neither drawing at the setup pose: the overlap swung
@@ -9336,6 +9363,7 @@ block carries the definitions above in words.
9336
9363
  | `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` |
9337
9364
  | `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` |
9338
9365
  | `pairs[].exposureA`, `pairs[].exposureB` | object | — | `{ frames, spans, worst }` for the whole named slot’s positive-alpha cells on otherwise zero-alpha skeleton background; measured independently of the band, including SKIP pairs; not part of `opens` or `maxPx` |
9366
+ | `pairs[].jointContact.frames[]` | object | seconds for `time` | `{ index, time, status, reason }` for every sampled frame; `status` is `joined`, `separated` or `unavailable`; `reason` is `null` for measured contact and a named explanation for unavailable contact. Independent of raw gap/ghost, `opens` and `maxPx` |
9339
9367
  | `<kind>.frames[]` | hit[] | — | only the frames with a cell of that kind, in frame order |
9340
9368
  | `<kind>.spans[]` | array | — | `<kind>.frames` as runs of consecutive indices: `{ frames: [first, last], times: [first, last], maxPx }` — frame indices, seconds, px |
9341
9369
  | `<kind>.worst` | hit or `null` | — | the frame with the most cells, the earliest on a tie; `null` when none |
@@ -9362,6 +9390,18 @@ Clean stationary/shared-motion pair fields remain unchanged apart from exposure;
9362
9390
  the `reading` legend states the revised definitions. Historical measurements
9363
9391
  above pin their old reader and must not be treated as current measurements.
9364
9392
 
9393
+ The subsequent `jointContact` addition preserves **all** existing pair fields,
9394
+ raw gap/coverage/ghost/exposure numbers, `opens`, `maxPx` and their definitions.
9395
+ It appends one pair field, a legend entry and a contact clause to the printed
9396
+ line; `rigc-seam/1` is unchanged. Readers accepting additive keys can keep their
9397
+ existing policy. In particular, a consumer using raw `gapOpen` plus an owned
9398
+ ghost as its policy still uses those same numbers; adding contact does not
9399
+ change that verdict. Adopting material contact as a separate consumer policy
9400
+ requires an explicit choice. Readers comparing entire JSON objects or exact
9401
+ console lines must account for the added fields and clause. `stays closed` and
9402
+ `opens` in the existing clause continue to describe raw gap/ghost; use the
9403
+ separate contact clause for operational material separation.
9404
+
9365
9405
  **Refused, nothing written:** a slot the skeleton does not have (with the slots it
9366
9406
  does), an entry without exactly one `:`, an empty entry or name, a slot paired with
9367
9407
  itself (a seam is between two drawings), a pair named twice in either order (the
@@ -12577,7 +12617,7 @@ pixel (`IN09`).
12577
12617
  | What does this bone move? | `--show art,mesh,weights:<bone> --dim` | the bone's weight over every region and mesh it moves, as a heat: a pixel whose centre lies in a triangle is painted the ramp's colour at the blend of its corners' weights — blue near 0, yellow at 0.5, red at 1 (the stops are in the legend) — and a weight of 0 is not painted, so the art shows where the bone does not reach. A region and an unweighted mesh are moved whole by their slot's bone. The weights are read off `skeleton.json`, the file both posers skin from; a bone that weights nothing paints nothing and the run says so on a line of its own |
12578
12618
  | Does this part's edge follow that bone? | `--show art,weights:<bone>,mesh --overlay-slot <part> --zoom slot:<part>` | the part's own triangles and the bone's weight over them alone, over the whole painting. Where slots stack — a neck under the face, the hair, a collar — every mesh crossing the box is drawn and the heat is the union of every slot the bone moves, so the part's edge has no pixel of its own until the others are left out. The legend's `meshes[]` gives the part's weight for the bone at every vertex, so "does the top row follow the head" is read as numbers as well as colour |
12579
12619
  | Is this rig's density where its motion is? | `--show art,density --dim` | each region and mesh filled by its **ratio** — its triangles per world unit² over the figure's — on one ramp, log-scaled and centred on 1×: grey is as dense as the figure, pink to magenta denser (4×, 16×), light to dark green sparser (1/4×, 1/16×), clamped past the end stops (the stops are in the legend). A part's area is its posed mesh's own, the sum of its triangles' areas at the pose drawn, not the image's box; a region is two triangles over its quad. Where parts overlap the pixel is the part drawn last, as in the art, so a part under others is read with `--overlay-slot <part>`. The figure's totals are always over every slot the pose shows — the filter picks what is drawn and listed, never what the figure is — and the legend's `density` gives each slot's attachment, kind, vertices, triangles, hull, area, triangles per area, ratio and the bones it is weighted to, in draw order. A measurement, not a verdict: no bar is drawn, and `A13_MESH_BUDGET` stays the one gate on counts |
12580
- | Does a joint tear in this animation? | `render --seam <a>:<b> --animation <name>` (§8.8) | One line per pair: the frames where background opens inside the joint (gap) or the overlap band of one piece shows past its partner (ghost), with the worst frame's cell count and world box; `seam.json` carries every frame and its time, and the runs of frames. A pair whose drawings never overlap is a SKIP, said by name. A reading, not a verdict — the exit status never moves |
12620
+ | Does a pair lose material contact in this animation? | `render --seam <a>:<b> --animation <name>` (§8.8) | Raw swept-support gap and visible ghost counts, plus separate pair material contact: joined, separated or explicitly unavailable. Gap alone is not certified tearing; contact does not infer anatomical intent or rule out local notches. `seam.json` records the frames and times. A reading, not a verdict — the exit status never moves |
12581
12621
  | Which slot covers which in this animation? | `render --overlaps --animation <name>` (§8.9) | One `OVERLAP "<under>" under "<over>"` line per pair that moves: the frames it covers on, as runs, and the most pixels it covers and the frame of them; one line counting the layering the motion does not move. `overlaps.json` carries every frame's pairs with their pixel count and box, and `overlaps-f<NNNN>.png` draws each covered frame's contours, the covered part dashed. A reading, not a verdict — the draw-order key is the remedy |
12582
12622
  | Does anything fold in this animation? | `render --folds --animation <name>` (§8.3), then `inspect --show art,folds,mesh --animation <name> --at <the worst frame's time> --zoom folds` | `render --folds` prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count, which triangles, their world box at that frame and their setup-pose area. The view fills those triangles at the pose drawn — every pixel whose centre a folded triangle holds — in the colour the legend's `folds` states, and the legend's `folds[]` names, per mesh, the folded triangles by index with the same box and area, and the setup pose's degenerate and inverted ones, which are never counted. A fold is usually a sliver a few units across, invisible in a picture of the whole mesh: `--zoom folds` frames the picture to the folded triangles' box. A thin sliver still needs pixels — raise `--max` when the fill is a line. A reading, not a verdict |
12583
12623
  | How is this part triangulated? | `--show art,mesh,hull --zoom slot:<slot>` | each mesh's triangle edges, and over them its hull — vertex 0 to 1 to … to `hull − 1` and back. Regions are not drawn: a region is not a mesh. The legend lists every vertex in world units and in pixels |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.83.0",
3
+ "version": "2.84.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/cli/shared.ts CHANGED
@@ -1073,7 +1073,7 @@ const FLAG_MEANINGS: Record<string, string> = {
1073
1073
  'per pair, the frames with a gap — background inside the carried overlap hull and a local sweep of the same setup band cell through both drawings — ' +
1074
1074
  'and the frames with a visible ghost after draw order — the band of one drawing showing where, from its partner\'s point of view, nothing was drawn at the setup pose — each with ' +
1075
1075
  `its cell count, area and world box. Cells are one texel of the coarser drawing, drawn at ${DRAWN_ALPHA} of 255 or more. One line per pair; a pair whose ` +
1076
- 'drawings share no cell at the setup pose is a SKIP. exposureA/B count each named slot’s positive-alpha cells on otherwise empty skeleton background, even without a band or partner. A reading, not a gate: the exit status never moves. An unknown slot, a slot with itself, ' +
1076
+ 'drawings share no cell at the setup pose is a SKIP. Raw gap measures swept support, not certified tearing. jointContact separately reports pair material connectivity: joined, separated, or unavailable with a reason; it does not infer anatomical joint intent or rule out local notches. exposureA/B count each named slot’s positive-alpha cells on otherwise empty skeleton background, even without a band or partner. A reading, not a gate: the exit status never moves. An unknown slot, a slot with itself, ' +
1077
1077
  'a slot that draws on no frame, a malformed list and a pair named twice in either order are refused by name. Not with --slot/--hide',
1078
1078
  'angle-range':
1079
1079
  'also read, for each <bone>:<relative-to> pair, the range of the first bone\'s world direction against the second\'s over each set\'s ' +
package/src/seam.ts CHANGED
@@ -63,6 +63,13 @@
63
63
  * Shared transforms read closed. The outer-hull reach remains bounded: tears
64
64
  * outside the transported setup overlap are not certified absent by a zero.
65
65
  *
66
+ * `jointContact` separately observes material connectivity on that same grid.
67
+ * Initially overlapping, single-component drawings are joined if their posed
68
+ * union is edge-connected, separated otherwise. Missing bands or non-single
69
+ * components are unavailable. Joined does not rule out local holes or notches;
70
+ * separated does not infer artistic intent. Raw gap can include an external
71
+ * rotational crescent of a joined pair and is deliberately unchanged.
72
+ *
66
73
  * Pure: no clock, no randomness, no runtime. The pieces, the attachments and the
67
74
  * rest table are handed in by whichever poser drew the frames.
68
75
  */
@@ -159,6 +166,15 @@ export interface SeamKindReading {
159
166
  worst: SeamHit | null;
160
167
  }
161
168
 
169
+ /** Connectivity of two initially overlapping single material components, at grid resolution. */
170
+ export interface SeamContactFrame {
171
+ index: number;
172
+ time: number;
173
+ status: 'joined' | 'separated' | 'unavailable';
174
+ /** Non-null exactly when the observation is unavailable; never a measured zero. */
175
+ reason: string | null;
176
+ }
177
+
162
178
  /** One pair's reading over one frame set. */
163
179
  export interface SeamPairReading {
164
180
  a: string;
@@ -186,6 +202,8 @@ export interface SeamPairReading {
186
202
  /** Positive-alpha cells contributed by the named slot where all OTHER slots have zero alpha. Independent of the band. */
187
203
  exposureA: SeamKindReading;
188
204
  exposureB: SeamKindReading;
205
+ /** Every sampled frame, including unavailable frames. Independent of raw gap/ghost and their summaries. */
206
+ jointContact: { frames: SeamContactFrame[] };
189
207
  }
190
208
 
191
209
  /** `seam.json`: one frame set's reading, every pair the flag names, in the flag's order. */
@@ -196,7 +214,7 @@ export interface SeamFile {
196
214
  skin?: string;
197
215
  fps: number;
198
216
  /** What the numbers mean, stated in the file so a reader does not need this module. */
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 };
217
+ 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; jointContact: string };
200
218
  pairs: SeamPairReading[];
201
219
  }
202
220
 
@@ -219,6 +237,8 @@ export interface SeamPlant {
219
237
  hiddenGhost?: boolean;
220
238
  /** Bridge unrelated overlap cells with the old global hull. */
221
239
  globalHull?: boolean;
240
+ /** Force a measured contact to one answer, ignoring the rendered material. */
241
+ contact?: 'joined' | 'separated';
222
242
  /** Wrong composite readings: half opacity, only the pair, or every gap forced to one kind. */
223
243
  gapCoverage?: 'half' | 'pair' | 'open' | 'covered';
224
244
  /** Report a pair with no band as read, rather than as a SKIP. */
@@ -473,8 +493,44 @@ function markHull(hull: ReadonlyArray<[number, number]>, grid: Grid, cells: Uint
473
493
  }
474
494
  }
475
495
 
496
+ /** Count edge-connected components, stopping after the second: only exactly one is supported. */
497
+ function components(cells: Uint8Array, width: number): number {
498
+ const seen = new Uint8Array(cells.length);
499
+ const queue = new Int32Array(cells.length);
500
+ let count = 0;
501
+ for (let k = 0; k < cells.length; k++) {
502
+ if (!cells[k] || seen[k]) continue;
503
+ if (++count === 2) return count;
504
+ let head = 0, tail = 1;
505
+ queue[0] = k; seen[k] = 1;
506
+ while (head < tail) {
507
+ const i = queue[head++];
508
+ const visit = (j: number): void => {
509
+ if (j >= 0 && j < cells.length && cells[j] && !seen[j]) { seen[j] = 1; queue[tail++] = j; }
510
+ };
511
+ if (i % width > 0) visit(i - 1);
512
+ if (i % width < width - 1) visit(i + 1);
513
+ visit(i - width); visit(i + width);
514
+ }
515
+ }
516
+ return count;
517
+ }
518
+
519
+ type Contact = Pick<SeamContactFrame, 'status' | 'reason'>;
520
+ const unavailableContact = (reason: string): Contact => ({ status: 'unavailable', reason });
521
+
522
+ /** Four neighbours mean a shared cell edge; diagonals alone do not connect material. No distance parameter. */
523
+ function contactOf(restA: Uint8Array, restB: Uint8Array, drawnA: Uint8Array, drawnB: Uint8Array, width: number, plant: SeamPlant): Contact {
524
+ for (const [name, cells] of [['a setup', restA], ['b setup', restB], ['a frame', drawnA], ['b frame', drawnB]] as const) {
525
+ const n = components(cells, width);
526
+ if (n !== 1) return unavailableContact(`${name}: ${n === 0 ? 'no' : 'multiple'} edge-connected half-opacity material components; exactly one is required`);
527
+ }
528
+ const union = drawnA.map((v, k) => v || drawnB[k]);
529
+ return { status: plant.contact ?? (components(union, width) === 1 ? 'joined' : 'separated'), reason: null };
530
+ }
531
+
476
532
  /** 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 {
533
+ 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; contact: Contact } | null {
478
534
  const { width, height } = grid.viewport;
479
535
  const restA = restPiece(A);
480
536
  const restB = restPiece(B);
@@ -520,6 +576,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
520
576
  const hull = hullOf(carried);
521
577
  const drawnA = drawnCells(A.piece, A.page, grid.viewport);
522
578
  const drawnB = drawnCells(B.piece, B.page, grid.viewport);
579
+ const contact = contactOf(bandA, bandB, drawnA, drawnB, width, plant);
523
580
  const [visibleA, visibleB] = visibility;
524
581
  const posedIdsA = triangleIds(A.triangles, A.piece.world, grid);
525
582
  const posedIdsB = triangleIds(B.triangles, B.piece.world, grid);
@@ -536,7 +593,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
536
593
  const gapCovered: Cells = { px: 0, box: null };
537
594
  let alphaCells: Uint8Array | null = null;
538
595
  const take = (into: Cells, qx: number, qy: number): void => takeCell(into, qx, qy, cell);
539
- if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
596
+ if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered, contact };
540
597
  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;
541
598
  for (let py = 0; py < height; py++) {
542
599
  for (let px = 0; px < width; px++) {
@@ -570,7 +627,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
570
627
  }
571
628
  }
572
629
  }
573
- return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
630
+ return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered, contact };
574
631
  }
575
632
 
576
633
  const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
@@ -634,9 +691,12 @@ export function seamsOf(
634
691
  const openGaps: SeamHit[] = [];
635
692
  const coveredGaps: SeamHit[] = [];
636
693
  const exposuresA: SeamHit[] = [], exposuresB: SeamHit[] = [];
694
+ const contacts: SeamContactFrame[] = [];
637
695
  for (const frame of frames) {
638
696
  const A = sideOf(frame, a);
639
697
  const B = sideOf(frame, b);
698
+ const contact = (reading: Contact): void => { contacts.push({ index: frame.index, time: frame.time, ...reading }); };
699
+ if (A === null || B === null) contact(unavailableContact('both named slots must draw on this frame'));
640
700
  const sides = [A, B].filter((side): side is Side => side !== null);
641
701
  if (sides.length === 0) continue;
642
702
  const sizes = sides.map((side) => texelSize(side.piece, side.rest, side.page));
@@ -652,7 +712,9 @@ export function seamsOf(
652
712
  drawn++;
653
713
  const cell = exposureCell;
654
714
  const read = readFrame(A, B, cell, plant, frame, pages, exposureGrid, exposure.map((m) => m.visible));
655
- if (read === null) continue;
715
+ if (read === null) { contact(unavailableContact('no shared half-opacity cell at the setup pose; no band to identify a joint')); continue; }
716
+ const fragmented = [a, b].some(slot => frame.pieces.filter(p => p.slot === slot).length !== 1);
717
+ contact(fragmented ? unavailableContact('each named slot must draw exactly one fragment for material contact') : read.contact);
656
718
  banded++;
657
719
  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) {
658
720
  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 });
@@ -663,7 +725,7 @@ export function seamsOf(
663
725
  const opens = [...new Set([...gaps, ...ghosts].map((f) => f.index))].sort((x, y) => x - y);
664
726
  const maxPx = Math.max(0, ...gaps.map((f) => f.px), ...ghosts.map((f) => f.px));
665
727
  const status = banded === 0 && !plant.skipAsRead ? 'skip' : 'read';
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) };
728
+ 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), jointContact: { frames: contacts } };
667
729
  });
668
730
  }
669
731
 
@@ -685,6 +747,7 @@ export const SEAM_READING: SeamFile['reading'] = {
685
747
  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
748
  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',
687
749
  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',
750
+ jointContact: 'every sampled frame: joined or separated only when a setup band exists and each named drawing has exactly one edge-connected half-opacity material component at setup and frame; joined means their frame union is edge-connected (overlap or shared cell edge), separated means it is not; diagonal contact alone is separated. Otherwise unavailable with a reason, including multiple fragments of a named slot. Uses the existing origin-aligned texel grid, alpha and attachment maps, independent of other slots and draw order. Raw gap measures swept support, not certified tearing. Contact is pair material connectivity, not anatomical joint intent; joined does not rule out local holes/notches. No statement about sub-cell contact; raw gap/ghost, opens/maxPx and exit status are unchanged',
688
751
  };
689
752
 
690
753
  /** The file for one frame set. */
@@ -730,5 +793,12 @@ function seamSummary(animation: string | null, fps: number, pair: SeamPairReadin
730
793
 
731
794
  /** The seam summary plus whole-drawing background exposures, including on SKIP pairs. */
732
795
  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)}`;
796
+ const contacts = pair.jointContact.frames;
797
+ const contactText = contacts.length === 0 ? 'unavailable: no sampled frames' : null;
798
+ const summary = (status: SeamContactFrame['status']): string => {
799
+ const frames = contacts.filter(f => f.status === status);
800
+ return `${status} on ${frames.length} frame(s) [${indexRuns(frames.map(f => f.index))}]`;
801
+ };
802
+ const reasons = [...new Set(contacts.flatMap(f => f.reason === null ? [] : [f.reason]))];
803
+ return `${seamSummary(animation, fps, pair)}; ${kindText(`exposure ${JSON.stringify(pair.a)}`, pair.exposureA)}; ${kindText(`exposure ${JSON.stringify(pair.b)}`, pair.exposureB)}; joint contact: ${contactText ?? `${summary('joined')}, ${summary('separated')}, ${summary('unavailable')}`}${reasons.length ? ` (${reasons.join('; ')})` : ''}`;
734
804
  }