spine-rigc 3.3.0 → 3.4.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
@@ -378,11 +378,12 @@ What the flags mean:
378
378
  | `--max` | `render` and `inspect`: the longest side of the frame in pixels (default `256`, finite and at least `16`). On `render`, refused with `--viewport-from`, which already fixes the pixel size — **§8.6**, **§13** |
379
379
  | `--show`, `--dim`, `--at`, `--zoom` | `inspect` only — what it draws, whether the art is faded, which times of `--animation` it poses and the bone, slot or folds it frames to; §13 says each, with the refusals |
380
380
  | `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
381
- | `--extremes-only` | `render` only: imply `--extremes`, but write only `extremes.json` and readable `extremes.png` with the same bytes. Pose the same grid; rasterize only the setup/min/max pictures used by the plate. No ordinary frame PNGs, contact sheet or `frames.json`. Takes no `--sheet`, `--sheet-format`, `--page-edges`, `--geometry`, `--folds`, `--pass-through`, `--seam`, `--overlaps` or `--angle-range`; drop the named flag or use `--extremes` instead — **§8.11** |
381
+ | `--extremes-only` | `render` only: imply `--extremes`, but write only `extremes.json` and readable `extremes.png` with the same bytes. Pose the same grid; rasterize only the setup/min/max pictures used by the plate. No ordinary frame PNGs, contact sheet or `frames.json`. Takes no `--sheet`, `--sheet-format`, `--page-edges`, `--geometry`, `--folds`, `--pass-through`, `--seam`, `--seam-cells`, `--overlaps` or `--angle-range`; drop the named flag or use `--extremes` instead — **§8.11** |
382
382
  | `--parallax` | `render` only: a named slider dial, through the core poser. Write `parallax.json` and a readable `parallax.png` into `--out` on a fixed 9-value grid. This is a separate dial render mode, with `--skin`, `--max`, `--candidate`, `--atlas` and `--poser core`; it takes no animation, fps, subset, other diagnostic, sheet, environment or viewport flag. A missing dial is refused by name; unavailable fits report SKIP, never a pass — **§8.12** |
383
383
  | `--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** |
384
384
  | `--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** |
385
385
  | `--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** |
386
+ | `--seam-cells` | `render` only, with `--seam`: add exact `cells` lists to `gapOpen`/`gapCovered` hits (including `worst`), with setup-pose top and underlying slot names at the same world cell. Without `--seam` it is refused before output. No effect on the counts — **§8.8** |
386
387
  | `--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** |
387
388
  | `--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** |
388
389
  | `--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** |
@@ -9396,6 +9397,33 @@ block carries the definitions above in words.
9396
9397
  | hit `area` | number | world units² | `px × cell²` |
9397
9398
  | hit `box` | `[minX, minY, maxX, maxY]` | world units, y up | the counted cells' whole squares, in Spine world |
9398
9399
 
9400
+ **Exact cells, opt-in.** Add `--seam-cells` beside `--seam` to retain the
9401
+ cells counted by the existing gap classifier. Only `gapOpen` and `gapCovered`
9402
+ hits gain `cells`; `gap`, `ghost` and exposure hits keep their old shape. Each
9403
+ entry is `{ x, y, setupTop, setupUnder }`: `x,y` are the cell **centre** in world
9404
+ units, y up; the hit's `cell` gives the square's side. Lists are in descending
9405
+ `y`, then ascending `x` order (top row first, left to right), with no duplicates.
9406
+ Every nonempty hit has `cells.length === px`; absent hits still mean zero and
9407
+ SKIP pairs remain unmeasured. `worst` carries the same list as its frame hit.
9408
+
9409
+ `setupTop` is the topmost slot whose setup drawing emits positive rendered alpha
9410
+ at that **same world cell**, and `setupUnder` is the next distinct covering slot
9411
+ beneath it in setup draw order. Each is a slot name or `null` if absent. All
9412
+ slots participate, including third slots, faint alpha, texture holes and clipped
9413
+ fragments; multiple fragments of one slot count as one layer. These are setup
9414
+ layers, not the current frame's cover, not spatial neighbors, and not a cell
9415
+ carried back by the seam's inverse attachment maps. A currently open cell can
9416
+ therefore name setup art; a cell beyond all setup art has two null names.
9417
+
9418
+ This is an additive opt-in extension of **`rigc-seam/1`**: readers ignoring
9419
+ unknown hit keys retain every existing number and meaning. Exact-object readers
9420
+ must opt into the new shape. With the flag absent no setup attribution is sampled
9421
+ or rasterized and the sidecar bytes remain unchanged. With it present, setup is
9422
+ sampled lazily once per recipe/skin and its rendered-alpha layers are cached per
9423
+ distinct seam cell side across pairs and animation sets; only listed cells query
9424
+ the cache. Determinism controls compare two complete flagged renders, not just
9425
+ list lengths.
9426
+
9399
9427
  A downstream warning is a projection of this file and needs nothing re-measured:
9400
9428
  one span of one kind is `{ pair: "<a>|<b>", frames: span.frames, times:
9401
9429
  span.times, maxPx: span.maxPx, kind }`, and one pair over one animation is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "3.3.0",
3
+ "version": "3.4.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": {
@@ -45,7 +45,7 @@ import { PASS_THROUGH_FILE, passFrameLine, type PassPair, passSummaryLine, passT
45
45
  import type { Plate } from '../../tools/plate.ts';
46
46
  import { OVERLAPS_FILE, OverlapError, overlapLine, overlapPlate, overlapsFileOf, overlapsOf, overlapsPlateFile, overlapsText, plateParticipants, staticLine } from '../overlaps.ts';
47
47
  import { buildSheetSet, SHEET_MANIFEST_FILE, type SheetSetPack, SheetUsageError, sheetRigNameOf, sheetSidecarRecord, sheetSourceOf } from '../sheet.ts';
48
- import { readSeamPairs, SEAM_FILE, seamFileOf, seamLine, SeamError, type SeamPair, seamsOf, seamText } from '../seam.ts';
48
+ import { readSeamPairs, SEAM_FILE, seamFileOf, seamLine, SeamError, type SeamPair, seamsOf, seamText, setupCellReader } from '../seam.ts';
49
49
  import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
50
50
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
51
51
  import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, frameSetDir, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, prerollStart, prerollWarmPoints, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, sampleSetupPose, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser, type Viewport, viewportFor } from '../render_shared.ts';
@@ -737,7 +737,7 @@ export function cmdRender(flags: Record<string, string>): void {
737
737
  const given = readGivenViewport(flags);
738
738
  const extremesOnly = flags['extremes-only'] !== undefined;
739
739
  if (extremesOnly) {
740
- for (const flag of ['sheet', 'sheet-format', 'page-edges', 'geometry', 'folds', 'pass-through', 'seam', 'overlaps', 'angle-range', 'boundary-from']) {
740
+ for (const flag of ['sheet', 'sheet-format', 'page-edges', 'geometry', 'folds', 'pass-through', 'seam', 'seam-cells', 'overlaps', 'angle-range', 'boundary-from']) {
741
741
  if (flags[flag] !== undefined) {
742
742
  throw new UsageError(`--extremes-only takes no --${flag}: it writes only extremes.json and, when readable, extremes.png. Drop --${flag}, or use --extremes for ordinary frames and additional outputs`);
743
743
  }
@@ -827,6 +827,8 @@ export function cmdRender(flags: Record<string, string>): void {
827
827
  // The seam reading (issue #1456) is its own flag for --folds' reason, and refused beside a subset for --pass-through's:
828
828
  // it reads the two slots as the whole rig draws them.
829
829
  const seam = flags.seam;
830
+ const seamCells = flags['seam-cells'] !== undefined;
831
+ if (seamCells && seam === undefined) throw new UsageError('--seam-cells needs --seam <slot>:<slot>[,…]; no pair was supplied');
830
832
  if (seam !== undefined) {
831
833
  for (const flag of ['slot', 'hide'] as const) {
832
834
  if (flags[flag] !== undefined) {
@@ -854,7 +856,7 @@ export function cmdRender(flags: Record<string, string>): void {
854
856
  posersAsked(flags),
855
857
  );
856
858
  if (flags.parallax !== undefined) {
857
- for (const flag of ['animation', 'fps', 'slot', 'hide', 'geometry', 'folds', 'extremes', 'extremes-only', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'physics', 'boundary-from', 'viewport', 'viewport-from']) {
859
+ for (const flag of ['animation', 'fps', 'slot', 'hide', 'geometry', 'folds', 'extremes', 'extremes-only', 'pass-through', 'seam', 'seam-cells', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'physics', 'boundary-from', 'viewport', 'viewport-from']) {
858
860
  if (flags[flag] !== undefined) throw new UsageError(`--parallax takes no --${flag}: it samples one named dial on its own 9-value grid; drop --${flag} or run ordinary render separately`);
859
861
  }
860
862
  if (flags.poser !== undefined && flags.poser !== 'core') throw new UsageError('--parallax requires --poser core: it reads only a rigc compiled model');
@@ -1070,6 +1072,7 @@ export function cmdRender(flags: Record<string, string>): void {
1070
1072
  // whose slot draws on no frame of any set drawn is refused, as --pass-through refuses it, rather than read as a SKIP.
1071
1073
  const seamReadings = new Map<string, ReturnType<typeof seamFileOf>>();
1072
1074
  if (seamPairs !== undefined) {
1075
+ const cells = seamCells ? setupCellReader(() => sampleSetupPose(poser, { skin })[0].pieces, pages) : undefined;
1073
1076
  const draws = new Set<string>();
1074
1077
  for (const frames of sampled.values()) for (const frame of frames) for (const piece of frame.pieces) draws.add(piece.slot);
1075
1078
  for (const pair of seamPairs) {
@@ -1087,7 +1090,7 @@ export function cmdRender(flags: Record<string, string>): void {
1087
1090
  const shown = frames.map((frame) => frame.attachments ?? []);
1088
1091
  const read = frames.map((frame) => ({ index: frame.index, time: frame.time, pieces: frame.pieces, attachments: frame.attachments ?? [] }));
1089
1092
  try {
1090
- seamReadings.set(name, seamFileOf(animation, fps, skin, seamsOf(seamPairs, poser.rest(skin, shown), read, pages)));
1093
+ seamReadings.set(name, seamFileOf(animation, fps, skin, seamsOf(seamPairs, poser.rest(skin, shown), read, pages, {}, { cells })));
1091
1094
  } catch (err) {
1092
1095
  if (err instanceof SeamError) throw new UsageError(`--seam, ${JSON.stringify(name)}: ${err.message}`);
1093
1096
  throw err;
package/src/cli/shared.ts CHANGED
@@ -151,7 +151,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
151
151
  * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
152
152
  * halves together by reading `--help` rather than by naming a flag.
153
153
  */
154
- const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'extremes', 'extremes-only', 'overlaps', 'sheet', 'accept-skeleton-differences', 'keep-pages', 'dim']);
154
+ const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'seam-cells', 'folds', 'extremes', 'extremes-only', 'overlaps', 'sheet', 'accept-skeleton-differences', 'keep-pages', 'dim']);
155
155
 
156
156
  /**
157
157
  * The flags a command is allowed to spell more than once.
@@ -1072,8 +1072,9 @@ const FLAG_MEANINGS: Record<string, string> = {
1072
1072
  '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 ' +
1073
1073
  "author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Not with --slot/--hide",
1074
1074
  parallax: 'read one named slider dial through the core poser on 9 evenly spaced values: parallax.json plus setup/extreme/diff/affine-residual heat-map parallax.png; geometry only, no assertion or pass threshold. A separate dial render mode; takes no animation, fps, other diagnostics, sheet, subset, environment, preroll or viewport flags',
1075
- 'extremes-only': 'write only extremes.json and readable extremes.png per set, with the same posed grid and bytes as --extremes; no ordinary frame PNGs, contact.png or frames.json. Implies --extremes; takes no --sheet, --sheet-format, --page-edges, --geometry, --folds, --pass-through, --seam, --overlaps or --angle-range',
1075
+ 'extremes-only': 'write only extremes.json and readable extremes.png per set, with the same posed grid and bytes as --extremes; no ordinary frame PNGs, contact.png or frames.json. Implies --extremes; takes no --sheet, --sheet-format, --page-edges, --geometry, --folds, --pass-through, --seam, --seam-cells, --overlaps or --angle-range',
1076
1076
  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',
1077
+ 'seam-cells': 'with --seam, include exact world-grid cells on gapOpen/gapCovered hits, with setupTop/setupUnder slots; absent by default',
1077
1078
  seam:
1078
1079
  `also read, for each <slot>:<slot> pair, where the two drawings stop meeting, and write ${SEAM_FILE} into each frame directory: ` +
1079
1080
  '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 — ' +
@@ -1789,7 +1790,7 @@ export const COMMANDS: CommandDoc[] = [
1789
1790
  'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
1790
1791
  'rigc render … --sheet [--sheet-format json-hash] [--page-edges pot|free] (deterministic sprite sheets + JSON hash table + sheet-manifest.json per set)',
1791
1792
  ],
1792
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'extremes', 'extremes-only', 'parallax', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'physics', 'boundary-from', 'poser', 'out'],
1793
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'extremes', 'extremes-only', 'parallax', 'pass-through', 'seam', 'seam-cells', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'physics', 'boundary-from', 'poser', 'out'],
1793
1794
  overrides: {
1794
1795
  'page-edges': {
1795
1796
  meaning:
package/src/seam.ts CHANGED
@@ -133,6 +133,69 @@ export interface SeamFrame {
133
133
  /** A world box `[minX, minY, maxX, maxY]`, world units, y up. */
134
134
  export type SeamBox = [number, number, number, number];
135
135
 
136
+ /** One counted gap cell: centre in world units, y up. */
137
+ export interface SeamCell {
138
+ x: number;
139
+ y: number;
140
+ setupTop: string | null;
141
+ setupUnder: string | null;
142
+ }
143
+
144
+ /** Optional detail; absent preserves the existing sidecar bytes. */
145
+ export interface SeamOptions {
146
+ cells?: SeamCellReader;
147
+ }
148
+
149
+ /** Fills setup attribution for counted cells; reusable across pairs and frame sets. */
150
+ export type SeamCellReader = (cells: SeamCell[], side: number) => void;
151
+
152
+ /**
153
+ * Lazy setup raster, once per distinct cell side. Slot occupancy uses the same
154
+ * positive rendered-alpha traversal as gap coverage, without source-over hiding
155
+ * the layer below. Repeated fragments of one slot are one layer.
156
+ */
157
+ export function setupCellReader(setup: () => readonly Piece[], pages: ReadonlyMap<string, Plate>): SeamCellReader {
158
+ const cache = new Map<number, { viewport: Viewport; names: string[]; top: Int32Array; under: Int32Array } | null>();
159
+ let pieces: readonly Piece[] | undefined;
160
+ return (cells, side) => {
161
+ if (cells.length === 0) return;
162
+ if (!cache.has(side)) {
163
+ pieces ??= setup();
164
+ if (pieces.length === 0) cache.set(side, null);
165
+ else {
166
+ const { viewport } = gridOf([], side, pieces);
167
+ const top = new Int32Array(viewport.width * viewport.height).fill(-1);
168
+ const under = new Int32Array(top.length).fill(-1);
169
+ const names = [...new Set(pieces.map(p => p.slot))];
170
+ const ids = new Map(names.map((name, i) => [name, i]));
171
+ const project = projector(viewport);
172
+ for (const piece of pieces) {
173
+ const page = pages.get(piece.page);
174
+ if (page === undefined) throw new SeamError(`setup slot "${piece.slot}" samples missing atlas page "${piece.page}"; available [${[...pages.keys()].join(', ')}]`);
175
+ const id = ids.get(piece.slot)!;
176
+ rasterisePiece(page, piece, project, viewport, (x, y, _r, _g, _b, alpha) => {
177
+ const k = y * viewport.width + x;
178
+ if (alpha > 0 && top[k] !== id) { under[k] = top[k]; top[k] = id; }
179
+ });
180
+ }
181
+ cache.set(side, { viewport, names, top, under });
182
+ }
183
+ }
184
+ const read = cache.get(side);
185
+ for (const cell of cells) {
186
+ cell.setupTop = null; cell.setupUnder = null;
187
+ if (read == null) continue;
188
+ const { viewport, names, top, under } = read;
189
+ const x = Math.round((cell.x - viewport.minX) / side - 0.5);
190
+ const y = Math.round((viewport.maxY - cell.y) / side - 0.5);
191
+ if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) continue;
192
+ const k = y * viewport.width + x;
193
+ cell.setupTop = names[top[k]] ?? null;
194
+ cell.setupUnder = names[under[k]] ?? null;
195
+ }
196
+ };
197
+ }
198
+
136
199
  /** What one frame reads of one kind. */
137
200
  export interface SeamHit {
138
201
  index: number;
@@ -146,6 +209,8 @@ export interface SeamHit {
146
209
  area: number;
147
210
  /** The counted cells' whole squares, world units, y up. */
148
211
  box: SeamBox;
212
+ /** Opt-in on gapOpen/gapCovered only; descending y, then ascending x. */
213
+ cells?: SeamCell[];
149
214
  }
150
215
 
151
216
  /** A run of consecutive frames of one kind — first and last frame, their times, the most cells on any of them. */
@@ -385,6 +450,7 @@ interface Side {
385
450
  interface Cells {
386
451
  px: number;
387
452
  box: SeamBox | null;
453
+ cells?: SeamCell[];
388
454
  }
389
455
 
390
456
  const restPiece = (side: Side): Piece => ({ ...side.piece, world: Array.from(side.rest) });
@@ -530,7 +596,7 @@ function contactOf(restA: Uint8Array, restB: Uint8Array, drawnA: Uint8Array, dra
530
596
  }
531
597
 
532
598
  /** 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 {
599
+ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: SeamFrame, pages: ReadonlyMap<string, Plate>, grid: Grid, visibility: readonly Uint8Array[], options: SeamOptions): { gap: Cells; ghost: Cells; ghostA: Cells; ghostB: Cells; gapOpen: Cells; gapCovered: Cells; contact: Contact } | null {
534
600
  const { width, height } = grid.viewport;
535
601
  const restA = restPiece(A);
536
602
  const restB = restPiece(B);
@@ -589,8 +655,8 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
589
655
  const ghost: Cells = { px: 0, box: null };
590
656
  const ghostA: Cells = { px: 0, box: null };
591
657
  const ghostB: Cells = { px: 0, box: null };
592
- const gapOpen: Cells = { px: 0, box: null };
593
- const gapCovered: Cells = { px: 0, box: null };
658
+ const gapOpen: Cells = { px: 0, box: null, ...(options.cells ? { cells: [] } : {}) };
659
+ const gapCovered: Cells = { px: 0, box: null, ...(options.cells ? { cells: [] } : {}) };
594
660
  let alphaCells: Uint8Array | null = null;
595
661
  const take = (into: Cells, qx: number, qy: number): void => takeCell(into, qx, qy, cell);
596
662
  if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered, contact };
@@ -610,7 +676,9 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
610
676
  take(gap, qx, qy);
611
677
  const covered = plant.gapCoverage === 'covered' || (plant.gapCoverage !== 'open' &&
612
678
  (alphaCells ??= frameAlphaCells(plant.gapCoverage === 'pair' ? [A.piece, B.piece] : frame.pieces, pages, grid.viewport, plant.gapCoverage === 'half'))[k] !== 0);
613
- take(covered ? gapCovered : gapOpen, qx, qy);
679
+ const into = covered ? gapCovered : gapOpen;
680
+ take(into, qx, qy);
681
+ into.cells?.push({ x: qx, y: qy, setupTop: null, setupUnder: null });
614
682
  }
615
683
  continue;
616
684
  }
@@ -665,6 +733,7 @@ export function seamsOf(
665
733
  frames: readonly SeamFrame[],
666
734
  pages: ReadonlyMap<string, Plate>,
667
735
  plant: SeamPlant = {},
736
+ options: SeamOptions = {},
668
737
  ): SeamPairReading[] {
669
738
  const restOf = new Map(rest.map((r) => [restKey(r.slot, r.attachment), r]));
670
739
  const sideOf = (frame: SeamFrame, slot: string): Side | null => {
@@ -711,13 +780,17 @@ export function seamsOf(
711
780
  if (A === null || B === null) continue;
712
781
  drawn++;
713
782
  const cell = exposureCell;
714
- const read = readFrame(A, B, cell, plant, frame, pages, exposureGrid, exposure.map((m) => m.visible));
783
+ const read = readFrame(A, B, cell, plant, frame, pages, exposureGrid, exposure.map((m) => m.visible), options);
715
784
  if (read === null) { contact(unavailableContact('no shared half-opacity cell at the setup pose; no band to identify a joint')); continue; }
716
785
  const fragmented = [a, b].some(slot => frame.pieces.filter(p => p.slot === slot).length !== 1);
717
786
  contact(fragmented ? unavailableContact('each named slot must draw exactly one fragment for material contact') : read.contact);
718
787
  banded++;
788
+ if (options.cells) {
789
+ options.cells(read.gapOpen.cells!, cell);
790
+ options.cells(read.gapCovered.cells!, cell);
791
+ }
719
792
  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) {
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 });
793
+ 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, ...(cells.cells === undefined ? {} : { cells: cells.cells }) });
721
794
  }
722
795
  }
723
796
  const gap = kindOf(gaps);