@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 +5 -2
- package/cli.ts +8 -2
- package/package.json +2 -2
- package/src/report.ts +26 -2
- package/src/rigc.ts +18 -0
- package/src/run.ts +63 -2
- package/src/seams.ts +241 -0
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
|
|
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
|
-
--
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|