@rig-c/core 2.67.2 → 2.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -186,6 +186,16 @@ of them: it reads whatever frames you point it at, so it runs from the installed
186
186
  package on pictures of your own — which is what *Where to go next* below tells you
187
187
  to do with it, and it is the one instrument here that can see a wrong animation.
188
188
 
189
+ Programmatic checked consumers can import `rig-c/render-shared`: the official
190
+ candidate chooser (`loadCandidate`, `throughPoser`), setup and animation samplers
191
+ (`sampleSetupPose`, `sampleAnimation`) and raster reads (`pageFor`, `projector`,
192
+ `rasterisePiece`, `renderFrame`), with their argument/result types and named
193
+ failures. It loads without the optional Spine runtime; a Spine input requires
194
+ explicitly loading `rig-c/render`, whose eager runtime dependency is unchanged.
195
+ The promised scope and planned first minor release are in
196
+ [RELEASING.md, The import surface](https://github.com/rig-c/core/blob/main/RELEASING.md#the-import-surface);
197
+ [AUTHORING §0.5](docs/AUTHORING.md#05-reading-a-build-from-code) shows the call chain.
198
+
189
199
  ### Install it into your agent
190
200
 
191
201
  The guides under [Documentation](#documentation) also ship as
@@ -606,7 +616,7 @@ commands take it and what its default is.
606
616
  | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
607
617
  | `split --rig … --motion … --slot <name> --out <dir>` | the answer to a mesh refused because its triangles are **several pieces that share no vertex** — two legs after the triangles bridging them were deleted. It reads the two specs and writes them back into an absent or empty `--out` with one mesh per piece: piece 1 stays in the slot, piece k gets a new slot `<slot>_<k>` after it, each with the original's uvs and weights unchanged, its triangles renumbered outline first where the hull rule needs it, and every deform key re-sliced to the new indices — plus `split.json`, where every original vertex and triangle went. One piece is said so and nothing is written; what it cannot carry is refused by name ([AUTHORING §3.4.11](docs/AUTHORING.md)) |
608
618
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
609
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`, one directory per set: `<anim>@<fps>fps/` rather than `<anim>/` at any rate but the default — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box — a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box — a reading, not a gate; the exit status never moves. `--overlaps` adds an `overlaps.json` per set and prints one `OVERLAP "<under>" under "<over>"` line per pair of slots where the one drawn later covers the earlier one and the motion moves it — the frames, and the most pixels covered and the frame of them — with an outline plate per covered frame; the render reports what was drawn, and the draw-order key is the author's remedy — a reading, not a gate. `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on — a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time — the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene |
619
+ | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`, one directory per set: `<anim>@<fps>fps/` rather than `<anim>/` at any rate but the default — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box — a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box; `ghostA` and `ghostB` partition the same ghost by which named pair member drew it, each with its own frames, spans and worst; `gapOpen` and `gapCovered` partition `gap` by zero versus positive rendered alpha from the whole skeleton, including faint and clipped drawings ([AUTHORING §8.8](docs/AUTHORING.md#88-where-two-slots-stop-meeting--render---seam)) — a reading, not a gate; the exit status never moves. `--overlaps` adds an `overlaps.json` per set and prints one `OVERLAP "<under>" under "<over>"` line per pair of slots where the one drawn later covers the earlier one and the motion moves it — the frames, and the most pixels covered and the frame of them — with an outline plate per covered frame; the render reports what was drawn, and the draw-order key is the author's remedy — a reading, not a gate. `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on — a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time — the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene |
610
620
  | `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
611
621
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
612
622
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
package/docs/AUTHORING.md CHANGED
@@ -1617,6 +1617,68 @@ written`, followed by `rigc repack: nothing was written to <out>; the work direc
1617
1617
 
1618
1618
  ---
1619
1619
 
1620
+ ### 0.5 Reading a build from code
1621
+
1622
+ The named `rig-c/render-shared` entry promises the official renderer reads for
1623
+ checked consumers (issue #1561). It is planned for the next minor, 2.69.0; wait
1624
+ for the measured release before using `>=2.69.0 <3.0.0`. It requires Bun and the
1625
+ candidate's real files, but imports no optional Spine runtime. The existing
1626
+ `rig-c/render` entry still eagerly imports that runtime.
1627
+
1628
+ ```ts
1629
+ import { readFileSync } from 'node:fs';
1630
+ import { join } from 'node:path';
1631
+ import {
1632
+ loadCandidate, throughPoser, sampleSetupPose, sampleAnimation,
1633
+ pageFor, projector, rasterisePiece, renderFrame,
1634
+ type Viewport,
1635
+ } from 'rig-c/render-shared';
1636
+
1637
+ const dir = 'build';
1638
+ const skeleton = join(dir, 'skeleton.json');
1639
+ const atlas = join(dir, 'skeleton.atlas');
1640
+ const candidate = loadCandidate({
1641
+ skeletonText: readFileSync(skeleton, 'utf8'),
1642
+ atlasText: readFileSync(atlas, 'utf8'),
1643
+ atlasDir: dir, label: skeleton,
1644
+ }, { skeleton, atlas }, undefined);
1645
+ const read = throughPoser(candidate.choice, (poser, roster) => ({
1646
+ setup: sampleSetupPose(poser, { bones: true, geometry: true })[0],
1647
+ frames: sampleAnimation(poser, 'wave', 12),
1648
+ skins: roster.skins,
1649
+ }));
1650
+ // The consumer supplies the world box and pixel grid it intends to measure.
1651
+ const viewport: Viewport = {
1652
+ minX: 0, minY: 0, maxX: 256, maxY: 256,
1653
+ scale: 1, width: 256, height: 256,
1654
+ };
1655
+ const project = projector(viewport);
1656
+ for (const piece of read.value.setup.pieces) {
1657
+ rasterisePiece(pageFor(candidate.pages, piece), piece, project, viewport,
1658
+ (x, y, r, g, b, a) => { /* read each covered sample */ });
1659
+ }
1660
+ const image = renderFrame(read.value.setup, candidate.pages, viewport, [0, 0, 0, 0]);
1661
+ ```
1662
+
1663
+ The animation name and viewport above belong to the consumer; the loader does
1664
+ not invent them. The chooser checks the model document beside the skeleton,
1665
+ its `spine.sha256`, atlas placement, page files and bone/slot/animation rosters.
1666
+ A /3 document can be loaded with `atlasText: null` when its atlas is absent.
1667
+ `throughPoser` returns `poser` and `note` with its value. Keep its callback free
1668
+ of writes: an explicitly registered runtime fallback can rerun it.
1669
+
1670
+ The entry also exports the original `CandidateAtlasError` (missing required
1671
+ atlas), `CandidatePairError` (unloadable pair or missing page),
1672
+ `SpineRuntimeError` (input needs an unregistered or unusable Spine side),
1673
+ `PoserChoiceError` (a forced-core pose refusal), `SlotSubsetError` (invalid
1674
+ slot subset) and `CoreInputError` (core input/pose refusal). The chooser's
1675
+ mismatch reason remains in the refusal: no silent substitute is selected by
1676
+ this entry. Unknown animation and missing `pageFor` page are `Error`s naming
1677
+ the requested name and available names. A forced-core flag keeps the existing
1678
+ deferred choice check; it alone does not prove `candidate.choice.core` exists.
1679
+ To use a Spine export or `forced: 'spine'`, install the runtime and explicitly
1680
+ import `rig-c/render` first; importing `render-shared` never registers it.
1681
+
1620
1682
  ## 1. The two files
1621
1683
 
1622
1684
  ### 1.1 A complete minimal rig spec
@@ -8933,7 +8995,8 @@ table's vertices, and the atlas alpha:
8933
8995
  - **Drawn** is the renderer painting a cell's centre at **128 of 255** or more —
8934
8996
  the half-opacity line `--pass-through` draws outlines at, the frame's tint
8935
8997
  included, the setup pose drawn at the same tint (so a slot faded out is not a
8936
- tear: it is faded in both). No other alpha enters.
8998
+ tear: it is faded in both). This threshold defines the pair's band, gap and
8999
+ ghost; the composite coverage partition below asks a different question.
8937
9000
  - **The band** is the cells both drawings draw at the setup pose — where the two
8938
9001
  were painted to overlap. It has **no width of its own**: it is the overlap the
8939
9002
  art states, dilated by nothing. A frame whose band is empty is not read; a pair
@@ -8949,8 +9012,30 @@ table's vertices, and the atlas alpha:
8949
9012
  the drawing piece, carrying it back, lands in the band, and which the other,
8950
9013
  carrying it back, finds neither drawing at at the setup pose: the overlap swung
8951
9014
  clear of its partner.
9015
+ - **ghostA**, **ghostB** — that same ghost partitioned by the piece drawing the
9016
+ cell: `a` only or `b` only, respectively, in the flag's named pair order. On
9017
+ each frame `ghost.px = ghostA.px + ghostB.px` (an absent hit contributes 0),
9018
+ and each owner's box lies inside the union's. Reversing the pair exchanges
9019
+ the complete owner readings and preserves `gap` and `ghost`.
9020
+ - **gapOpen**, **gapCovered** — the same gap cells partitioned by the actual
9021
+ skeleton composite at their centres, on the same cell grid, over a transparent
9022
+ background: alpha **0** is open; alpha **>0** is covered. Every skeleton drawing
9023
+ enters, including the pair itself, with the frame's tint, UVs and clipping.
9024
+ Alpha **1..127 covers too**: this is not the pair's half-opacity outline test.
9025
+ The renderer's existing byte quantization remains in force: it emits rounded
9026
+ alpha only when the sampled, tinted value exceeds 0.5 on the 0..255 scale;
9027
+ a value that produces no rendered alpha byte is open. No display background
9028
+ enters. Source-over cannot erase positive alpha, so the renderer's positive
9029
+ emissions give exactly the nonzero alpha of its transparent composite.
9030
+
9031
+ On every frame `gap.px = gapOpen.px + gapCovered.px` (missing hit means 0).
9032
+ Each partition's box lies in the union's. A gap in the pair can be covered by a
9033
+ third drawing and need not be a visible opening. Reversing the pair preserves
9034
+ both gap coverage readings.
8952
9035
 
8953
9036
  Draw order does not enter: a cell is covered or bare whichever piece is in front.
9037
+ An owner says which piece drew a ghost cell; it does not say which piece is over
9038
+ or under the other, or whether that exposure should fail a consumer's policy.
8954
9039
 
8955
9040
  **Its reach, stated.** A piece folded right back over its partner carries the band
8956
9041
  across the region it vacated, so a hull that wide reads the vacated cells its
@@ -8970,7 +9055,7 @@ block carries the definitions above in words.
8970
9055
  | `animation` | string or `null` | — | the set's animation; `null` for a skeleton with none (its one frame is the setup pose) |
8971
9056
  | `skin` | string | — | the skin asked for; **absent** when none was |
8972
9057
  | `fps` | number | frames per second | the rate the set was sampled at |
8973
- | `reading` | object | — | `read`, `cell`, `drawn`, `band`, `carried`, `gap`, `ghost`, `box`, `status`: the definitions, in words |
9058
+ | `reading` | object | — | `read`, `cell`, `drawn`, `band`, `carried`, `gap`, `ghost`, `box`, `status`, `ghostA`, `ghostB`, `gapOpen`, `gapCovered`: the definitions, in words |
8974
9059
  | `pairs[]` | array | — | one per pair, in the flag's order |
8975
9060
  | `pairs[].a`, `pairs[].b` | string | — | the two slots, as the flag names them |
8976
9061
  | `pairs[].drawn` | integer | frames | frames on which both slots draw |
@@ -8979,6 +9064,8 @@ block carries the definitions above in words.
8979
9064
  | `pairs[].opens` | integer[] | frame indices | every frame with a gap or a ghost, ascending |
8980
9065
  | `pairs[].maxPx` | integer | px (cells) | the most cells of either kind on any frame; 0 when none opens |
8981
9066
  | `pairs[].gap`, `pairs[].ghost` | object | — | one per kind, each `{ frames, spans, worst }` |
9067
+ | `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` |
9068
+ | `pairs[].gapOpen`, `pairs[].gapCovered` | object | — | the same `{ frames, spans, worst }` shape for gap cells with zero or positive rendered skeleton-composite alpha, respectively; an empty kind has `frames: []`, `spans: []`, `worst: null` |
8982
9069
  | `<kind>.frames[]` | hit[] | — | only the frames with a cell of that kind, in frame order |
8983
9070
  | `<kind>.spans[]` | array | — | `<kind>.frames` as runs of consecutive indices: `{ frames: [first, last], times: [first, last], maxPx }` — frame indices, seconds, px |
8984
9071
  | `<kind>.worst` | hit or `null` | — | the frame with the most cells, the earliest on a tie; `null` when none |
@@ -8995,6 +9082,16 @@ span.times, maxPx: span.maxPx, kind }`, and one pair over one animation is
8995
9082
  `opens.length` frame(s) `[opens]`, max `maxPx` px — with `status: "skip"` the case
8996
9083
  to say SKIP for.
8997
9084
 
9085
+ The owner keys and their two legend entries are additive in `rigc-seam/1`.
9086
+ Removing just those four keys leaves the previous file's bytes after the same
9087
+ two-space JSON serialization and newline. The union, its spans and worst, and
9088
+ the printed summary retain their existing meanings; an owner may have different
9089
+ spans or a different worst frame from the union.
9090
+ Likewise `gapOpen`, `gapCovered` and their two legend entries are additive:
9091
+ removing only those entries leaves the previous file's bytes after the same
9092
+ serialization, including `gap`, `ghostA` and `ghostB`. The coverage partition may
9093
+ have different spans and worst frames from the union; it changes no seam policy.
9094
+
8998
9095
  **Refused, nothing written:** a slot the skeleton does not have (with the slots it
8999
9096
  does), an entry without exactly one `:`, an empty entry or name, a slot paired with
9000
9097
  itself (a seam is between two drawings), a pair named twice in either order (the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.67.2",
3
+ "version": "2.69.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": {
@@ -11,6 +11,7 @@
11
11
  "./font5x7": "./tools/font5x7.ts",
12
12
  "./transform": "./src/transform.ts",
13
13
  "./render": "./src/render.ts",
14
+ "./render-shared": "./src/render_read.ts",
14
15
  "./png": "./src/png.ts",
15
16
  "./compile": "./src/compile.ts",
16
17
  "./rig": "./src/rig.ts",
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The promised renderer reads for checked consumers (issue #1561).
3
+ *
4
+ * This entry registers no Spine side. Rigc builds use the official candidate
5
+ * chooser and core poser; inputs needing Spine are refused by SpineRuntimeError
6
+ * unless the consumer has explicitly loaded rig-c/render. That entry keeps its
7
+ * eager runtime import and registers the same implementation this seam uses.
8
+ * Only the agreed reads, their types and their existing failure classes belong
9
+ * here. The broader implementation module is not the public contract.
10
+ */
11
+ export {
12
+ loadCandidate,
13
+ throughPoser,
14
+ sampleAnimation,
15
+ sampleSetupPose,
16
+ pageFor,
17
+ projector,
18
+ rasterisePiece,
19
+ renderFrame,
20
+ CandidateAtlasError,
21
+ CandidatePairError,
22
+ PoserChoiceError,
23
+ SlotSubsetError,
24
+ } from './render_shared.ts';
25
+ export { SpineRuntimeError } from './spine_side.ts';
26
+ export { CoreInputError } from './core/index.ts';
27
+ export type {
28
+ Candidate,
29
+ SkeletonFacts,
30
+ PoserChoice,
31
+ PoserName,
32
+ MakeCorePoser,
33
+ Poser,
34
+ Posed,
35
+ PoseSource,
36
+ PoseOptions,
37
+ DrawOptions,
38
+ SkinRoster,
39
+ SlotSubset,
40
+ Frame,
41
+ BoneSnapshot,
42
+ AttachmentPose,
43
+ AttachmentRest,
44
+ Piece,
45
+ PieceCommon,
46
+ PieceTexture,
47
+ UvWindow,
48
+ Quad,
49
+ Mesh,
50
+ ClipSource,
51
+ Viewport,
52
+ EmitPixel,
53
+ } from './render_shared.ts';
54
+ export type { Plate, RGBA } from '../tools/plate.ts';
55
+ export type { EnvironmentStep, EnvironmentChange } from './environment.ts';
package/src/seam.ts CHANGED
@@ -56,6 +56,10 @@
56
56
  *
57
57
  * Each frame's figures are the cell counts (`px`), the cell size, their area,
58
58
  * and the world box of the counted cells (each cell's whole square).
59
+ * The pair's gap is partitioned into gapOpen (the transparent skeleton frame
60
+ * has alpha 0 there) and gapCovered (positive rendered alpha from any piece,
61
+ * including a faint pair member). This does not change the half-opacity pair
62
+ * predicate: another drawing can cover what the pair no longer covers.
59
63
  *
60
64
  * Known reach, stated rather than discovered: a piece folded right back over its
61
65
  * partner carries the band across the region it vacated, so a hull that wide
@@ -71,13 +75,13 @@
71
75
  import type { Plate } from '../tools/plate.ts';
72
76
  import { indexRuns } from './folds.ts';
73
77
  import { DRAWN_ALPHA, drawnCells, drawsAt, sameVertices, texelSize } from './passthrough.ts';
74
- import type { AttachmentPose, AttachmentRest, Piece, Viewport } from './render_shared.ts';
78
+ import { type AttachmentPose, type AttachmentRest, type Piece, projector, rasterisePiece, type Viewport } from './render_shared.ts';
75
79
 
76
80
  /** The reading's file name inside an animation's frame directory, and its format tag. */
77
81
  export const SEAM_FILE = 'seam.json';
78
82
  export const SEAM_SPEC = 'rigc-seam/1';
79
83
 
80
- /** One pair as the flag names it. The reading is symmetric: `a:b` and `b:a` are one pair. */
84
+ /** One pair as the flag names it. Reversing it preserves the union and exchanges the ghost owners. */
81
85
  export interface SeamPair {
82
86
  a: string;
83
87
  b: string;
@@ -176,6 +180,14 @@ export interface SeamPairReading {
176
180
  maxPx: number;
177
181
  gap: SeamKindReading;
178
182
  ghost: SeamKindReading;
183
+ /** Ghost cells drawn by `a` only; together with ghostB, an exact partition of ghost. */
184
+ ghostA: SeamKindReading;
185
+ /** Ghost cells drawn by `b` only. Ownership is the flag's order, not draw order. */
186
+ ghostB: SeamKindReading;
187
+ /** Gap cells whose skeleton composite has no rendered alpha at the cell centre. */
188
+ gapOpen: SeamKindReading;
189
+ /** Gap cells whose skeleton composite has positive rendered alpha, even below half opacity. */
190
+ gapCovered: SeamKindReading;
179
191
  }
180
192
 
181
193
  /** `seam.json`: one frame set's reading, every pair the flag names, in the flag's order. */
@@ -186,12 +198,12 @@ export interface SeamFile {
186
198
  skin?: string;
187
199
  fps: number;
188
200
  /** What the numbers mean, stated in the file so a reader does not need this module. */
189
- reading: { read: string; cell: string; drawn: string; band: string; carried: string; gap: string; ghost: string; box: string; status: string };
201
+ reading: { read: string; cell: string; drawn: string; band: string; carried: string; gap: string; ghost: string; box: string; status: string; ghostA: string; ghostB: string; gapOpen: string; gapCovered: string };
190
202
  pairs: SeamPairReading[];
191
203
  }
192
204
 
193
205
  /**
194
- * A planted defect, for the suite's mutants only (`SM` controls in
206
+ * A planted defect, for the suite's mutants only (`SE` controls in
195
207
  * `selftest.ts`). Production passes none.
196
208
  */
197
209
  export interface SeamPlant {
@@ -203,6 +215,10 @@ export interface SeamPlant {
203
215
  bothViews?: boolean;
204
216
  /** Count a ghost cell wherever a piece draws what its partner's view had none of, not only from the band. */
205
217
  ghostAnyArt?: boolean;
218
+ /** Attribute every ghost to a, regardless of which piece draws it. */
219
+ ghostOnlyA?: boolean;
220
+ /** Wrong composite readings: half opacity, only the pair, or every gap forced to one kind. */
221
+ gapCoverage?: 'half' | 'pair' | 'open' | 'covered';
206
222
  /** Report a pair with no band as read, rather than as a SKIP. */
207
223
  skipAsRead?: boolean;
208
224
  /** Count nothing. */
@@ -347,8 +363,28 @@ interface Cells {
347
363
 
348
364
  const restPiece = (side: Side): Piece => ({ ...side.piece, world: Array.from(side.rest) });
349
365
 
350
- /** One frame's two counts, or `null` when the two drawings share no cell at the setup pose. See the module comment. */
351
- function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant): { gap: Cells; ghost: Cells } | null {
366
+ /**
367
+ * Positive alpha of the transparent skeleton composite, on the pair's cell grid.
368
+ * The renderer emits rounded alpha bytes through this same traversal, including
369
+ * tint, UV windows and already-clipped pieces. Source-over cannot erase positive
370
+ * alpha: OR of positive emissions is exactly composite alpha > 0. No background
371
+ * enters, and the pair's half-opacity outline is not this test.
372
+ */
373
+ function frameAlphaCells(pieces: readonly Piece[], pages: ReadonlyMap<string, Plate>, viewport: Viewport, half: boolean): Uint8Array {
374
+ const cells = new Uint8Array(viewport.width * viewport.height);
375
+ const project = projector(viewport);
376
+ for (const piece of pieces) {
377
+ const page = pages.get(piece.page);
378
+ if (page === undefined) throw new SeamError(`slot "${piece.slot}" samples atlas page "${piece.page}", which is not among [${[...pages.keys()].join(', ')}]`);
379
+ rasterisePiece(page, piece, project, viewport, (x, y, _r, _g, _b, alpha) => {
380
+ if (half ? alpha >= DRAWN_ALPHA : alpha > 0) cells[y * viewport.width + x] = 1;
381
+ });
382
+ }
383
+ return cells;
384
+ }
385
+
386
+ /** One frame's counts, or `null` when the two drawings share no cell at the setup pose. See the module comment. */
387
+ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: SeamFrame, pages: ReadonlyMap<string, Plate>): { gap: Cells; ghost: Cells; ghostA: Cells; ghostB: Cells; gapOpen: Cells; gapCovered: Cells } | null {
352
388
  // One grid over both drawings at the setup pose and at the frame, on multiples of the cell from the world origin.
353
389
  let [x0, y0, x1, y1] = [Infinity, Infinity, -Infinity, -Infinity];
354
390
  for (const xy of [A.rest, B.rest, A.piece.world, B.piece.world]) {
@@ -401,6 +437,11 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant): { gap: Cel
401
437
  const composite = (p: [number, number] | null): boolean => drawnAtRest(p, restA, A.page) || drawnAtRest(p, restB, B.page);
402
438
  const gap: Cells = { px: 0, box: null };
403
439
  const ghost: Cells = { px: 0, box: null };
440
+ const ghostA: Cells = { px: 0, box: null };
441
+ const ghostB: Cells = { px: 0, box: null };
442
+ const gapOpen: Cells = { px: 0, box: null };
443
+ const gapCovered: Cells = { px: 0, box: null };
444
+ let alphaCells: Uint8Array | null = null;
404
445
  const take = (into: Cells, qx: number, qy: number): void => {
405
446
  into.px++;
406
447
  const h = cell / 2;
@@ -410,7 +451,7 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant): { gap: Cel
410
451
  into.box[2] = Math.max(into.box[2], qx + h);
411
452
  into.box[3] = Math.max(into.box[3], qy + h);
412
453
  };
413
- if (plant.never) return { gap, ghost };
454
+ if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
414
455
  const inBand = (p: [number, number] | null): boolean => p !== null && p[0] >= bx0 - cell / 2 && p[0] <= bx1 + cell / 2 && p[1] >= by0 - cell / 2 && p[1] <= by1 + cell / 2;
415
456
  for (let py = 0; py < height; py++) {
416
457
  for (let px = 0; px < width; px++) {
@@ -423,7 +464,12 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant): { gap: Cel
423
464
  if (!plant.noHull && !inHull(hull, qx, qy)) continue;
424
465
  const fromA = composite(back(A, posedIdsA, k, qx, qy));
425
466
  const fromB = composite(back(B, posedIdsB, k, qx, qy));
426
- if (plant.bothViews ? fromA && fromB : fromA || fromB) take(gap, qx, qy);
467
+ if (plant.bothViews ? fromA && fromB : fromA || fromB) {
468
+ take(gap, qx, qy);
469
+ const covered = plant.gapCoverage === 'covered' || (plant.gapCoverage !== 'open' &&
470
+ (alphaCells ??= frameAlphaCells(plant.gapCoverage === 'pair' ? [A.piece, B.piece] : frame.pieces, pages, grid.viewport, plant.gapCoverage === 'half'))[k] !== 0);
471
+ take(covered ? gapCovered : gapOpen, qx, qy);
472
+ }
427
473
  continue;
428
474
  }
429
475
  if (a && b) continue;
@@ -432,10 +478,13 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant): { gap: Cel
432
478
  const otherRest = a ? restB : restA;
433
479
  const p = back(mine, myIds, k, qx, qy);
434
480
  if (!plant.ghostAnyArt && !(inBand(p) && drawnAtRest(p, myRest, mine.page) && drawnAtRest(p, otherRest, other.page))) continue;
435
- if (!composite(back(other, otherIds, k, qx, qy))) take(ghost, qx, qy);
481
+ if (!composite(back(other, otherIds, k, qx, qy))) {
482
+ take(ghost, qx, qy);
483
+ take(a || plant.ghostOnlyA ? ghostA : ghostB, qx, qy);
484
+ }
436
485
  }
437
486
  }
438
- return { gap, ghost };
487
+ return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered };
439
488
  }
440
489
 
441
490
  const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
@@ -494,6 +543,10 @@ export function seamsOf(
494
543
  let banded = 0;
495
544
  const gaps: SeamHit[] = [];
496
545
  const ghosts: SeamHit[] = [];
546
+ const ghostsA: SeamHit[] = [];
547
+ const ghostsB: SeamHit[] = [];
548
+ const openGaps: SeamHit[] = [];
549
+ const coveredGaps: SeamHit[] = [];
497
550
  for (const frame of frames) {
498
551
  const A = sideOf(frame, a);
499
552
  const B = sideOf(frame, b);
@@ -504,10 +557,10 @@ export function seamsOf(
504
557
  const flat = cellA === null ? a : cellB === null ? b : null;
505
558
  if (cellA === null || cellB === null) throw new SeamError(`frame ${frame.index}: slot "${flat}" draws no area at its setup pose, so it has no texel to grid the seam by`);
506
559
  const cell = Math.max(cellA, cellB);
507
- const read = readFrame(A, B, cell, plant);
560
+ const read = readFrame(A, B, cell, plant, frame, pages);
508
561
  if (read === null) continue;
509
562
  banded++;
510
- for (const [cells, into] of [[read.gap, gaps], [read.ghost, ghosts]] as const) {
563
+ for (const [cells, into] of [[read.gap, gaps], [read.ghost, ghosts], [read.ghostA, ghostsA], [read.ghostB, ghostsB], [read.gapOpen, openGaps], [read.gapCovered, coveredGaps]] as const) {
511
564
  if (cells.px > 0 && cells.box !== null) into.push({ index: frame.index, time: frame.time, px: cells.px, cell, area: cells.px * cell * cell, box: cells.box });
512
565
  }
513
566
  }
@@ -516,7 +569,7 @@ export function seamsOf(
516
569
  const opens = [...new Set([...gaps, ...ghosts].map((f) => f.index))].sort((x, y) => x - y);
517
570
  const maxPx = Math.max(0, ...gaps.map((f) => f.px), ...ghosts.map((f) => f.px));
518
571
  const status = banded === 0 && !plant.skipAsRead ? 'skip' : 'read';
519
- return { a, b, drawn, banded, status, opens, maxPx, gap, ghost };
572
+ return { a, b, drawn, banded, status, opens, maxPx, gap, ghost, ghostA: kindOf(ghostsA), ghostB: kindOf(ghostsB), gapOpen: kindOf(openGaps), gapCovered: kindOf(coveredGaps) };
520
573
  });
521
574
  }
522
575
 
@@ -532,6 +585,10 @@ export const SEAM_READING: SeamFile['reading'] = {
532
585
  ghost: "a ghost cell is drawn at the frame by one piece and not the other; the drawing piece, carrying it back, lands it in the band, and the other piece, carrying it back, finds neither drawing there at the setup pose",
533
586
  box: "a frame's box is [minX, minY, maxX, maxY] in world units (y up) over the whole squares of that kind's counted cells",
534
587
  status: 'a pair is skip when no frame is read — the two never draw together, or never share a cell at the setup pose — which is nothing measured, not a seam that held',
588
+ ghostA: 'the ghost cells drawn by a only, with the same frames, spans and worst shape as ghost; ghostA and ghostB partition ghost on every frame',
589
+ ghostB: 'the ghost cells drawn by b only; a and b are the named pair order, not which piece is in front — reversing the pair exchanges ghostA and ghostB',
590
+ gapOpen: 'the gap cells whose centre has alpha 0 in the transparent frame composite of every skeleton drawing, using the renderer on the same cell grid; gapOpen and gapCovered partition gap',
591
+ gapCovered: 'the gap cells whose centre has positive rendered alpha from any skeleton drawing, including the pair; alpha 1..127 covers too — this is not the pair\'s half-opacity test, and no presentation background enters',
535
592
  };
536
593
 
537
594
  /** The file for one frame set. */