@rig-c/core 2.82.2 → 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
@@ -129,7 +129,7 @@ commands take it and what its default is.
129
129
  | `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 <a id="cmd-ingest"></a> |
130
130
  | `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)) <a id="cmd-split"></a> |
131
131
  | `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 <a id="cmd-explain"></a> |
132
- | `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. `--extremes` adds `extremes.json` and a native six-panel `extremes.png` (setup, geometry-pair min/max and three premultiplied RGBA diffs with a legend), plus one `EXTREMES` line per set. Per-channel minima/maxima and the farthest original-vertex geometry pair are distinct readings; valid channel extrema survive a geometry/image SKIP, with channel refusals reported separately; still or uncomparable geometry reports a named SKIP without changing render exit status. `--extremes-only` writes the same `extremes.json` and readable `extremes.png` bytes while skipping ordinary frame rasterization/encoding, `contact.png` and `frames.json`; it implies `--extremes` and takes no other diagnostic or sheet output flags. Look at min, max and diff before a person does ([AUTHORING §8.11](../docs/AUTHORING.md#811-min-max-and-diff-before-a-person-looks--render---extremes)). `--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. `--sheet [--sheet-format json-hash] [--page-edges pot\|free]` also packs each set's straight-alpha transparent frames into `sheet.png` + `sheet.json` (TexturePacker JSON Hash with a shared world-origin `anchor` and `2`-px clamped edge extrusion, verified through `pixi.js@8.22.0` and `phaser@3.90.0` by `tools/sheet_readers.ts`) and `sheet-manifest.json` (`rigc-sheet/1`), leaving diagnostic `f####.png` and `contact.png` unchanged ([AUTHORING §8.10](../docs/AUTHORING.md#810-sprite-sheets-from-a-render--render---sheet)) — a self-contained sprite object per animation set; when animations play and how a scene is composed remains the consumer's <a id="cmd-render"></a> |
132
+ | `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; ghost counts only visible contributions after draw order; `exposureA`/`exposureB` report each named slot’s whole-drawing background exposure even without a band ([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. `--extremes` adds `extremes.json` and a native six-panel `extremes.png` (setup, geometry-pair min/max and three premultiplied RGBA diffs with a legend), plus one `EXTREMES` line per set. Per-channel minima/maxima and the farthest original-vertex geometry pair are distinct readings; valid channel extrema survive a geometry/image SKIP, with channel refusals reported separately; still or uncomparable geometry reports a named SKIP without changing render exit status. `--extremes-only` writes the same `extremes.json` and readable `extremes.png` bytes while skipping ordinary frame rasterization/encoding, `contact.png` and `frames.json`; it implies `--extremes` and takes no other diagnostic or sheet output flags. Look at min, max and diff before a person does ([AUTHORING §8.11](../docs/AUTHORING.md#811-min-max-and-diff-before-a-person-looks--render---extremes)). `--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. `--sheet [--sheet-format json-hash] [--page-edges pot\|free]` also packs each set's straight-alpha transparent frames into `sheet.png` + `sheet.json` (TexturePacker JSON Hash with a shared world-origin `anchor` and `2`-px clamped edge extrusion, verified through `pixi.js@8.22.0` and `phaser@3.90.0` by `tools/sheet_readers.ts`) and `sheet-manifest.json` (`rigc-sheet/1`), leaving diagnostic `f####.png` and `contact.png` unchanged ([AUTHORING §8.10](../docs/AUTHORING.md#810-sprite-sheets-from-a-render--render---sheet)) — a self-contained sprite object per animation set; when animations play and how a scene is composed remains the consumer's <a id="cmd-render"></a> |
133
133
  | `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` <a id="cmd-preview"></a> |
134
134
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` <a id="cmd-vote"></a> |
135
135
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture <a id="cmd-pose"></a> |
@@ -381,7 +381,7 @@ What the flags mean:
381
381
  | `--extremes` | `render` only: add `extremes.json` and an `EXTREMES` line per set; a moving set with comparable geometry and readable images also gets a native six-panel `extremes.png`: setup, geometry-pair min/max and three premultiplied RGBA diffs with a legend. Per-channel extrema are independent of that geometry pair; valid channels survive geometry/image SKIPs, and channel refusals are named separately. Still or uncomparable geometry is a named SKIP, with no invented pair or PNG; observation failures do not change a successful render's exit status. Supports `--slot`/`--hide` on both posers, on the whole rig's viewport. Without the flag, no extremes observations are added — **§8.11** |
382
382
  | `--folds` | `render` only: also read which mesh triangles **fold** — turn over against the setup pose — and write a `folds.json` into each frame directory: per mesh, the frames with a folded triangle, the worst frame's count and which triangles, by index, with their world box and setup-pose area. One printed line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. A reading, not a gate. Every other file is byte for byte what the render writes without it, `geometry.json` included. Refused with `--slot`/`--hide`, for `--geometry`'s reason — **§8.3** |
383
383
  | `--pass-through` | `render` only: `<cloth>:<leg>[,<cloth>:<leg>…]` — also read where each cloth slot **passes through** its leg slot, and write a `pass-through.json` into each frame directory: the frames where the cloth draws over the leg and its drawn side lies inside the leg's drawn outline by 2 steps or more, each with the depth and the world box. One printed line per pair, and one per frame that passes through. The first name must draw over the second for a frame to be read — the declared order, never both. A reading, not a gate. Every other file is byte for byte what the render writes without it. An unknown slot, a slot that draws on no frame, a malformed list and a pair named twice are refused by name, and so is `--slot`/`--hide` beside it — **§8.5** |
384
- | `--seam` | `render` only: `<slot>:<slot>[,<slot>:<slot>…]` — also read, for each pair, where the two drawings **stop meeting**, and write a `seam.json` into each frame directory: per pair, the frames with a **gap** (background inside the joint, where the two covered together at the setup pose) and the frames with a **ghost** (the overlap band of one showing where, from its partner's point of view, nothing was drawn), each with its cell count, area and world box, and as runs of frames with their times. One printed line per pair; a pair whose drawings share no cell at the setup pose is a **SKIP**. A reading, not a gate: the exit status never moves. Every other file is byte for byte what the render writes without it. An unknown slot, a slot with itself, a slot that draws on no frame, a malformed list and a pair named twice in either order are refused by name, and so is `--slot`/`--hide` beside it — **§8.8** |
384
+ | `--seam` | `render` only: `<slot>:<slot>[,<slot>:<slot>…]` — also read, for each pair, where the two drawings **stop meeting**, and write a `seam.json` into each frame directory: per pair, the frames with a **gap** (background inside the joint, where the two covered together at the setup pose) and the frames with a **ghost** (the visible overlap band of one showing where, from its partner's point of view, nothing was drawn), plus `exposureA`/`exposureB` for each named slot’s positive-alpha drawing on otherwise empty skeleton background, each with its cell count, area and world box, and as runs of frames with their times. One printed line per pair; a pair whose drawings share no cell at the setup pose is a **SKIP**. A reading, not a gate: the exit status never moves. Every other file is byte for byte what the render writes without it. An unknown slot, a slot with itself, a slot that draws on no frame, a malformed list and a pair named twice in either order are refused by name, and so is `--slot`/`--hide` beside it — **§8.8** |
385
385
  | `--overlaps` | `render` only: also read **which slot each frame drew over which**, and write an `overlaps.json` into each frame directory: per frame, every pair of slots where the slot drawn later covers pixels the earlier one drew (drawn at 128 of 255 or more, on the frames' own pixels), with the pixel count and the pixel box. One `OVERLAP` line per pair that covers anything — its frames as runs, how many of the set, and the most pixels and the frame of them — and one line counting the pairs that are layering the motion does not move. Each frame with a moving pair also gets an **outline plate**, `overlaps-f<NNNN>.png`: every slot in a moving pair as its contour in its own colour, dashed where a slot drawn later covers it, named at its top. The render reports what was drawn; who should be in front is the author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Every other file is byte for byte what the render writes without it, and `frames.json` gains each set's `overlaps` record. Refused beside `--slot`/`--hide` — **§8.9** |
386
386
  | `--angle-range` | `render` only: `<bone>:<relative-to>[,<bone>:<relative-to>…]` — also read, for each pair, the range of the first bone's world direction against the second's over each set's frames, and print one line per pair per set: the lowest and the highest angle in degrees, the span, and the frame each was first reached on. A reading, not a gate: nothing is written, and every file is the bytes the render writes without it. An unknown bone (with the bones there are), a bone against itself, a malformed list and a pair named twice are refused by name, and so is a bone with no direction on a frame — **§8.7** |
387
387
  | `--sheet` | `render` only: also pack each rendered animation set as a straight-alpha transparent sprite sheet (`sheet.png` + `sheet.json`, plus `sheet2.png` / `sheet2.json` on multi-page spill, and `sheet-manifest.json` (`rigc-sheet/1`)) inside its set directory, and record `sheet` on each set in `frames.json`. Diagnostic `f####.png` and `contact.png` stay byte for byte what the render writes without it — **§8.10** |
@@ -9185,15 +9185,15 @@ inside it, and a second copy of the leg on the same bone):
9185
9185
  ```bash
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
- # .. swing 25 frame(s), 2.000s + contact.png + seam.json (1 of 2 pair(s) open) -> …/swing
9189
- # .. seam "swing": seam "skirt" | "leg" opens on 23 of 25 frame(s) read [1-23] at 12 fps, max 1606 px — gap on 13 frame(s) [6-18], worst frame 12 (t=1.0000s): 616 px (616.00 units² at 1.0000 units a px) in world box x [-7.00, 19.00] y [105.99, 196.00] (26.00 × 90.00); ghost on 23 frame(s) [1-23], worst frame 12 (t=1.0000s): 1606 px (1606.00 units² at 1.0000 units a px) in world box x [-27.00, 41.00] y [104.99, 198.00] (68.00 × 93.00)
9190
- # .. seam "swing": seam "leg_front" | "leg" stays closed on all 25 frame(s) read at 12 fps — no gap, no ghost
9188
+ # One seam line per pair, including no gap/no ghost or SKIP, followed by
9189
+ # exposure "<slot>" and joint contact: joined/separated/unavailable frames.
9191
9190
  ```
9192
9191
 
9193
9192
  The skirt swinging off the leg opens background between the leg's right edge and
9194
9193
  the skirt's swung side (the gap, widest at the top of the swing), and uncovers the
9195
- top of the leg the skirt hid at rest (the ghost). The two copies of the leg move
9196
- on one bone and never open. A pair with nothing to read says so —
9194
+ top of a visible leg drawing the skirt hid at rest (the ghost). A leg drawing
9195
+ hidden by its opaque front copy contributes no ghost; reading `skirt:leg_front`
9196
+ measures that front copy instead. The two copies move on one bone and never open. A pair with nothing to read says so —
9197
9197
  `seam "a" | "b": SKIP — the two drawings share no cell at the setup pose on any of
9198
9198
  the N frame(s) both draw, so there is no band to read` — and a closed pair says
9199
9199
  `stays closed on all N frame(s) read`; the two are never the same line.
@@ -9225,17 +9225,58 @@ table's vertices, and the atlas alpha:
9225
9225
  were painted to overlap. It has **no width of its own**: it is the overlap the
9226
9226
  art states, dilated by nothing. A frame whose band is empty is not read; a pair
9227
9227
  whose band is empty on every frame both draw is a SKIP.
9228
- - **gap** — a cell inside the convex hull of the band's cell centres, carried to
9229
- the frame by both pieces (the region the joint occupies now, as both place it),
9230
- that neither piece draws at the frame, and that at least one piece, carrying it
9231
- back, finds drawn by either drawing at the setup pose: the two no longer cover
9232
- what they covered together. The hull is the joint's own extent, which is what
9233
- keeps a piece's far end swinging away from reading as a tear; the rest test is
9234
- what keeps a band that is concave at the setup pose from reading its own notch.
9228
+ - **gap** — a cell inside the outer convex hull of the band's centres carried
9229
+ through both pieces, **and inside a local sweep of the same setup band cell**,
9230
+ that neither piece draws now, and that at least one inverse attachment map
9231
+ finds drawn by either setup drawing. A local sweep is the convex hull of one
9232
+ overlap cell's four corners carried through each attachment's triangle maps
9233
+ (eight points); each corner uses its own setup triangle, extending the nearest
9234
+ affine map outside a mesh as above. Take the union of these local sweeps,
9235
+ never a hull joining unrelated overlap cells. The outer hull and inverse OR
9236
+ test remain required. This derives joint support from the rig's art, texel
9237
+ grid and deformation, without a pivot distance or a joint-disc limit. A
9238
+ connected branch with two separated overlap rows can have plain background
9239
+ between those rows: the outer hull alone fills it, while local correspondence
9240
+ excludes it. A genuine opening between opposed overlap cells remains a gap.
9241
+ The rectangle controls derive eight real tear cells; a notched branch derives
9242
+ sixteen adjacent background cells excluded by this rule, including when both
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.
9235
9271
  - **ghost** — a cell one piece draws at the frame and the other does not, which
9236
9272
  the drawing piece, carrying it back, lands in the band, and which the other,
9237
- carrying it back, finds neither drawing at at the setup pose: the overlap swung
9238
- clear of its partner.
9273
+ carrying it back, finds neither drawing at the setup pose: the overlap swung
9274
+ clear of its partner. It must also retain nonzero **source-over contribution**
9275
+ from that named slot after every drawing in frame draw order. Later alpha 255
9276
+ hides earlier contributions; partial alpha leaves a contribution, and earlier
9277
+ art cannot hide later art. Every fragment of every slot participates, with
9278
+ renderer alpha, tint, UV windows and clipping. This tests contribution, not a
9279
+ colour difference or an artistic judgment.
9239
9280
  - **ghostA**, **ghostB** — that same ghost partitioned by the piece drawing the
9240
9281
  cell: `a` only or `b` only, respectively, in the flag's named pair order. On
9241
9282
  each frame `ghost.px = ghostA.px + ghostB.px` (an absent hit contributes 0),
@@ -9257,9 +9298,22 @@ Each partition's box lies in the union's. A gap in the pair can be covered by a
9257
9298
  third drawing and need not be a visible opening. Reversing the pair preserves
9258
9299
  both gap coverage readings.
9259
9300
 
9260
- Draw order does not enter: a cell is covered or bare whichever piece is in front.
9261
- An owner says which piece drew a ghost cell; it does not say which piece is over
9262
- or under the other, or whether that exposure should fail a consumer's policy.
9301
+ - **exposureA**, **exposureB** — cells of positive rendered alpha from each
9302
+ named slot where removing **all fragments of that slot** leaves alpha zero
9303
+ from every other skeleton slot. The entire drawing enters, including art
9304
+ outside the band, at setup as well as in motion. A positive-alpha drawing of
9305
+ another slot excludes exposure regardless of its draw order or opacity: this
9306
+ asks what the named slot adds on background, separately from ghost visibility.
9307
+ No presentation background enters. Hits, spans and worst frames use the same
9308
+ shape and units as gap; the grid uses the coarser pair texel when both draw,
9309
+ or the present member's texel when its partner is absent. Exposure is measured
9310
+ even when there is no band, while the seam still says SKIP. It is **not** a
9311
+ change-from-setup measure, does not enter `opens` or `maxPx`, and can be nonzero
9312
+ on a clean rig. Pair reversal exchanges the complete exposure readings.
9313
+
9314
+ Draw order enters ghost visibility. Gap coverage and background exposure depend
9315
+ on zero versus positive alpha and do not depend on order. Named owners describe
9316
+ contributions; none of these fields decides whether a consumer should fail.
9263
9317
 
9264
9318
  **Outside the overlap hull is outside the gap reading.** Both `gap` and its
9265
9319
  `gapOpen` partition are judged only inside the carried setup-overlap hull. Art
@@ -9278,13 +9332,14 @@ cells on the joint rim. The [measurement receipt](https://github.com/rig-c/core/
9278
9332
  pins the inputs, commands and frame digest; these are seam cells, not display
9279
9333
  pixels. See [#1575](https://github.com/rig-c/core/issues/1575) for the reading.
9280
9334
 
9281
- **Its reach, stated.** A piece folded right back over its partner carries the band
9282
- across the region it vacated, so a hull that wide reads the vacated cells its
9283
- partner's view still finds drawn. And no size is too small to print: on
9284
- `gallery/walk`, built as its README says, `--seam thigh_f:shin_f,arm_f:hand_f`
9285
- reads gaps of 1–2 px on 5 of the 12 frames at the knee and of 1 px on 2 at the
9286
- wrist — the wrist's, drawn at 20 pixels a unit, is the V where the two outlines
9287
- meet at an angle, one cell deep. Whether that is a tear is yours.
9335
+ **Its reach, stated.** Local material sweeps replace the old global hull's
9336
+ bridges across unrelated overlap cells. They still describe a sampled geometric
9337
+ support region, not an oracle for intended silhouette or an anatomical joint
9338
+ inferred from bone names. Folding or severe deformation can carry a local cell
9339
+ across vacated background; a result requires inspection. No minimum count, pivot
9340
+ radius or acceptance threshold is added. The controls include ordinary tears,
9341
+ adjacent background, hidden and partly visible ghosts, absent partners and
9342
+ whole-drawing exposures; they do not certify every possible art shape.
9288
9343
 
9289
9344
  **The file.** `seam.json`, `rigc-seam/1`, beside each set's frames — the contract
9290
9345
  a consumer reads it by. Every unit is in this table; the file's own `reading`
@@ -9296,7 +9351,7 @@ block carries the definitions above in words.
9296
9351
  | `animation` | string or `null` | — | the set's animation; `null` for a skeleton with none (its one frame is the setup pose) |
9297
9352
  | `skin` | string | — | the skin asked for; **absent** when none was |
9298
9353
  | `fps` | number | frames per second | the rate the set was sampled at |
9299
- | `reading` | object | — | `read`, `cell`, `drawn`, `band`, `carried`, `gap`, `ghost`, `box`, `status`, `ghostA`, `ghostB`, `gapOpen`, `gapCovered`: the definitions, in words |
9354
+ | `reading` | object | — | `read`, `cell`, `drawn`, `band`, `carried`, `gap`, `ghost`, `box`, `status`, `ghostA`, `ghostB`, `gapOpen`, `gapCovered`, `exposureA`, `exposureB`: the definitions, in words |
9300
9355
  | `pairs[]` | array | — | one per pair, in the flag's order |
9301
9356
  | `pairs[].a`, `pairs[].b` | string | — | the two slots, as the flag names them |
9302
9357
  | `pairs[].drawn` | integer | frames | frames on which both slots draw |
@@ -9307,6 +9362,8 @@ block carries the definitions above in words.
9307
9362
  | `pairs[].gap`, `pairs[].ghost` | object | — | one per kind, each `{ frames, spans, worst }` |
9308
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` |
9309
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` |
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` |
9310
9367
  | `<kind>.frames[]` | hit[] | — | only the frames with a cell of that kind, in frame order |
9311
9368
  | `<kind>.spans[]` | array | — | `<kind>.frames` as runs of consecutive indices: `{ frames: [first, last], times: [first, last], maxPx }` — frame indices, seconds, px |
9312
9369
  | `<kind>.worst` | hit or `null` | — | the frame with the most cells, the earliest on a tie; `null` when none |
@@ -9323,15 +9380,27 @@ span.times, maxPx: span.maxPx, kind }`, and one pair over one animation is
9323
9380
  `opens.length` frame(s) `[opens]`, max `maxPx` px — with `status: "skip"` the case
9324
9381
  to say SKIP for.
9325
9382
 
9326
- The owner keys and their two legend entries are additive in `rigc-seam/1`.
9327
- Removing just those four keys leaves the previous file's bytes after the same
9328
- two-space JSON serialization and newline. The union, its spans and worst, and
9329
- the printed summary retain their existing meanings; an owner may have different
9330
- spans or a different worst frame from the union.
9331
- Likewise `gapOpen`, `gapCovered` and their two legend entries are additive:
9332
- removing only those entries leaves the previous file's bytes after the same
9333
- serialization, including `gap`, `ghostA` and `ghostB`. The coverage partition may
9334
- have different spans and worst frames from the union; it changes no seam policy.
9383
+ `rigc-seam/1` retains its hit and summary shapes and adds `exposureA/B` and
9384
+ their legend entries. The reader semantics intentionally change: ghosts hidden
9385
+ by later opaque art disappear, and global-hull-only gap cells disappear. This
9386
+ can reduce `ghost`, its owners, `gap`, its partitions, `opens` and `maxPx`;
9387
+ consumers using those readings as their own policy must account for the change.
9388
+ The seam remains a reading: no compiler assertion or CLI exit verdict changes.
9389
+ Clean stationary/shared-motion pair fields remain unchanged apart from exposure;
9390
+ the `reading` legend states the revised definitions. Historical measurements
9391
+ above pin their old reader and must not be treated as current measurements.
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.
9335
9404
 
9336
9405
  **Refused, nothing written:** a slot the skeleton does not have (with the slots it
9337
9406
  does), an entry without exactly one `:`, an empty entry or name, a slot paired with
@@ -12548,7 +12617,7 @@ pixel (`IN09`).
12548
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 |
12549
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 |
12550
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 |
12551
- | 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 |
12552
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 |
12553
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 |
12554
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.82.2",
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
@@ -1070,10 +1070,10 @@ const FLAG_MEANINGS: Record<string, string> = {
1070
1070
  extremes: 'also write extremes.json and a native six-panel extremes.png per set: setup, geometry-pair min/max and premultiplied RGBA diffs, with a legend. Per-channel extrema are recorded separately and valid channels survive geometry/image SKIPs; channel refusals are named separately. Still geometry reports SKIP; a reading, not a gate. Attachment identity/count changes record a named SKIP without changing render exit; supports --slot/--hide on both posers',
1071
1071
  seam:
1072
1072
  `also read, for each <slot>:<slot> pair, where the two drawings stop meeting, and write ${SEAM_FILE} into each frame directory: ` +
1073
- 'per pair, the frames with a gap — background inside the hull of the band the two drawings share at the setup pose, carried to the frame by both — ' +
1074
- 'and the frames with a ghost — the band of one drawing showing where, from its partner\'s point of view, nothing was drawn at the setup pose — each with ' +
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
+ '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. 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 ' +
@@ -1762,7 +1762,7 @@ export const COMMANDS: CommandDoc[] = [
1762
1762
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
1763
1763
  'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
1764
1764
  'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
1765
- 'rigc render … --seam <slot>:<slot>[,…] (the frames where two slots that meet open a gap or show a ghost)',
1765
+ 'rigc render … --seam <slot>:<slot>[,…] (joint gaps, visible ghosts and each named slot’s background exposure)',
1766
1766
  'rigc render … --overlaps (which slot each frame drew over which, by name, with an outline plate)',
1767
1767
  'rigc render … --angle-range <bone>:<relative-to>[,…] (how far a bone turns against another, per animation)',
1768
1768
  'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
package/src/seam.ts CHANGED
@@ -39,34 +39,36 @@
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.
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.
63
65
  *
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.
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.
70
72
  *
71
73
  * Pure: no clock, no randomness, no runtime. The pieces, the attachments and the
72
74
  * rest table are handed in by whichever poser drew the frames.
@@ -164,6 +166,15 @@ export interface SeamKindReading {
164
166
  worst: SeamHit | null;
165
167
  }
166
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
+
167
178
  /** One pair's reading over one frame set. */
168
179
  export interface SeamPairReading {
169
180
  a: string;
@@ -188,6 +199,11 @@ export interface SeamPairReading {
188
199
  gapOpen: SeamKindReading;
189
200
  /** Gap cells whose skeleton composite has positive rendered alpha, even below half opacity. */
190
201
  gapCovered: SeamKindReading;
202
+ /** Positive-alpha cells contributed by the named slot where all OTHER slots have zero alpha. Independent of the band. */
203
+ exposureA: SeamKindReading;
204
+ exposureB: SeamKindReading;
205
+ /** Every sampled frame, including unavailable frames. Independent of raw gap/ghost and their summaries. */
206
+ jointContact: { frames: SeamContactFrame[] };
191
207
  }
192
208
 
193
209
  /** `seam.json`: one frame set's reading, every pair the flag names, in the flag's order. */
@@ -198,7 +214,7 @@ export interface SeamFile {
198
214
  skin?: string;
199
215
  fps: number;
200
216
  /** 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 };
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 };
202
218
  pairs: SeamPairReading[];
203
219
  }
204
220
 
@@ -217,6 +233,12 @@ export interface SeamPlant {
217
233
  ghostAnyArt?: boolean;
218
234
  /** Attribute every ghost to a, regardless of which piece draws it. */
219
235
  ghostOnlyA?: boolean;
236
+ /** Ignore later drawings when reading ghosts. */
237
+ hiddenGhost?: boolean;
238
+ /** Bridge unrelated overlap cells with the old global hull. */
239
+ globalHull?: boolean;
240
+ /** Force a measured contact to one answer, ignoring the rendered material. */
241
+ contact?: 'joined' | 'separated';
220
242
  /** Wrong composite readings: half opacity, only the pair, or every gap forced to one kind. */
221
243
  gapCoverage?: 'half' | 'pair' | 'open' | 'covered';
222
244
  /** Report a pair with no band as read, rather than as a SKIP. */
@@ -387,11 +409,10 @@ function frameAlphaCells(pieces: readonly Piece[], pages: ReadonlyMap<string, Pl
387
409
  return cells;
388
410
  }
389
411
 
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.
412
+ /** One origin-aligned texel grid, also used when only one named slot draws. */
413
+ function gridOf(sides: readonly Side[], cell: number, extra: readonly Piece[] = []): Grid {
393
414
  let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
394
- for (const xy of [A.rest, B.rest, A.piece.world, B.piece.world]) {
415
+ for (const xy of [...sides.flatMap((side) => [side.rest, side.piece.world]), ...extra.map((piece) => piece.world)]) {
395
416
  for (let i = 0; i + 1 < xy.length; i += 2) {
396
417
  x0 = Math.min(x0, xy[i]);
397
418
  y0 = Math.min(y0, xy[i + 1]);
@@ -403,7 +424,114 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
403
424
  const maxY = (Math.ceil(y1 / cell) + 1) * cell;
404
425
  const width = Math.ceil((x1 - minX) / cell) + 1;
405
426
  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 };
427
+ return { viewport: { minX, minY: maxY - height * cell, maxX: minX + width * cell, maxY, scale: 1 / cell, width, height }, cell };
428
+ }
429
+
430
+ const emptyCells = (): Cells => ({ px: 0, box: null });
431
+ function takeCell(into: Cells, qx: number, qy: number, cell: number): void {
432
+ into.px++;
433
+ const h = cell / 2;
434
+ into.box ??= [Infinity, Infinity, -Infinity, -Infinity];
435
+ into.box[0] = Math.min(into.box[0], qx - h);
436
+ into.box[1] = Math.min(into.box[1], qy - h);
437
+ into.box[2] = Math.max(into.box[2], qx + h);
438
+ into.box[3] = Math.max(into.box[3], qy + h);
439
+ }
440
+
441
+ /**
442
+ * Symbolic nonzero source-over contribution per named slot. Later alpha 255
443
+ * erases earlier contributions; any positive emission of that slot adds one.
444
+ * Partial alpha cannot erase a nonzero contribution. Traverse EVERY fragment,
445
+ * including multiple pieces of the same slot and clipped source meshes.
446
+ * Exposure asks a separate counterfactual: would removing this slot leave
447
+ * alpha zero? It therefore excludes even faint art of any other slot, before
448
+ * OR after the named slot. No setup comparison or seam band enters.
449
+ */
450
+ function slotCells(frame: SeamFrame, pages: ReadonlyMap<string, Plate>, grid: Grid, slots: readonly string[]): { visible: Uint8Array; exposure: Cells }[] {
451
+ const n = grid.viewport.width * grid.viewport.height;
452
+ const masks = slots.map(() => ({ visible: new Uint8Array(n), own: new Uint8Array(n), other: new Uint8Array(n), exposure: emptyCells() }));
453
+ const project = projector(grid.viewport);
454
+ for (const piece of frame.pieces) {
455
+ const page = pages.get(piece.page);
456
+ if (page === undefined) throw new SeamError(`slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`);
457
+ rasterisePiece(page, piece, project, grid.viewport, (x, y, _r, _g, _b, alpha) => {
458
+ const k = y * grid.viewport.width + x;
459
+ for (let i = 0; i < slots.length; i++) {
460
+ const m = masks[i];
461
+ if (alpha === 255) m.visible[k] = 0;
462
+ if (alpha > 0) {
463
+ if (piece.slot === slots[i]) m.own[k] = m.visible[k] = 1;
464
+ else m.other[k] = 1;
465
+ }
466
+ }
467
+ });
468
+ }
469
+ for (const m of masks) for (let k = 0; k < n; k++) if (m.own[k] && !m.other[k]) {
470
+ takeCell(m.exposure, centreX(grid, k % grid.viewport.width), centreY(grid, Math.floor(k / grid.viewport.width)), grid.cell);
471
+ }
472
+ return masks;
473
+ }
474
+
475
+ /** Rasterise a convex swept cell by scanline; never fill between unrelated band cells. */
476
+ function markHull(hull: ReadonlyArray<[number, number]>, grid: Grid, cells: Uint8Array): void {
477
+ if (hull.length === 0) return;
478
+ const { minX, maxY, width, height } = grid.viewport;
479
+ const lo = Math.max(0, Math.ceil((maxY - Math.max(...hull.map((p) => p[1]))) / grid.cell - 0.5));
480
+ const hi = Math.min(height - 1, Math.floor((maxY - Math.min(...hull.map((p) => p[1]))) / grid.cell - 0.5));
481
+ for (let py = lo; py <= hi; py++) {
482
+ const y = centreY(grid, py);
483
+ let left = Infinity, right = -Infinity;
484
+ for (let i = 0; i < hull.length; i++) {
485
+ const [x0, y0] = hull[i], [x1, y1] = hull[(i + 1) % hull.length];
486
+ if (y < Math.min(y0, y1) || y > Math.max(y0, y1)) continue;
487
+ if (y0 === y1) { left = Math.min(left, x0, x1); right = Math.max(right, x0, x1); }
488
+ else { const x = x0 + (y - y0) * (x1 - x0) / (y1 - y0); left = Math.min(left, x); right = Math.max(right, x); }
489
+ }
490
+ const x0 = Math.max(0, Math.ceil((left - minX) / grid.cell - 0.5));
491
+ const x1 = Math.min(width - 1, Math.floor((right - minX) / grid.cell - 0.5));
492
+ for (let px = x0; px <= x1; px++) cells[py * width + px] = 1;
493
+ }
494
+ }
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
+
532
+ /** One frame's counts, or `null` when the two drawings share no cell at the setup pose. See the module comment. */
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 {
534
+ const { width, height } = grid.viewport;
407
535
  const restA = restPiece(A);
408
536
  const restB = restPiece(B);
409
537
  const bandA = drawnCells(restA, A.page, grid.viewport);
@@ -411,7 +539,13 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
411
539
  // The band: both drawings at the setup pose. Carried to the frame by each piece, its centres span the joint's hull.
412
540
  const restIdsA = triangleIds(A.triangles, A.rest, grid);
413
541
  const restIdsB = triangleIds(B.triangles, B.rest, grid);
542
+ // The corner lattice is offset by half a cell. Reuse the same lowest-index
543
+ // containing-triangle rule as centres, including overlapping setup meshes.
544
+ 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 } };
545
+ const cornerIdsA = triangleIds(A.triangles, A.rest, corners);
546
+ const cornerIdsB = triangleIds(B.triangles, B.rest, corners);
414
547
  const carried: Array<[number, number]> = [];
548
+ const joint = new Uint8Array(width * height);
415
549
  let band = 0;
416
550
  let [bx0, by0, bx1, by1] = [Infinity, Infinity, -Infinity, -Infinity];
417
551
  for (let py = 0; py < height; py++) {
@@ -422,16 +556,28 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
422
556
  const qx = centreX(grid, px);
423
557
  const qy = centreY(grid, py);
424
558
  [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) {
559
+ const local: Array<[number, number]> = [];
560
+ for (const [side, ids, cornerIds] of [[A, restIdsA, cornerIdsA], [B, restIdsB, cornerIdsB]] as const) {
426
561
  const p = plant.world ? ([qx, qy] as [number, number]) : carry(side.triangles, side.rest, side.piece.world, ids[k], qx, qy);
427
562
  if (p !== null) carried.push(p);
563
+ // The same material cell through BOTH attachment maps. Corners use
564
+ // their own triangle when the cell crosses a skinned mesh edge.
565
+ for (const ox of [0, 1]) for (const oy of [0, 1]) {
566
+ const x = grid.viewport.minX + (px + ox) * cell, y = grid.viewport.maxY - (py + oy) * cell;
567
+ const id = cornerIds[(py + oy) * (width + 1) + px + ox];
568
+ const corner = plant.world ? [x, y] as [number, number] : carry(side.triangles, side.rest, side.piece.world, id, x, y);
569
+ if (corner !== null) local.push(corner);
570
+ }
428
571
  }
572
+ markHull(hullOf(local), grid, joint);
429
573
  }
430
574
  }
431
575
  if (band === 0) return null;
432
576
  const hull = hullOf(carried);
433
577
  const drawnA = drawnCells(A.piece, A.page, grid.viewport);
434
578
  const drawnB = drawnCells(B.piece, B.page, grid.viewport);
579
+ const contact = contactOf(bandA, bandB, drawnA, drawnB, width, plant);
580
+ const [visibleA, visibleB] = visibility;
435
581
  const posedIdsA = triangleIds(A.triangles, A.piece.world, grid);
436
582
  const posedIdsB = triangleIds(B.triangles, B.piece.world, grid);
437
583
  // A frame point carried back into a side's setup pose.
@@ -446,16 +592,8 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
446
592
  const gapOpen: Cells = { px: 0, box: null };
447
593
  const gapCovered: Cells = { px: 0, box: null };
448
594
  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
- };
458
- if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
595
+ const take = (into: Cells, qx: number, qy: number): void => takeCell(into, qx, qy, cell);
596
+ if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered, contact };
459
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;
460
598
  for (let py = 0; py < height; py++) {
461
599
  for (let px = 0; px < width; px++) {
@@ -465,7 +603,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
465
603
  const a = drawnA[k] !== 0;
466
604
  const b = drawnB[k] !== 0;
467
605
  if (!a && !b) {
468
- if (!plant.noHull && !inHull(hull, qx, qy)) continue;
606
+ if (!plant.noHull && (!inHull(hull, qx, qy) || (!plant.globalHull && !joint[k]))) continue;
469
607
  const fromA = composite(back(A, posedIdsA, k, qx, qy));
470
608
  const fromB = composite(back(B, posedIdsB, k, qx, qy));
471
609
  if (plant.bothViews ? fromA && fromB : fromA || fromB) {
@@ -477,6 +615,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
477
615
  continue;
478
616
  }
479
617
  if (a && b) continue;
618
+ if (!plant.hiddenGhost && !(a ? visibleA[k] : visibleB[k])) continue;
480
619
  // One piece draws the cell and its partner does not.
481
620
  const [mine, myIds, myRest, other, otherIds] = a ? [A, posedIdsA, restA, B, posedIdsB] : [B, posedIdsB, restB, A, posedIdsA];
482
621
  const otherRest = a ? restB : restA;
@@ -488,7 +627,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
488
627
  }
489
628
  }
490
629
  }
491
- return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
630
+ return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered, contact };
492
631
  }
493
632
 
494
633
  const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
@@ -551,18 +690,31 @@ export function seamsOf(
551
690
  const ghostsB: SeamHit[] = [];
552
691
  const openGaps: SeamHit[] = [];
553
692
  const coveredGaps: SeamHit[] = [];
693
+ const exposuresA: SeamHit[] = [], exposuresB: SeamHit[] = [];
694
+ const contacts: SeamContactFrame[] = [];
554
695
  for (const frame of frames) {
555
696
  const A = sideOf(frame, a);
556
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'));
700
+ const sides = [A, B].filter((side): side is Side => side !== null);
701
+ if (sides.length === 0) continue;
702
+ const sizes = sides.map((side) => texelSize(side.piece, side.rest, side.page));
703
+ 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`);
704
+ const exposureCell = Math.max(...sizes.filter((size): size is number => size !== null));
705
+ const exposureGrid = gridOf(sides, exposureCell, frame.pieces.filter((piece) => piece.slot === a || piece.slot === b));
706
+ const exposure = slotCells(frame, pages, exposureGrid, [a, b]);
707
+ for (const [i, into] of [[0, exposuresA], [1, exposuresB]] as const) {
708
+ const cells = exposure[i].exposure;
709
+ 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 });
710
+ }
557
711
  if (A === null || B === null) continue;
558
712
  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);
565
- if (read === null) continue;
713
+ const cell = exposureCell;
714
+ const read = readFrame(A, B, cell, plant, frame, pages, exposureGrid, exposure.map((m) => m.visible));
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);
566
718
  banded++;
567
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) {
568
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 });
@@ -573,26 +725,29 @@ export function seamsOf(
573
725
  const opens = [...new Set([...gaps, ...ghosts].map((f) => f.index))].sort((x, y) => x - y);
574
726
  const maxPx = Math.max(0, ...gaps.map((f) => f.px), ...ghosts.map((f) => f.px));
575
727
  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) };
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 } };
577
729
  });
578
730
  }
579
731
 
580
732
  /** What `seam.json` states its numbers mean — the one wording the file and the docs share. */
581
733
  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²",
734
+ 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',
735
+ 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
736
  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
737
  band: "the band is the cells both drawings draw at the setup pose — where the two were painted to overlap, dilated by nothing",
586
738
  carried:
587
739
  "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",
740
+ 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",
741
+ 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
742
  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
743
  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
744
  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
745
  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
746
  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',
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',
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',
595
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',
596
751
  };
597
752
 
598
753
  /** The file for one frame set. */
@@ -609,7 +764,7 @@ export function seamText(file: SeamFile): string {
609
764
  const outward = (x: number, up: boolean): string => ((up ? Math.ceil(x * 100) : Math.floor(x * 100)) / 100).toFixed(2);
610
765
 
611
766
  /** 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 {
767
+ function kindText(kind: string, reading: SeamKindReading): string {
613
768
  const w = reading.worst;
614
769
  if (w === null) return `no ${kind}`;
615
770
  const [x0, y0, x1, y1] = w.box;
@@ -624,7 +779,7 @@ function kindText(kind: 'gap' | 'ghost', reading: SeamKindReading): string {
624
779
  * The line for one pair over one set — printed for every pair, so a pair that
625
780
  * stays closed says so, and a pair with nothing to read says SKIP and why.
626
781
  */
627
- export function seamLine(animation: string | null, fps: number, pair: SeamPairReading): string {
782
+ function seamSummary(animation: string | null, fps: number, pair: SeamPairReading): string {
628
783
  const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
629
784
  const head = `${where}: seam "${pair.a}" | "${pair.b}"`;
630
785
  if (pair.drawn === 0) return `${head}: SKIP — the two slots draw together on no frame, so there is no seam to read`;
@@ -635,3 +790,15 @@ export function seamLine(animation: string | null, fps: number, pair: SeamPairRe
635
790
  `${kindText('gap', pair.gap)}; ${kindText('ghost', pair.ghost)}`
636
791
  );
637
792
  }
793
+
794
+ /** The seam summary plus whole-drawing background exposures, including on SKIP pairs. */
795
+ export function seamLine(animation: string | null, fps: number, pair: SeamPairReading): string {
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('; ')})` : ''}`;
804
+ }