@rig-c/pose 0.32.0 → 0.33.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
@@ -134,6 +134,7 @@ and what the run cannot decide for you it lists:
134
134
  - `FLIP` — a segment that turns more than `--flip` degrees in one frame (with `--flip <deg>`; off by default) is spread over the frames around the jump; one line per spread, with the chain, the segment, the jump, the frames and h. Below.
135
135
  - `BEND` — with `--bends clamp`, a hinge bent the wrong way is straightened on that frame instead of refusing; one line per hinge with the count, the frame range and the worst angle (one per frame with `--verbose`; every frame in the report's `bends`).
136
136
  - `FOLD` — `rigc render --folds` over the new animation: every mesh that folds, and when. A reading, not a gate.
137
+ - `SEAM` — rig-c's seam reading (`rigc render --seam`, rig-c ≥ 2.60.0) over the new animation, for the rig's declared neighbour pairs ([#216](https://github.com/rig-c/pose/issues/216)): one line per span of a **gap** (background inside the joint) or a **ghost** (one piece's overlap swung clear of its partner) over the one-cell floor, with the pair, the frames and their times, the rate and the most cells; per pair the frames rigc named that were one cell thick (`under the floor — not reported`), a pair not read and why, or rigc's SKIP (the two drawings share no cell at rest — nothing measured, never a seam that held); then `n span(s) over k pair(s) at <fps> fps — r of d declared pair(s) read`. **The pairs**: a rig spec carries none — `rig.json` holds bones, slots and skins — so they come from @rig-c/parts' `parts.json`, named with `--parts <file or directory>` (its build's `--out`, beside `rig/`): each limb's consecutive segments (a part's `limb` record: `name`, `segment`, and below a joint `joint`), a pair named twice counting once, each part drawn by the slot of its name — the pairs @rig-c/parts' own `check` reads (rig-c/parts#503). A pair whose part has no slot of its name is `not read`, named. Without `--parts`, or with a `parts.json` that holds no limb, the line is `SEAM no neighbour pair declared — <why>; seams: []`, and where the rig's parent directory holds a `parts.json` it says so. **The floor** is @rig-c/parts' so the two tools agree: a frame's gap or ghost counts when it holds more cells than its box is long (its longer side, in cells) — thicker than one cell; a crack one cell deep is the half-opacity edge moving by less than a cell. Its own render, at the rate the folds are read at; it moves no byte of the motion or the build, and never changes the exit or the gate. A malformed `parts.json` is an input and is refused (`PARTS_JSON`).
137
138
  - `JITTER` — one line per bone a chain keys (a face or body dial bone excepted), in map order ([#236](https://github.com/rig-c/pose/issues/236)): the mean |second difference| of its local rotation as rig-c's poser posed the gated build (its world matrix in its parent's axes, so the rotate key up to a constant), against the same figure of its map pair's picture angle less the pair of the nearest keyed bone above it, in °/frame², and the ratio. Both are read on the same triples, three consecutive frames on which every pose point the two pairs need is good. A pair whose figure rounds to 0 is still, and gets no ratio. A group's jitter ratio is its bones' together; the line names the bone that moved it. A reading, not a gate.
138
139
  - `REACH` — a `reach` chain ([below](#the-map-rig-pose-map1)) whose aim departs from its own pair's angle ([#236](https://github.com/rig-c/pose/issues/236)): why (its origin sits off the anchor's at setup, a lever; and the bones above it the animation turns, or translates other than by the anchor's travel), the largest departure with its frame, the departure's mean |second difference|, and the aim's against the pair's, in °/frame², with the ratio. A reach whose bone sits on the anchor's origin with nothing above it moved is aimed along the pair and prints none. This line also names, with `--raised-by-head` or a `"by-hip"` set, a placed arm out of reach (below); the two read `REACH chain …` and `REACH set …`. A reading, not a gate.
139
140
  - `POSTURE` — when the map has a pelvis chain (joints `r_hip`, `l_hip`) and a `translate`: the hip line's tilt range, and the sway — the x-range of mid_hip − mid_shoulder — in rig units and in shoulder widths, the pose's beside the posed rig's. A reading, not a gate. A frame on which either hip is `inferred` is left out of the tilt. When that is every frame, as after `clean --hips-inferred`, the line is `hip tilt not read: hips inferred (declared at clean)` and the report's `posture` has `"tilt": null` with `"tiltWhy"`; on some frames only, the tilt is read over the others and the line counts the frames left out (`posture.hipsInferred`). The source is read from the clean record, or is `source not stated` when the joints carry the state and the record does not. With a `measured` record ([#86](https://github.com/rig-c/pose/issues/86), [#106](https://github.com/rig-c/pose/issues/106)), the read, interpolated, scaled and held frames are the measurement, so the tilt is read on all four, while `unmeasured` frames (`fit.corr < 0.9`) are left out and named. The line adds `tilt measured (<method>) on N frame(s): R read, I interpolated, H held (held: <spans>)` (plus `scaled` and `unmeasured` spans when present). The report's `posture` carries `tiltMeasured`. The sway reads the hips as before. With `--plant-feet`, `POSTURE` also prints one `bounce` line per support span ([#107](https://github.com/rig-c/pose/issues/107), `posture.bounce` in the report): the detrended (centred ±12-frame moving mean removed) vertical standard deviation, rig/reference ratio and correlation for the hip, the neck and `eyes − neck`.
@@ -166,8 +167,8 @@ and what the run cannot decide for you it lists:
166
167
 
167
168
  Each figure is formatted as the full line formats it, from the same record. Refusals print the same in every mode, and the exit code and the gate do not change with it; `--verbose` with `--summary` is a usage error.
168
169
 
169
- `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `spec`, `rigc` (`{ version, entry }`: the rig-c that gated it), `rig`, `animation`, `inputs` (consumed rig spec, map and pose files, each with invocation-relative `path` and exact-byte `sha256`; a folder rig resolves to its actual rig.json; these three identities do not claim to bind optional base motion, sets or images), `frames`, `duration`, `frameTimes` (the exact motion-key time for each pose frame; never FPS inferred from duration), `render` (the separate renderer sampling domain, `{ fps, frames }` from frame-set metadata, or `null` when not measured), `cleaned` (the sequence's `cleaned` record, [above](#clean-first-rig-pose-clean)), `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale`, `plantFeet`, `stance`, `floor` and `raisedByHead` included), `scale`, `scaleFit` (the `SCALE` reading: `segment`, `leg`, `hip`, `outside`, `travel`; `null` without a scale), `holds`, `short`, `endOn`, `bends`, `flips`, `gate` (rig-c's gate summary, a string), `landings`, `jitter`, `reach`, `posture`, `folds`, `body` (`standing`, `read`, `over45`, `framesOver45`, and with `body.yaw` or `body.sets` in the map, `yaw` and `sets`), `legScale`, `pivot`, `feet` (`planted`, `legLength`, `straight`, `rigLeg`, `segments`, `supports`, `jumps`, `jumpEvents`, `steps`, `changes`, `lowered`, `floor`, `shifts`, `blend`, `closure`, and the posed `reading`; `straight` is each side's 95th-percentile picture length hip → ankle in px and `rigLeg` each side's rig leg at setup (thigh origin → ankle) in world units, both `null` without both legs; `segments` holds one `{ chain, bone, joints, straight, ratio: { min, median, max }, why? }` per mapped leg segment, read with or without `--leg-scale` (`straight` and `ratio` `null`, and `why` said, where it was not measured), [#237](https://github.com/rig-c/pose/issues/237); a jump is an inclusive `[first, last]` pair of frames; `lowered` holds `{ frames, was, now, cause? }` runs (`cause`, where `now` is `null`: `released` or `higher`), present only with `--plant-feet` on a sequence not declared in place ([#113](https://github.com/rig-c/pose/issues/113)); `floor` is the world y every support span was planted on and `shifts` holds one `{ side, frames, shift }` per support span in world units, `null` and empty without `--plant-feet`; `blend` is `{ bounds, runs: [{ side, frames, k: { min, max } }], over: [{ side, frames, needed, left }] }`, the supporting leg stretched on the blend frames, `null` without `--plant-feet`; `closure` is `{ drift, frames }`, the hip's x drift closed over an in-place loop, `null` unless the sequence is `"in-place"` and `--plant-feet` is given), `stance` (`tolerance`, `release`, `minSeconds`, `bounds`, `runs`: `{ state, side, frames, maxHeight, maxDepth?, k, absorbed?, moved? }` (`state` is `both`, `lifted` or `below`; `maxDepth`, on a `below` run, is the free ankle's largest distance below the supporting one in leg lengths; `moved`, on a `lifted` or `below` run, the frames whose free ankle a `--leg-scale` clamp moved off the pose's height, as `legScale[].free` is written, [#237](https://github.com/rig-c/pose/issues/237)), `over`: `{ side, frames, needed, left }`; `null` without `--stance`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), `reduce` (the tolerances, each row of `REDUCE` and `ankleDrift`, [below](#few-keys---reduce)), and the demand lists. The fields a run is graded by:
170
- Report event objects with a pose `frame` or inclusive `frames` also carry exact `t` or `times` from `frameTimes`; existing `seconds` remain span lengths, with additive `spanSeconds`. Holds preserve their frame-index `from`/`to`. Existing scan joint/chain identities remain beside map-derived `bone` (a unique mapped target) or `bones` (all actual mapped targets); no joint-name heuristic chooses a target. `feet.jumps` stays an array of original frame pairs; `feet.jumpEvents` adds matching `{ frames, times }` objects. Fold and uncovered rows preserve their discrete `times` and gaps, declare `domain: "render"`, and use the separate root `render` domain, never fabricated nearest pose-frame indices. The run hashes map/pose from the exact buffers passed to their readers, snapshots the resolved rig file before its gate, and rechecks all three before user-output writes. The writer validates coordinates before output writes and never changes motion keys. The switch diagnostic `moved.frame` is a selected-to-keyed pair with signed `frames`/`seconds` displacement; it is preserved as diagnostic data, not reinterpreted as an inclusive interval. An explicitly supplied uniform writer clock requires both positive `fps` and seconds offset `frame0`; canonical pose input does not yet declare FPS, so the retarget producer always writes exact `frameTimes`.
170
+ `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `spec`, `rigc` (`{ version, entry }`: the rig-c that gated it), `rig`, `animation`, `inputs` (consumed rig spec, map and pose files, each with invocation-relative `path` and exact-byte `sha256`; a folder rig resolves to its actual rig.json; these three identities do not claim to bind optional base motion, sets or images), `frames`, `duration`, `frameTimes` (the exact motion-key time for each pose frame; never FPS inferred from duration), `render` (the separate renderer sampling domain, `{ fps, frames }` from frame-set metadata, or `null` when not measured), `cleaned` (the sequence's `cleaned` record, [above](#clean-first-rig-pose-clean)), `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale`, `plantFeet`, `stance`, `floor` and `raisedByHead` included), `scale`, `scaleFit` (the `SCALE` reading: `segment`, `leg`, `hip`, `outside`, `travel`; `null` without a scale), `holds`, `short`, `endOn`, `bends`, `flips`, `gate` (rig-c's gate summary, a string), `landings`, `jitter`, `reach`, `posture`, `folds`, `seams`, `seamPairs`, `body` (`standing`, `read`, `over45`, `framesOver45`, and with `body.yaw` or `body.sets` in the map, `yaw` and `sets`), `legScale`, `pivot`, `feet` (`planted`, `legLength`, `straight`, `rigLeg`, `segments`, `supports`, `jumps`, `jumpEvents`, `steps`, `changes`, `lowered`, `floor`, `shifts`, `blend`, `closure`, and the posed `reading`; `straight` is each side's 95th-percentile picture length hip → ankle in px and `rigLeg` each side's rig leg at setup (thigh origin → ankle) in world units, both `null` without both legs; `segments` holds one `{ chain, bone, joints, straight, ratio: { min, median, max }, why? }` per mapped leg segment, read with or without `--leg-scale` (`straight` and `ratio` `null`, and `why` said, where it was not measured), [#237](https://github.com/rig-c/pose/issues/237); a jump is an inclusive `[first, last]` pair of frames; `lowered` holds `{ frames, was, now, cause? }` runs (`cause`, where `now` is `null`: `released` or `higher`), present only with `--plant-feet` on a sequence not declared in place ([#113](https://github.com/rig-c/pose/issues/113)); `floor` is the world y every support span was planted on and `shifts` holds one `{ side, frames, shift }` per support span in world units, `null` and empty without `--plant-feet`; `blend` is `{ bounds, runs: [{ side, frames, k: { min, max } }], over: [{ side, frames, needed, left }] }`, the supporting leg stretched on the blend frames, `null` without `--plant-feet`; `closure` is `{ drift, frames }`, the hip's x drift closed over an in-place loop, `null` unless the sequence is `"in-place"` and `--plant-feet` is given), `stance` (`tolerance`, `release`, `minSeconds`, `bounds`, `runs`: `{ state, side, frames, maxHeight, maxDepth?, k, absorbed?, moved? }` (`state` is `both`, `lifted` or `below`; `maxDepth`, on a `below` run, is the free ankle's largest distance below the supporting one in leg lengths; `moved`, on a `lifted` or `below` run, the frames whose free ankle a `--leg-scale` clamp moved off the pose's height, as `legScale[].free` is written, [#237](https://github.com/rig-c/pose/issues/237)), `over`: `{ side, frames, needed, left }`; `null` without `--stance`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), `reduce` (the tolerances, each row of `REDUCE` and `ankleDrift`, [below](#few-keys---reduce)), and the demand lists. The fields a run is graded by:
171
+ Report event objects with a pose `frame` or inclusive `frames` also carry exact `t` or `times` from `frameTimes`; existing `seconds` remain span lengths, with additive `spanSeconds`. Holds preserve their frame-index `from`/`to`. Existing scan joint/chain identities remain beside map-derived `bone` (a unique mapped target) or `bones` (all actual mapped targets); no joint-name heuristic chooses a target. `feet.jumps` stays an array of original frame pairs; `feet.jumpEvents` adds matching `{ frames, times }` objects. Fold, uncovered and seam rows preserve their discrete `times` and gaps, declare `domain: "render"`, and use the separate root `render` domain, never fabricated nearest pose-frame indices. The run hashes map/pose from the exact buffers passed to their readers, snapshots the resolved rig file before its gate, and rechecks all three before user-output writes. The writer validates coordinates before output writes and never changes motion keys. The switch diagnostic `moved.frame` is a selected-to-keyed pair with signed `frames`/`seconds` displacement; it is preserved as diagnostic data, not reinterpreted as an inclusive interval. An explicitly supplied uniform writer clock requires both positive `fps` and seconds offset `frame0`; canonical pose input does not yet declare FPS, so the retarget producer always writes exact `frameTimes`.
171
172
 
172
173
 
173
174
  | field | each entry |
@@ -179,6 +180,8 @@ Report event objects with a pose `frame` or inclusive `frames` also carry exact
179
180
  | `jitter` | `{ bone, chain, joints, against, rig, pair, ratio, triples, reach }`: the `JITTER` line's figures, one per bone a chain keys. `rig` and `pair` are mean \|second difference\| in °/frame², `against` the keyed bone above whose pair the pose's local angle is read against (`null` at the top), `ratio` = `rig` ÷ `pair` (`null` when the pair is still: its figure rounds to 0), `triples` how many three-frame runs both were read over, `reach` whether the chain has `"reach"` |
180
181
  | `reach` | `{ chain, bone, joints, anchor, offset: { units, ofLength }, above: [{ bone, property, chain }], departure: { max, frame, t, meanD2 }, aimD2, pairD2, ratio }`: the `REACH` line's figures, one per `reach` chain whose aim departs from its pair; empty when none does. `offset` is the bone's setup distance from the anchor's origin (units, and a fraction of the reached length), `above` the bones over it the animation turns (`rotate`, with the chain that turns it) or translates other than by the anchor's travel (`translate`), `departure` the aim − the pair's angle (largest \|·\| in degrees, its frame, and its mean \|second difference\|), `aimD2` and `pairD2` the two angles' mean \|second difference\| over every frame, `ratio` = `aimD2` ÷ `pairD2` |
181
182
  | `folds` | `{ slot, attachment, times }`: every mesh `rigc render --folds` saw fold, at those seconds; `null` when it did not read |
183
+ | `seams` | `{ pair, kind, frames: [first, last], times: [first, last], maxPx, domain: "render" }`: one per consecutive span of one kind (`gap` or `ghost`) over the one-cell floor, per declared pair (`"<a>\|<b>"`), in the pairs' order, gap before ghost; `frames` are the seam render's frame indices (rigc's `f<index>.png`) and `times` rigc's seconds, `maxPx` the most cells on a frame of the span. `[]` when none opens, when no pair is declared, and when none was read — `seamPairs` says which. Always present: the writer refuses a report without it (`REPORT_SEAMS`) |
184
+ | `seamPairs` | `{ pair, declared, status, opens, maxPx, underFloor, reason? }`: one per declared pair. `status` is `read`, `skip` (rigc read no band: nothing measured) or `unread` (not rendered; `reason` says why); `opens` the render frames over the floor, `underFloor` those rigc named that were one cell thick, `declared` where `parts.json` declared it. `[]` when no pair is declared. Always present (`REPORT_SEAMS`) |
182
185
 
183
186
  The demand lists:
184
187
 
package/cli.ts CHANGED
@@ -180,7 +180,12 @@ retarget
180
180
  --profile spine|spine-html rigc's gate profile (default: spine, rigc's own default)
181
181
  --build <dir> keep the gated build (skeleton.json, .atlas, .model.json) here
182
182
  --render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
183
- --report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds,
183
+ --parts <path> @rig-c/parts' parts.json, or the directory holding it (its build's --out, beside rig/):
184
+ the neighbour pairs the seam reading reads — each limb's consecutive segments. Each
185
+ pair is read by rigc render --seam over the new animation and every span of a gap or
186
+ a ghost thicker than one cell prints a SEAM line and a report seams row; a reading,
187
+ never a gate. Without it no pair is declared (a rig spec carries none): seams: []
188
+ --report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds, seams,
184
189
  switches, uncovered, turns, range, artRange, raised, byHip,
185
190
  idleDropped, idleKeptOut, merged, reduce) as JSON (default: <out>.report.json beside
186
191
  the motion, a trailing .json of --out replaced); its wrote line names the mtime of a
@@ -315,7 +320,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
315
320
  const RETARGET_ALIASES = { sequence: 'pose', animation: 'name' } as const;
316
321
 
317
322
  function retargetArgs(argv: string[]): RetargetArgs {
318
- const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report', 'camera', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip', 'stance', 'floor'], ['coco', 'plant-feet', 'carry-idle', 'raised-by-head', 'verbose', 'summary'], RETARGET_ALIASES);
323
+ const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'parts', 'report', 'camera', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip', 'stance', 'floor'], ['coco', 'plant-feet', 'carry-idle', 'raised-by-head', 'verbose', 'summary'], RETARGET_ALIASES);
319
324
  if (f.has('verbose') && f.has('summary')) throw new Usage('--verbose and --summary are both given; --verbose prints every line in full and --summary one line per measure, so give one');
320
325
  const bends = f.get('bends') ?? 'refuse';
321
326
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
@@ -352,6 +357,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
352
357
  profile,
353
358
  build: f.get('build'),
354
359
  render: f.get('render'),
360
+ parts: f.get('parts'),
355
361
  report: f.get('report'),
356
362
  camera: f.get('camera'),
357
363
  idle: f.get('idle'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,7 +46,7 @@
46
46
  "prepublishOnly": "bun run typecheck && bun run lint && bun run test"
47
47
  },
48
48
  "dependencies": {
49
- "rig-c": "^2.47.1"
49
+ "rig-c": "^2.60.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@esotericsoftware/spine-core": "4.3.13",
package/src/report.ts CHANGED
@@ -56,10 +56,12 @@ export function enrichReport(input:Record<string,unknown>,map:JointMap,clock:Rep
56
56
  if(Array.isArray(value)){value.forEach((row,i)=>visit(row,`${where}[${i}]`));return;}
57
57
  if(!isObject(value))return;
58
58
  // Preserve uncovered's discrete renderer times; they do not index the pose.
59
- if(where.startsWith('report.uncovered[')||where.startsWith('report.folds[')){
59
+ if(where.startsWith('report.uncovered[')||where.startsWith('report.folds[')||where.startsWith('report.seams[')){
60
60
  if(!render)fail(where,'render-domain event requires declared renderer metadata');
61
61
  add(value,'domain','render',where);return;
62
62
  }
63
+ // seamPairs' frame lists are the seam render's indices (#216); they carry no pose coordinate.
64
+ if(where.startsWith('report.seamPairs['))return;
63
65
  let coordinates=false;
64
66
  if(poseEvent(where)&&value.frame!==undefined&&typeof value.frame!=='number')fail(where+'.frame','expected nonnegative integer pose frame');
65
67
  if(typeof value.frame==='number'){add(value,'t',time(value.frame,where+'.frame'),where);coordinates=true;}
@@ -85,4 +87,26 @@ export function enrichReport(input:Record<string,unknown>,map:JointMap,clock:Rep
85
87
  if(inputs!==undefined){if(!isObject(inputs))throw new Refusal([{code:'REPORT_INPUTS',text:'inputs: expected rig, map and pose file identities'}]);for(const key of ['rig','map','pose'] as const){const entry=inputs[key];if(!isObject(entry)||typeof entry.path!=='string'||!entry.path||entry.path.startsWith('/')||entry.path.charCodeAt(0)===92||/^[A-Za-z]:/.test(entry.path)||typeof entry.sha256!=='string'||! /^[a-f0-9]{64}$/.test(entry.sha256))throw new Refusal([{code:'REPORT_INPUTS',text:`inputs.${key}: expected invocation-relative consumed file path and exact SHA-256`}]);}add(report,'inputs',clone(inputs),'report');}
86
88
  problems.throwIfAny();return report;
87
89
  }
88
- export function writeReport(input:Record<string,unknown>,map:JointMap,clock:ReportClock,render:RenderDomain|null,inputs:ReportInputs):string {if(inputs===undefined)throw new Refusal([{code:'REPORT_INPUTS',text:'inputs: expected rig, map and pose file identities'}]);return JSON.stringify(enrichReport(input,map,clock,render,inputs),null,2)+'\n';}
90
+ /**
91
+ * rig-c/pose#216: `seams` and `seamPairs` are declared fields, not optional by
92
+ * accident — a report without them is a run whose seam reading never ran, and
93
+ * the writer refuses it by name. Each `seams` row is one span of one kind:
94
+ * `{ pair, kind, frames: [first, last], times: [first, last], maxPx }`.
95
+ */
96
+ export function checkSeams(input:Record<string,unknown>):void {
97
+ const p=new Problems();
98
+ if(!Array.isArray(input.seams))p.add('REPORT_SEAMS',`seams: found ${input.seams===undefined?'no field':JSON.stringify(input.seams)}; the report declares the seam reading's spans (rig-c/pose#216), [] when none opens or no pair is declared, and seamPairs says which`);
99
+ if(!Array.isArray(input.seamPairs))p.add('REPORT_SEAMS',`seamPairs: found ${input.seamPairs===undefined?'no field':JSON.stringify(input.seamPairs)}; the report declares what the seam reading said of each declared pair (rig-c/pose#216), [] when none is declared`);
100
+ p.throwIfAny();
101
+ const int=(v:unknown)=>Number.isSafeInteger(v)&&(v as number)>=0;
102
+ (input.seams as unknown[]).forEach((row,i)=>{
103
+ const ok=isObject(row)&&typeof row.pair==='string'&&row.pair.includes('|')&&(row.kind==='gap'||row.kind==='ghost')&&Array.isArray(row.frames)&&row.frames.length===2&&row.frames.every(int)&&(row.frames[1] as number)>=(row.frames[0] as number)&&Array.isArray(row.times)&&row.times.length===2&&row.times.every(t=>typeof t==='number'&&Number.isFinite(t)&&t>=0)&&(row.times[1] as number)>=(row.times[0] as number)&&int(row.maxPx)&&(row.maxPx as number)>0;
104
+ if(!ok)p.add('REPORT_SEAMS',`seams[${i}]: found ${JSON.stringify(row)}; expected { pair: "<a>|<b>", kind: "gap"|"ghost", frames: [first, last], times: [first, last], maxPx > 0 }`);
105
+ });
106
+ (input.seamPairs as unknown[]).forEach((row,i)=>{
107
+ const ok=isObject(row)&&typeof row.pair==='string'&&(row.status==='read'||row.status==='skip'||row.status==='unread')&&(row.status!=='unread'||typeof row.reason==='string');
108
+ if(!ok)p.add('REPORT_SEAMS',`seamPairs[${i}]: found ${JSON.stringify(row)}; expected { pair, status: "read"|"skip"|"unread", reason when unread }`);
109
+ });
110
+ p.throwIfAny();
111
+ }
112
+ export function writeReport(input:Record<string,unknown>,map:JointMap,clock:ReportClock,render:RenderDomain|null,inputs:ReportInputs):string {checkSeams(input);if(inputs===undefined)throw new Refusal([{code:'REPORT_INPUTS',text:'inputs: expected rig, map and pose file identities'}]);return JSON.stringify(enrichReport(input,map,clock,render,inputs),null,2)+'\n';}
package/src/rigc.ts CHANGED
@@ -231,6 +231,24 @@ export function folds(build: string, animation: string, dir: string, fps: number
231
231
  };
232
232
  }
233
233
 
234
+ /**
235
+ * `rigc render --seam <a>:<b>,…` over one animation (rig-c/pose#216): its own
236
+ * render, apart from the folds' so a seam refusal cannot cost the fold reading
237
+ * and the fold render's arguments stay what they were. `seam` is the set's
238
+ * `seam.json` text, null when the render did not run green; `frames` is its
239
+ * `frames.json`, for the render domain.
240
+ */
241
+ export function seamRender(build: string, animation: string, dir: string, fps: number, pairs: ReadonlyArray<readonly [string, string]>): { run: RigcRun; seam: { text: string; path: string } | null; frames: unknown } {
242
+ const out = join(dir, 'render');
243
+ const run = runRigc(['render', '--candidate', build, '--animation', animation, '--seam', pairs.map(([a, b]) => `${a}:${b}`).join(','), '--fps', String(fps), '--out', out]);
244
+ if (run.status !== 0 || !existsSync(join(out, 'frames.json'))) return { run, seam: null, frames: null };
245
+ const framesDoc = JSON.parse(readFileSync(join(out, 'frames.json'), 'utf8')) as { sets: Array<{ dir: string; animation: string }> };
246
+ const set = framesDoc.sets.find((s) => s.animation === animation);
247
+ const path = set === undefined ? null : join(resolve(out), set.dir, 'seam.json');
248
+ if (path === null || !existsSync(path)) return { run, seam: null, frames: framesDoc };
249
+ return { run, seam: { text: readFileSync(path, 'utf8'), path }, frames: framesDoc };
250
+ }
251
+
234
252
  /** rig-c's version and the entry its launcher picked (`rigc --version`), so a run names the compiler that gated it. */
235
253
  export function rigcVersion(): { version: string; entry: string } {
236
254
  const r = runRigc(['--version']);
package/src/run.ts CHANGED
@@ -14,7 +14,8 @@ import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type Cam
14
14
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
15
15
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
16
16
  import { bendGroupLine, bendGroups, bendLine, endOnLine, flipLine, holdLine, nodLine, rangeLine, reachCostLine, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions, type ScaleBand, type ScaleFit } from './retarget.ts';
17
- import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
17
+ import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, seamRender, slotsOf } from './rigc.ts';
18
+ import { partsPath, projectSeams, readSeamDoc, readSeamPairs, seamLines, type SeamPair } from './seams.ts';
18
19
  import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
19
20
  import { jitterLine, landed, type FeetReading, type JitterRow, type Landing, measureFeet, measureJitter, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
20
21
  import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
@@ -41,6 +42,8 @@ export interface RetargetArgs extends RetargetOptions {
41
42
  build?: string;
42
43
  /** Keep rigc's render of the animation (frames, contact sheet, folds.json) here. */
43
44
  render?: string;
45
+ /** @rig-c/parts' `parts.json` (or the directory holding it): the neighbour pairs the seam reading reads (#216). */
46
+ parts?: string;
44
47
  /** Write the run's measurements as JSON here. */
45
48
  report?: string;
46
49
  /** Write the sequence's per-frame relative figure scale and pan as a sidecar JSON here (#161). */
@@ -112,6 +115,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
112
115
  if (!(idleName in animations) && (a.idle !== undefined || a.carryIdle === true)) {
113
116
  refuse([{ code: 'IDLE_NAME', text: `${a.idle !== undefined ? `--idle "${idleName}"` : '--carry-idle reads the animation "idle", and'} the rig's motion spec ${input.motion === null ? '— there is none, so it has no animations' : `${input.motion} has no animation "${idleName}" (it has ${Object.keys(animations).join(', ') || 'none'})`}; name the rig's idle with --idle` }]);
114
117
  }
118
+ // #216: the neighbour pairs, read before any work so a bad --parts is refused first.
119
+ const seamDecl = a.parts === undefined ? null : (() => {
120
+ const path = partsPath(a.parts);
121
+ return { path, pairs: readSeamPairs(readFileSync(path, 'utf8'), path) };
122
+ })();
115
123
  const fragment = a.merge === undefined ? [] : readFragment(parseJson(readFileSync(a.merge, 'utf8'), a.merge, 'MERGE_JSON'), a.merge);
116
124
  const mapSnapshot = readReportInput(a.map), poseSnapshot = readReportInput(a.pose);
117
125
  const map = readMap(mapSnapshot.bytes.toString('utf8'), a.map);
@@ -347,6 +355,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
347
355
  for (const u of uncovered) say(` UNCOVERED mesh "${u.attachment}" (slot "${u.slot}"; chains ${u.chains.length === 0 ? 'none' : u.chains.join(', ')}) folds with no switch covering it on ${u.times.length} frame(s): t=${spans(u.times, 1 / fps)}`);
348
356
  }
349
357
 
358
+ // #216: the seam reading over the declared neighbour pairs — a reading, never a gate.
359
+ const seamRead = readSeams(seamDecl, input.rig, doc.slots.map((s) => s.name), g1.build, a.name, join(scratch, 'seams'), fps);
360
+ for (const l of seamRead.lines) say(` SEAM ${l}`);
361
+
350
362
  // #240: what --reduce allowed each ankle, at the end of the run, from the same figure the LAND check applied.
351
363
  for (const d of drift) say(` REDUCE ${ankleDriftLine(d)}`);
352
364
 
@@ -372,6 +384,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
372
384
  jitter,
373
385
  posture,
374
386
  folds: f.meshes,
387
+ seams: seamRead.seams,
388
+ seamPairs: seamRead.seamPairs,
375
389
  switches: r.switches,
376
390
  uncovered,
377
391
  turns: r.turns,
@@ -411,7 +425,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
411
425
  idleKeptOut: idle === null ? null : idle.keptOut,
412
426
  merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
413
427
  reduce: reduced === null ? null : { tolerance: a.reduce, hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
414
- }, map, { frameTimes: r.times }, f.meshes !== null && doc.slots.length > 0 ? reportRenderDomain(JSON.parse(readFileSync(join(f.frames, '..', 'frames.json'), 'utf8')), a.name) : null, {rig:rigSnapshot.identity,map:mapSnapshot.identity,pose:poseSnapshot.identity});
428
+ }, map, { frameTimes: r.times }, f.meshes !== null && doc.slots.length > 0 ? reportRenderDomain(JSON.parse(readFileSync(join(f.frames, '..', 'frames.json'), 'utf8')), a.name) : seamRead.frames === null ? null : reportRenderDomain(seamRead.frames, a.name), {rig:rigSnapshot.identity,map:mapSnapshot.identity,pose:poseSnapshot.identity});
415
429
  if (a.build !== undefined) {
416
430
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
417
431
  rmSync(a.build, { recursive: true, force: true });
@@ -1016,6 +1030,53 @@ export function assertReportInputs(inputs:readonly ReportInputSnapshot[]):void {
1016
1030
  for(const expected of inputs){const current=readReportInput(expected.file);if(current.identity.path!==expected.identity.path||current.identity.sha256!==expected.identity.sha256)throw new Refusal([{code:'REPORT_INPUTS_CHANGED',text:`${expected.identity.path}: consumed input changed before output writes; expected SHA-256 ${expected.identity.sha256}, found ${current.identity.sha256}`}]);}
1017
1031
  }
1018
1032
 
1033
+ /**
1034
+ * #216: the seam reading end to end — the declared pairs, each pair whose part
1035
+ * has no slot of its name left unread (rigc would refuse the whole render for
1036
+ * it), one `rigc render --seam` over the rest, and its projection. Whatever it
1037
+ * finds, or fails to read, it says so and refuses nothing.
1038
+ */
1039
+ function readSeams(decl: { path: string; pairs: SeamPair[] } | null, rig: string, slotNames: string[], build: string, animation: string, dir: string, fps: number): ReturnType<typeof projectSeams> & { lines: string[]; frames: unknown } {
1040
+ if (decl === null || decl.pairs.length === 0) {
1041
+ let why: string;
1042
+ if (decl !== null) why = `${decl.path} holds no limb segment below another (a part's "limb" record), so it declares no pair`;
1043
+ else {
1044
+ // @rig-c/parts writes rig/ and parts.json side by side under its --out; a rig spec itself carries no pair.
1045
+ const home = dirname(dirname(rig));
1046
+ why = `a rig spec carries none and no --parts was named${existsSync(join(home, 'parts.json')) ? ` (${home} holds parts.json: --parts ${home} reads its limbs)` : ''}`;
1047
+ }
1048
+ return { seams: [], seamPairs: [], frames: null, lines: [`no neighbour pair declared — ${why}; seams: []`] };
1049
+ }
1050
+ const have = new Set(slotNames);
1051
+ const unread = new Map<string, string>();
1052
+ for (const p of decl.pairs) {
1053
+ const missing = [p.a, p.b].filter((n) => !have.has(n));
1054
+ if (missing.length > 0) unread.set(p.pair, `${missing.map((n) => `part "${n}"`).join(' and ')} has no slot of its name in the rig (a part is drawn by the slot of its name)`);
1055
+ }
1056
+ const toRead = decl.pairs.filter((p) => !unread.has(p.pair));
1057
+ let rows: ReturnType<typeof readSeamDoc> = [];
1058
+ let frames: unknown = null;
1059
+ if (toRead.length > 0) {
1060
+ const s = seamRender(build, animation, dir, fps, toRead.map((p) => [p.a, p.b] as const));
1061
+ let why: string | null = null;
1062
+ if (s.seam === null) {
1063
+ const said = s.run.out.split('\n').map((l) => l.trim()).find((l) => l.startsWith('rigc: ') && !l.startsWith('rigc: wrote'));
1064
+ why = `rigc render --seam did not read (exit ${s.run.status})${said === undefined ? '' : `: ${said}`}`;
1065
+ } else {
1066
+ try {
1067
+ rows = readSeamDoc(s.seam.text, s.seam.path);
1068
+ frames = s.frames;
1069
+ } catch (e) {
1070
+ if (!(e instanceof Refusal)) throw e;
1071
+ why = e.message;
1072
+ }
1073
+ }
1074
+ if (why !== null) for (const p of toRead) unread.set(p.pair, why);
1075
+ }
1076
+ const projected = projectSeams(decl.pairs, rows, unread);
1077
+ return { ...projected, frames, lines: seamLines(projected, fps) };
1078
+ }
1079
+
1019
1080
  /** rig-c/pose#239: the report's path when --report is not given — `<out>.report.json`, beside the motion (a trailing `.json` of --out is replaced). */
1020
1081
  export function defaultReportPath(out: string): string {
1021
1082
  return `${out.endsWith('.json') ? out.slice(0, -'.json'.length) : out}.report.json`;
package/src/seams.ts ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The seam reading's projection (rig-c/pose#216): which neighbour pairs a rig
3
+ * declares, and what `rigc render --seam` found between them, as report rows.
4
+ * Pure — the render itself is `src/rigc.ts`'s.
5
+ *
6
+ * **Where the pairs come from.** A rig spec does not carry them: `rig.json`
7
+ * holds bones, slots and skins, and @rig-c/parts records a limb's segments in
8
+ * `parts.json` beside the rig directory (each part's `limb`: `name`,
9
+ * `segment`, and below a joint `joint`). So the pairs are read from the
10
+ * `parts.json` the run is given (`--parts`), the way @rig-c/parts' own
11
+ * `check` reads them (rig-c/parts#503): each limb's consecutive segments, the
12
+ * upper and the one below, a pair named twice counting once in either order.
13
+ * Each part is drawn by the slot of its name.
14
+ *
15
+ * **The floor** is @rig-c/parts' (#503), so the two tools agree on what a seam
16
+ * opening is: a frame's gap or ghost counts when it holds more cells than its
17
+ * box is long (its longer side, in cells) — thicker than one cell. rigc counts
18
+ * a cell drawn when its centre is painted at half opacity, so an outline that
19
+ * moves by less than a cell flips the cells along it: a line one cell deep
20
+ * holds at most as many cells as its box is long, and a hole of any area more.
21
+ * The rule types no number; it reads rigc's own `px`, `cell` and `box`.
22
+ *
23
+ * A reading, never a gate: nothing here refuses a run for what it finds.
24
+ */
25
+ import { existsSync, statSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import { isObject, parseJson, Refusal } from './problems.ts';
28
+
29
+ export type SeamKind = 'gap' | 'ghost';
30
+ export const SEAM_KINDS: readonly SeamKind[] = ['gap', 'ghost'];
31
+
32
+ /** One declared neighbour pair: two part names, each drawn by the slot of its name. */
33
+ export interface SeamPair {
34
+ /** `"<a>|<b>"`, the form rigc and @rig-c/parts name a pair by. */
35
+ pair: string;
36
+ a: string;
37
+ b: string;
38
+ /** Where it was declared: `config.limbs.<limb> segment <n>, joint "<joint>"`, once per declaration. */
39
+ declared: string[];
40
+ }
41
+
42
+ /** One `report.json` `seams` row: one consecutive span of one kind over the floor (#216). */
43
+ export interface SeamSpan {
44
+ pair: string;
45
+ kind: SeamKind;
46
+ /** Inclusive render frame indices, rigc's `f<index>.png`. */
47
+ frames: [number, number];
48
+ /** Those frames' times in seconds, rigc's. */
49
+ times: [number, number];
50
+ /** The most cells of this kind on any frame of the span. */
51
+ maxPx: number;
52
+ }
53
+
54
+ /** One `report.json` `seamPairs` row: what the reading said of one declared pair. */
55
+ export interface SeamPairReading {
56
+ pair: string;
57
+ declared: string[];
58
+ /** `read`: rigc read a band; `skip`: rigc read none (the two drawings share no cell at rest); `unread`: not rendered, `reason` says why. */
59
+ status: 'read' | 'skip' | 'unread';
60
+ /** Frames over the floor, of either kind, ascending. */
61
+ opens: number[];
62
+ /** The most cells over the floor on any frame; 0 when none opens. */
63
+ maxPx: number;
64
+ /** Frames rigc named open whose every opening is one cell thick — under the floor, not in `seams`. */
65
+ underFloor: number[];
66
+ reason?: string;
67
+ }
68
+
69
+ const PARTS_SPEC = 'PARTS_JSON';
70
+
71
+ /** `--parts`: a `parts.json`, or a directory holding one. */
72
+ export function partsPath(path: string): string {
73
+ if (!existsSync(path)) throw new Refusal([{ code: 'ARG_PATH', text: `--parts ${path} does not exist` }]);
74
+ if (statSync(path).isDirectory()) {
75
+ const file = join(path, 'parts.json');
76
+ if (!existsSync(file)) throw new Refusal([{ code: 'ARG_PATH', text: `--parts ${path} is a directory with no parts.json (@rig-c/parts writes it to <out>/, beside rig/)` }]);
77
+ return file;
78
+ }
79
+ return path;
80
+ }
81
+
82
+ /**
83
+ * The neighbour pairs `parts.json` declares, in its limbs' order: each limb's
84
+ * consecutive segments by `segment`. A record that is not one is refused by
85
+ * name; a part with no `limb` declares nothing.
86
+ */
87
+ export function readSeamPairs(text: string, path: string): SeamPair[] {
88
+ const doc = parseJson(text, path, PARTS_SPEC);
89
+ if (!isObject(doc) || !Array.isArray(doc.parts)) throw new Refusal([{ code: PARTS_SPEC, text: `${path} is not @rig-c/parts' parts.json: it has no "parts" array` }]);
90
+ const problems: Array<{ code: string; text: string }> = [];
91
+ const limbs = new Map<string, Array<{ part: string; segment: number; joint: string | undefined }>>();
92
+ doc.parts.forEach((p: unknown, i: number) => {
93
+ if (!isObject(p) || typeof p.name !== 'string' || p.name === '') {
94
+ problems.push({ code: PARTS_SPEC, text: `${path} parts[${i}]: expected a record with a "name" string` });
95
+ return;
96
+ }
97
+ if (p.limb === undefined) return;
98
+ const l = p.limb;
99
+ if (!isObject(l) || typeof l.name !== 'string' || !Number.isSafeInteger(l.segment) || (l.segment as number) < 0 || (l.joint !== undefined && typeof l.joint !== 'string')) {
100
+ problems.push({ code: PARTS_SPEC, text: `${path} parts[${i}] ("${p.name}") limb: expected { name: string, segment: integer ≥ 0, joint?: string }, found ${JSON.stringify(l)}` });
101
+ return;
102
+ }
103
+ const segs = limbs.get(l.name) ?? [];
104
+ if (segs.some((s) => s.segment === l.segment)) problems.push({ code: PARTS_SPEC, text: `${path} parts[${i}] ("${p.name}"): limb "${l.name}" segment ${String(l.segment)} is already part "${segs.find((s) => s.segment === l.segment)!.part}"` });
105
+ segs.push({ part: p.name, segment: l.segment as number, joint: l.joint as string | undefined });
106
+ limbs.set(l.name, segs);
107
+ });
108
+ if (problems.length > 0) throw new Refusal(problems);
109
+ const out: SeamPair[] = [];
110
+ for (const [limb, segs] of limbs) {
111
+ segs.sort((x, y) => x.segment - y.segment);
112
+ for (let i = 1; i < segs.length; i++) {
113
+ const [up, low] = [segs[i - 1]!, segs[i]!];
114
+ const declared = `config.limbs.${limb} segment ${low.segment}${low.joint === undefined ? '' : `, joint "${low.joint}"`}`;
115
+ const hit = out.find((x) => (x.a === up.part && x.b === low.part) || (x.a === low.part && x.b === up.part));
116
+ if (hit !== undefined) hit.declared.push(declared);
117
+ else out.push({ pair: `${up.part}|${low.part}`, a: up.part, b: low.part, declared: [declared] });
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** A frame's opening of one kind is over the floor when it holds more cells than its box, in cells, is long. */
124
+ export function overFloor(hit: { px: number; cell: number; box: readonly number[] }): boolean {
125
+ const long = Math.round(Math.max(hit.box[2]! - hit.box[0]!, hit.box[3]! - hit.box[1]!) / hit.cell);
126
+ return hit.px > long;
127
+ }
128
+
129
+ interface SeamHit {
130
+ index: number;
131
+ time: number;
132
+ px: number;
133
+ cell: number;
134
+ box: number[];
135
+ }
136
+ interface SeamDocPair {
137
+ a: string;
138
+ b: string;
139
+ status: 'read' | 'skip';
140
+ opens: number[];
141
+ gap: { frames: SeamHit[] };
142
+ ghost: { frames: SeamHit[] };
143
+ }
144
+
145
+ /** `seam.json` (`rigc-seam/1`): the fields this projection reads, checked; anything else is rigc's. */
146
+ export function readSeamDoc(text: string, path: string): SeamDocPair[] {
147
+ const doc = parseJson(text, path, 'SEAM_JSON');
148
+ const bad = (what: string): never => {
149
+ throw new Refusal([{ code: 'SEAM_JSON', text: `${path}: ${what}; rig-c's rigc-seam/1 (its AUTHORING §8.8) is the contract this reads` }]);
150
+ };
151
+ if (!isObject(doc) || doc.spec !== 'rigc-seam/1' || !Array.isArray(doc.pairs)) return bad('expected spec "rigc-seam/1" with a "pairs" array');
152
+ return doc.pairs.map((p: unknown, i: number) => {
153
+ if (!isObject(p) || typeof p.a !== 'string' || typeof p.b !== 'string' || (p.status !== 'read' && p.status !== 'skip') || !Array.isArray(p.opens)) return bad(`pairs[${i}]: expected a, b, status read|skip, opens`);
154
+ const kind = (k: SeamKind) => {
155
+ const o = p[k];
156
+ if (!isObject(o) || !Array.isArray(o.frames)) return bad(`pairs[${i}].${k}: expected { frames }`);
157
+ const frames = o.frames.map((h: unknown, j: number) => {
158
+ if (!isObject(h) || !Number.isSafeInteger(h.index) || typeof h.time !== 'number' || !Number.isSafeInteger(h.px) || typeof h.cell !== 'number' || !(h.cell > 0) || !Array.isArray(h.box) || h.box.length !== 4 || !h.box.every((v) => typeof v === 'number')) {
159
+ return bad(`pairs[${i}].${k}.frames[${j}]: expected index, time, px, cell > 0, box [minX, minY, maxX, maxY]`);
160
+ }
161
+ return { index: h.index as number, time: h.time, px: h.px as number, cell: h.cell, box: h.box as number[] };
162
+ });
163
+ return { frames };
164
+ };
165
+ return { a: p.a, b: p.b, status: p.status, opens: p.opens.filter((n): n is number => typeof n === 'number'), gap: kind('gap'), ghost: kind('ghost') };
166
+ });
167
+ }
168
+
169
+ /** Runs of consecutive frame indices among `hits` (ascending), each with its first and last time and its most cells. */
170
+ function runsOf(hits: readonly SeamHit[]): Array<{ frames: [number, number]; times: [number, number]; maxPx: number }> {
171
+ const out: Array<{ frames: [number, number]; times: [number, number]; maxPx: number }> = [];
172
+ for (const h of hits) {
173
+ const last = out[out.length - 1];
174
+ if (last !== undefined && h.index === last.frames[1] + 1) {
175
+ last.frames[1] = h.index;
176
+ last.times[1] = h.time;
177
+ last.maxPx = Math.max(last.maxPx, h.px);
178
+ } else out.push({ frames: [h.index, h.index], times: [h.time, h.time], maxPx: h.px });
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * The report's rows from one `seam.json`: per declared pair its reading, and
185
+ * per pair and kind one row per consecutive span of frames over the floor —
186
+ * in the pairs' order, gap before ghost, spans in frame order.
187
+ */
188
+ export function projectSeams(pairs: readonly SeamPair[], doc: readonly SeamDocPair[], unread: ReadonlyMap<string, string>): { seams: SeamSpan[]; seamPairs: SeamPairReading[] } {
189
+ const seams: SeamSpan[] = [];
190
+ const seamPairs: SeamPairReading[] = [];
191
+ for (const p of pairs) {
192
+ const why = unread.get(p.pair);
193
+ const d = doc.find((x) => x.a === p.a && x.b === p.b);
194
+ if (why !== undefined || d === undefined) {
195
+ seamPairs.push({ pair: p.pair, declared: p.declared, status: 'unread', opens: [], maxPx: 0, underFloor: [], reason: why ?? 'seam.json holds no row for it' });
196
+ continue;
197
+ }
198
+ const opens = new Set<number>();
199
+ let maxPx = 0;
200
+ for (const k of SEAM_KINDS) {
201
+ const over = d[k].frames.filter(overFloor);
202
+ for (const h of over) {
203
+ opens.add(h.index);
204
+ maxPx = Math.max(maxPx, h.px);
205
+ }
206
+ for (const r of runsOf(over)) seams.push({ pair: p.pair, kind: k, frames: r.frames, times: r.times, maxPx: r.maxPx });
207
+ }
208
+ const sorted = [...opens].sort((x, y) => x - y);
209
+ seamPairs.push({ pair: p.pair, declared: p.declared, status: d.status, opens: sorted, maxPx, underFloor: d.opens.filter((f) => !opens.has(f)) });
210
+ }
211
+ return { seams, seamPairs };
212
+ }
213
+
214
+ /** Frame indices as runs, `[0-6, 8, 13]`, the form rigc prints a frame list in. */
215
+ export function frameRuns(frames: readonly number[]): string {
216
+ const runs: string[] = [];
217
+ for (let i = 0; i < frames.length; ) {
218
+ let j = i;
219
+ while (j + 1 < frames.length && frames[j + 1] === frames[j]! + 1) j++;
220
+ runs.push(i === j ? String(frames[i]) : `${frames[i]}-${frames[j]}`);
221
+ i = j + 1;
222
+ }
223
+ return `[${runs.join(', ')}]`;
224
+ }
225
+
226
+ /** The run's `SEAM` lines: one per span, one per pair not read or skipped, and the count. */
227
+ export function seamLines(r: { seams: readonly SeamSpan[]; seamPairs: readonly SeamPairReading[] }, fps: number): string[] {
228
+ const out: string[] = [];
229
+ for (const s of r.seams) {
230
+ out.push(`pair "${s.pair}" ${s.kind} on frame(s) ${s.frames[0]}-${s.frames[1]} (t=${s.times[0].toFixed(3)}–${s.times[1].toFixed(3)}s) at ${fps} fps, max ${s.maxPx} px — over the one-cell floor; a reading, not a gate`);
231
+ }
232
+ for (const p of r.seamPairs) {
233
+ if (p.status === 'unread') out.push(`pair "${p.pair}": not read — ${p.reason}`);
234
+ else if (p.status === 'skip') out.push(`pair "${p.pair}": SKIP — rigc read no band (the two drawings share no cell at the setup pose), so nothing was measured`);
235
+ else if (p.underFloor.length > 0) out.push(`pair "${p.pair}": ${p.underFloor.length} frame(s) ${frameRuns(p.underFloor)} open one cell thick, under the floor — not reported`);
236
+ }
237
+ const read = r.seamPairs.filter((p) => p.status === 'read').length;
238
+ const pairsOpen = new Set(r.seams.map((s) => s.pair)).size;
239
+ out.push(`${r.seams.length} span(s) over ${pairsOpen} pair(s) at ${fps} fps — ${read} of ${r.seamPairs.length} declared pair(s) read; a reading, not a gate`);
240
+ return out;
241
+ }