@rig-c/pose 0.32.0 → 0.34.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 +18 -8
- package/cli.ts +20 -5
- package/package.json +2 -2
- package/src/report.ts +26 -2
- package/src/rigc.ts +18 -0
- package/src/run.ts +131 -13
- package/src/seams.ts +241 -0
- package/src/verify.ts +17 -7
package/README.md
CHANGED
|
@@ -109,7 +109,7 @@ rig-pose retarget --rig out/rig --map map.json --sequence dance.pose.json --out
|
|
|
109
109
|
|
|
110
110
|
**Names** ([#243](https://github.com/rig-c/pose/issues/243)). `--sequence` and `--animation` are the names the rest of the toolchain gives these inputs (rig-c/exam's grader, `rigc render --animation`); `--pose` and `--name`, this command's first names, are the same flags. Both of a pair given with the same value is accepted; with different values the run is refused before anything is read, naming both (`--pose "a.json" and --sequence "b.json" are both given and differ; --sequence is another name for --pose, so give one`). Either spelling writes the same bytes.
|
|
111
111
|
|
|
112
|
-
**Paths** ([#239](https://github.com/rig-c/pose/issues/239)). `--rig` is the directory @rig-c/parts writes, `<out>/rig/`: it reads `rig.json` and the `motion.json` beside it. Naming the `rig.json` itself reads no motion spec, so a rig that needs its own animations — a slider applies one — is red before anything is added, and the `RIG_GATE_BASE` line says the `motion.json` beside it was not read and names the directory form (or `--base-motion`). A directory `--out`, `--report` or `--camera` names that is not there yet is made once the run is green, and a `made directory <dir>` line says so and which paths it holds; a red run makes none, and a file where a directory has to be made is refused (`ARG_PATH`). The report is always written: without `--report` it goes beside the motion as `<out>.report.json` (`motion.json` → `motion.report.json`), and its `wrote` line says it was the default. A report that is already at that path is replaced, and the `wrote` line names when the replaced one was last written (its mtime, ISO 8601), so a second run to the same path is never silent. None of this changes the motion's bytes.
|
|
112
|
+
**Paths** ([#239](https://github.com/rig-c/pose/issues/239)). `--rig` is the directory @rig-c/parts writes, `<out>/rig/`: it reads `rig.json` and the `motion.json` beside it. Naming the `rig.json` itself reads no motion spec, so a rig that needs its own animations — a slider applies one — is red before anything is added, and the `RIG_GATE_BASE` line says the `motion.json` beside it was not read and names the directory form (or `--base-motion`). When the gate is green without it, the run goes on and `--out` carries none of the rig's own animations, so a `MOTION` line says the same thing, in every output mode ([#250](https://github.com/rig-c/pose/issues/250)). A directory `--out`, `--report` or `--camera` names that is not there yet is made once the run is green, and a `made directory <dir>` line says so and which paths it holds; a red run makes none, and a file where a directory has to be made is refused (`ARG_PATH`). The report is always written: without `--report` it goes beside the motion as `<out>.report.json` (`motion.json` → `motion.report.json`), and its `wrote` line says it was the default. A report that is already at that path is replaced, and the `wrote` line names when the replaced one was last written (its mtime, ISO 8601), so a second run to the same path is never silent. None of this changes the motion's bytes.
|
|
113
113
|
|
|
114
114
|
For every frame of the pose and every bone the map names, the segment that bone carries (an upper arm: shoulder → elbow) points somewhere in the picture. The bone is turned so its segment points the same way: the picture angle, flipped into rig-c's y-up world, minus the bone's angle at the rig's setup pose (read through rig-c's own poser), less the turn of the mapped bone above it — because a `rotate` key in rig-c adds to the setup rotation and a child turns with its parent. The travel of the pelvis midpoint, scaled by a length measured on both the rig and the pose, moves the hip. When a head chain carries the eye line (`r_eye → l_eye`), the eyes' vertical travel against `neck` in the reference, scaled into the rig's units, keys a vertical `translate` nod channel on the head bone ([#111](https://github.com/rig-c/pose/issues/111)) clamped within `±2%` of the setup neck length (`head − neck`, [#181](https://github.com/rig-c/pose/issues/181)), while on sequences where that clamp acts, the clamped vertical nod (`clampedY`) is coupled in phase into `face.pitch`'s dial (`+2.8°/unit`, mapped through `face.pitch.range`) and the head bone's `rotate` track (`+1.6°/unit`, [#190](https://github.com/rig-c/pose/issues/190), [#205](https://github.com/rig-c/pose/issues/205); gains calibrated against the hand-polished reference `p1`). **How the nod's baseline is read** ([#241](https://github.com/rig-c/pose/issues/241)): on a sequence declared `"travel": "in-place"` it is frame 0, with no window, no clamp and so no coupling ([#242](https://github.com/rig-c/pose/issues/242)); otherwise it is a ±0.5 s running median of the eyes' height against the neck, averaged again over the same window, and the nod is the eyes' height less it. On a sequence `clean --loop` wrote (its `cleaned.params.loop`) and not declared in place, that window **wraps**, the last frame running into the first, as clean's own windows do; cut at the clip's ends instead, the two ends are read against one-sided baselines, and the coupling carries the difference into the head's rotate. On the tests' synthetic loop (#242's walk, 7 cycles of 24 frames, the eyes bobbing 3 px twice a cycle, `travel` deleted), the wrapped window puts cycle 7 on cycle 1 to 0.000° and 0.000 units; cut at the ends it was 1.057° on the rotate and 0.661 units on the nod. `"nod": false` on the eye-line chain ([below](#the-map-rig-pose-map1)) keys no nod and couples none. The `NOD` line says which of these happened. The result is one animation in rig-c's motion format (`rigc-motion/1`): a `rotate` track per mapped bone, a `translate` track on the hip (and on the head when the reference nods), one key per pose frame — and, when the map lists alternate drawings, an `attachment` track per slot they switch (and a `translate` track per bone a set offsets).
|
|
115
115
|
|
|
@@ -126,6 +126,7 @@ Nothing is written until every check passes:
|
|
|
126
126
|
and what the run cannot decide for you it lists:
|
|
127
127
|
|
|
128
128
|
- `CLEAN` — what `rig-pose clean` did to the sequence: its windows, spikes, jitter before → after, and the stillness hold counted — holds, joints, regions and their frames, the joint spans not held, the seams ([#238](https://github.com/rig-c/pose/issues/238)). The per-joint record is the sequence's `cleaned` (`holds`, `unheld`, `seams`), which `--report` writes whole; `--verbose` prints it on the line.
|
|
129
|
+
- `MOTION` — `--rig` named a `rig.json` with a `motion.json` beside it that was not read, and the gate passed anyway ([#250](https://github.com/rig-c/pose/issues/250)): the file, and the two forms that read it. Printed under `--summary` too.
|
|
129
130
|
- `RIGC` — the rig-c version and entry that gated and posed the run (also `rigc` in the report): a later rig-c can change the bytes.
|
|
130
131
|
- `SCALE` — when the map translates a bone: the figure scale, `scale.rig` over the median picture length of `scale.pose`, and ([#240](https://github.com/rig-c/pose/issues/240)) that median as a share of the segment's 95th percentile — under 1, the median frame foreshortens the segment and the factor is that much larger than the segment at its longest gives. Then a `bands` line: the rig-to-picture ratio of the legs (hip → ankle: the rig's at setup over the picture's 95th percentile, to over its median, either side) and of the hips (thigh roots apart at setup over `r_hip → l_hip`'s 95th percentile, to over its median), each `within` or `outside` — within when the rig's length carried into the picture by the factor (rig ÷ factor) lies between the picture's median and 95th percentile give or take one picture px, so a band the picture never widened is not missed by a rounding the picture cannot show (the wave re-cleaned with the hold on reads a factor of 1.00001 against a hip band of exactly 1.00000); an `outside both bands` line when the factor lies outside both — the scale pair sizes the figure as neither its legs nor its hips do; and a `hip travel` line, the translated joint's x and y range, the pose's in px beside the keyed bone's in units, each also in leg lengths (`legs`: the two sides' mean, the picture's 95th percentile and the rig's at setup) and hip widths (`hips`: the picture's 95th percentile, the rig's thigh roots at setup). A reading (`scaleFit` in the report); nothing it prints changes a key. The wave: leg 0.99698–0.99698 and hip 1.00000–1.00000, both within; no `outside both bands` line.
|
|
131
132
|
- `HOLD` — a joint missing, or scored under `--min-score`, holds its last good value; one line per span, with the frames and the reason.
|
|
@@ -134,6 +135,7 @@ and what the run cannot decide for you it lists:
|
|
|
134
135
|
- `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
136
|
- `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
137
|
- `FOLD` — `rigc render --folds` over the new animation: every mesh that folds, and when. A reading, not a gate.
|
|
138
|
+
- `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
139
|
- `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
140
|
- `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
141
|
- `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`.
|
|
@@ -150,7 +152,7 @@ and what the run cannot decide for you it lists:
|
|
|
150
152
|
- `STANCE` — with `--stance` ([below](#the-legs-foreshortening-and-planted-feet)): a `unit` line first, naming the tolerance's unit (`FEET`'s leg length, each side's median hip → ankle) in px and as a share of the straight leg (the 95th percentile hip → ankle) ([#237](https://github.com/rig-c/pose/issues/237)); one line per run of frames planted both (with the stretch keyed, its largest k), lifted, or past the tolerance below (the pose's height kept, [#114](https://github.com/rig-c/pose/issues/114) — printed only where no `--leg-scale` clamp moved the free ankle; otherwise the frames it moved and how far, [#237](https://github.com/rig-c/pose/issues/237)), with the free side, the frames and the tolerance in leg lengths and px; and one per run whose stretch fell past its bounds (`past the bounds`), with the k the floor needed, the bound it had and how far the foot was left from it; and, where the leg's own range set a lower bound below 0.92, one `bound from the leg's own range` line with each side's lowest.
|
|
151
153
|
- `SHEAR` — with `--leg-scale`: every scaled bone and every bone under one, how far its art is sheared and how long its world axes are against setup. A reading. Where the shear prints above 0.00°, the line names where it comes from ([#240](https://github.com/rig-c/pose/issues/240)): a bone that inherits `normal` from a scaled parent (or one under a scaled bone) takes its parent's unequal axes into its art (`it inherits "normal" from scaled "…"`), and `"inherit": "noScale"` on that bone in the rig spec is the fix — it still turns with its parent, and rig-pose keys it through its parent ([#153](https://github.com/rig-c/pose/issues/153)); `onlyTranslation` removes the shear too and also stops it turning. On the tests' bent knee under a scaled thigh, the shin's 5.27° is 0.00° with `noScale`.
|
|
152
154
|
- `IDLE`, `IDLE-DROPPED`, `IDLE-KEPT-OUT`, `MERGE` — the rig's idle against the new animation, and what `--carry-idle` and `--merge` put back ([below](#the-idle-what-the-retarget-drops)).
|
|
153
|
-
- `REDUCE` — with `--reduce` ([below](#few-keys---reduce)): per bone, its keys before and after and its largest error, with the frame; then the total. At the end of the run, one `ankle drift allowed` line per ankle ([#240](https://github.com/rig-c/pose/issues/240)): the tolerance its `LAND` line held it to, in units and in its side's rig leg at setup, split into the plant's and the reduction's, and the two-frame slide that allows.
|
|
155
|
+
- `REDUCE` — with `--reduce` ([below](#few-keys---reduce)): per bone, its keys before and after and its largest error, with the frame; then the total. At the end of the run, one `ankle drift allowed` line per ankle ([#240](https://github.com/rig-c/pose/issues/240)): the tolerance its `LAND` line held it to, in units and in its side's rig leg at setup, split into the plant's and the reduction's, and the two-frame slide that allows; with `--reduce-keep-planted`, the same for its support frames (the plant's plus the hip's), or that its side never supports.
|
|
154
156
|
- `NOD` — with a chain carrying the eye line ([#241](https://github.com/rig-c/pose/issues/241)): the head bone and chain, the keyed translate's y range in units over the frames, the window its baseline was read over (`none (in-place)`, or ±*h* frames `wrapped on the loop` or `cut at the clip's ends`), and outside in place the clamp (±2% of the neck's setup length) with the frames it acted on and the coupling that follows: `NOD bone "head" (chain "head", r_eye → l_eye): translate y -0.400 … +0.400 units over 168 frame(s); window ±12 frame(s) (0.5 s) running median, detrended, wrapped on the loop; clamp ±0.400 units (2% of the neck's setup length) acted on 140 frame(s), so the clamped nod is coupled in phase into bone "head"'s rotate (+1.6°/unit) on every frame`. When no nod is keyed it says why instead: `"nod": false`, a translate on the bone (the map's or a set's offsets), a scaled bone above it, no good neck, no scale, or eyes that do not move against the neck (the example wave).
|
|
155
157
|
- `FACE` — with `face` in the map ([#138](https://github.com/rig-c/pose/issues/138), [#173](https://github.com/rig-c/pose/issues/173)): per-frame yaw and pitch read from `nose` against the eye midpoint (`r_eye + l_eye`) and, when present, the ear midpoint (`r_ear + l_ear`), de-rolled by the eye line, then compressed into each configured axis's `[min, max]` range (`scale` and correlation `r`), and, when `face.yaw.sets` (or `face.sets`) is declared, every yaw-switched set's entered spans, swap count, and any `hides` slot keyed by another track, written as `face` in `--report`.
|
|
156
158
|
- `BODY` — per-frame body yaw read from the camera-normalised shoulder width (`r_shoulder + l_shoulder`) and hip width (`r_hip + l_hip`) projected perpendicular to the trunk against the sequence's standing width (`yaw ≈ acos(w / w_standing)`, [#173](https://github.com/rig-c/pose/issues/173)): its overall and opening (`frames 0–47`) range, every span with `|yaw| ≥ 45°`, and, when `body.yaw` or `body.sets` is declared in the map, its knee-mapped dial keys and every yaw-switched set's entered spans, swap count, and any `hides` slot keyed by another track, written as `body` in `--report`.
|
|
@@ -166,8 +168,8 @@ and what the run cannot decide for you it lists:
|
|
|
166
168
|
|
|
167
169
|
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
170
|
|
|
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
|
|
171
|
+
`--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; `reduceKeepPlanted: true` only with `--reduce-keep-planted`), `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:
|
|
172
|
+
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
173
|
|
|
172
174
|
|
|
173
175
|
| field | each entry |
|
|
@@ -175,10 +177,12 @@ Report event objects with a pose `frame` or inclusive `frames` also carry exact
|
|
|
175
177
|
| `holds` | `{ point, from, to, source, why }`: a joint held over frames `from`–`to` at frame `source`'s value |
|
|
176
178
|
| `bends` | `{ chain, joint, frame, t, angle, side, rest }`: a hinge found bent the wrong way (`angle` in degrees, Spine's sense; `side` ±1, the side it may bend) |
|
|
177
179
|
| `endOn` | `{ chain, bone, joints, frames, seconds, minFraction }`: a limb pointing at the camera for ≥ 0.15 s |
|
|
178
|
-
| `landings` | `{ bone, worst, frame, kind, tolerance }`: where rig-c's poser put each bone, against the target. `kind` is `angle` (degrees), `origin` or `foot` (world units), or `scale` (a fraction of the setup length). A run writes only when every `worst` ≤ `tolerance` |
|
|
180
|
+
| `landings` | `{ bone, worst, frame, kind, tolerance, on? }`: where rig-c's poser put each bone, against the target. `kind` is `angle` (degrees), `origin` or `foot` (world units), or `scale` (a fraction of the setup length). With `--reduce-keep-planted` each `foot` is two rows, `on: "support"` (the frames its side supports) and `on: "off-support"` (the rest), each with its own tolerance ([#251](https://github.com/rig-c/pose/issues/251)); a side with no frame in one of them has no row for it. A run writes only when every `worst` ≤ `tolerance` |
|
|
179
181
|
| `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
182
|
| `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
183
|
| `folds` | `{ slot, attachment, times }`: every mesh `rigc render --folds` saw fold, at those seconds; `null` when it did not read |
|
|
184
|
+
| `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`) |
|
|
185
|
+
| `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
186
|
|
|
183
187
|
The demand lists:
|
|
184
188
|
|
|
@@ -279,6 +283,7 @@ The retarget writes one key per pose frame on every mapped bone. That is faithfu
|
|
|
279
283
|
- A scaled bone's `scalex` track is thinned within 5e-5 of its setup length: half of the scale landing check's 1e-4, so a reduced curve cannot fail that check.
|
|
280
284
|
- A mapped bone under a scaled bone (keyed through its parent's posed matrix by `--leg-scale`) is solved again through its parent as reduced. Its rotate is then thinned with the error read as the world aim it lands on, and its `scalex` with the error read as the world length it lands on. Its world error is its own fit error, as down any other chain.
|
|
281
285
|
- Each ankle is held to the plant's tolerance plus the most the reduction may move it: the hip's tolerance (with `--reduce-hip`), plus the leg from the hip to the ankle (each bone's setup offset from its parent and the last bone's length) times the largest scale asked, times the angle tolerance in radians plus 5e-5. The ankle's `LAND` line prints that tolerance, and at the end of the run `REDUCE ankle drift allowed: bone "…:tip" X units = Y leg lengths on any one frame` prints the same figure, split `(plant … + reduce …)` (`reduce.ankleDrift` in the report, `{ bone, side, plant, reduce, units, legLengths }`, the row's `units` the landing's `tolerance` itself), with the slide between two frames it lets through (2X) — so a slide bar is read against the reduction before it is run, not after ([#240](https://github.com/rig-c/pose/issues/240)). On the wave with `--leg-scale 0.8,1.05 --plant-feet --reduce 0.5 --reduce-hip 0.5`, that is 1.97 units = 0.0132 leg lengths a frame, 0.0263 between two.
|
|
286
|
+
- **`--reduce-keep-planted`** (only with `--reduce` and `--plant-feet`, refused by name otherwise; [#251](https://github.com/rig-c/pose/issues/251)) adds every frame of each support span to the frames kept regardless, on every mapped bone from the hip down to that side's ankle (rotate, and `scalex` where scaled). A kept frame is keyed at its target, so on its support frames the planted ankle lands as dense but for the hip: its `LAND` line there is `ankle on its support frames (kept dense)`, held to the plant's tolerance plus `--reduce-hip`'s (0 without it), and off them `ankle off its support frames`, held to the allowance above. `reduce.ankleDrift[].support` carries the tighter figure (`{ reduce, units, legLengths }`, `reduce` being the hip's share; `null` for a side that never supports). The tolerance has no separate rounding term: on the tests' synthetic walk (72 frames, six support spans) the supporting ankle on its support frames sits 1.22e-3 units from the dense run's, against the plant's 0.0253. Measured on that walk with `--reduce 1`, the FEET slide (the worst horizontal drift of a supporting ankle over a support span) goes from 0.0226 leg lengths to 0.00002, the dense run's; with `--reduce-hip 0.5` beside it, 0.0048, since the hip is not kept. The price is the keys: the supporting leg keeps one per support frame (the walk's four leg rotate tracks, 47 → 135 keys).
|
|
282
287
|
- When `--leg-scale` is active, each trunk bone (`chest` and `head`, not an ancestor of any scaled bone) has its kept knots and Bézier handles smoothed within `deg` of its dense world aim to minimise the mean absolute second difference of its evaluated local angle and its child's compensated local angle, holding any limb children within 0.03° of their fitted world aim (rig-c/pose#116).
|
|
283
288
|
|
|
284
289
|
Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
|
|
@@ -517,10 +522,15 @@ The answer, since 0.2, is a second drawing of the arm for each of those poses, s
|
|
|
517
522
|
|
|
518
523
|
## What it does not do
|
|
519
524
|
|
|
525
|
+
What 1.0 leaves out ([#217](https://github.com/rig-c/pose/issues/217)). Where an open issue carries the work, it is named; where none does, the line says what to do instead.
|
|
526
|
+
|
|
520
527
|
- **Extract poses from video.** The input is joint positions. How you get them is up to you: a pose model, a dataset, or by hand.
|
|
521
|
-
- **Turn the body.** It works in 2D only. A segment pointing at the camera foreshortens, and the rig cannot: the direction is matched, the length is not. With `--leg-scale`, leg segments are the exception.
|
|
522
|
-
- **
|
|
523
|
-
- **
|
|
528
|
+
- **Turn the body.** It works in 2D only. A segment pointing at the camera foreshortens, and the rig cannot: the direction is matched, the length is not. With `--leg-scale`, the leg segments and the trunk ([#172](https://github.com/rig-c/pose/issues/172)) are the exception. A body turned past what a stretch can show is drawn by switching to a set the map declares (`body.sets`, [#173](https://github.com/rig-c/pose/issues/173)), not by turning.
|
|
529
|
+
- **Front and behind, per segment.** Which limb passes in front of which is not read. The keys carry no front/back slot order ([#33](https://github.com/rig-c/pose/issues/33)).
|
|
530
|
+
- **Blinks and mouth shapes.** No eyelid or mouth key is written. The face points read the head's yaw and pitch (`face` in [the map](#the-map-rig-pose-map1)) and nothing more. No issue carries this.
|
|
531
|
+
- **Fit cloth physics to the reference.** A skirt's physics is the rig's own. It is not fitted to how the cloth moved in the video ([#34](https://github.com/rig-c/pose/issues/34)).
|
|
532
|
+
- **Clean detector errors by limb length.** `clean` reads spikes, occlusion and jitter. It does not find a joint the detector placed wrong from the limb lengths around it ([#75](https://github.com/rig-c/pose/issues/75)).
|
|
533
|
+
- **Track the camera from the video.** A zoom or a pan is read from the sequence's `camera` ([above](#the-pose-rig-pose-sequence1)). Fitting it from pixels is the repository's `tools/` scripts, which are not shipped. Without a `camera`, the picture is read as a fixed camera.
|
|
524
534
|
- **Hands and fingers.** There are no bones for them yet (rig-c/parts#171).
|
|
525
535
|
|
|
526
536
|
## Licence
|
package/cli.ts
CHANGED
|
@@ -170,6 +170,10 @@ retarget
|
|
|
170
170
|
at the end as "ankle drift allowed" in units and leg lengths (rig-c/pose#240)
|
|
171
171
|
--reduce-hip <units> with --reduce, thin the hip's translate keys too, its world origin within units
|
|
172
172
|
of the dense keys (default: the hip stays dense)
|
|
173
|
+
--reduce-keep-planted with --reduce and --plant-feet, keep every frame of each support span on the
|
|
174
|
+
supporting leg (each mapped bone from the hip down to its ankle) at its dense key, so the
|
|
175
|
+
planted ankle lands as dense on its support frames: its landing tolerance there is the
|
|
176
|
+
plant's plus the hip's --reduce-hip, off them --reduce's allowance (rig-c/pose#251)
|
|
173
177
|
--switch-tolerance <d> a set of alternate drawings (the map's "alternates") may show on a frame where
|
|
174
178
|
every segment of its chains is within d degrees of its key skeleton (default: 25)
|
|
175
179
|
--switch-min <s> a run of one set shorter than s seconds is dropped (default: 0.25)
|
|
@@ -180,7 +184,12 @@ retarget
|
|
|
180
184
|
--profile spine|spine-html rigc's gate profile (default: spine, rigc's own default)
|
|
181
185
|
--build <dir> keep the gated build (skeleton.json, .atlas, .model.json) here
|
|
182
186
|
--render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
|
|
183
|
-
--
|
|
187
|
+
--parts <path> @rig-c/parts' parts.json, or the directory holding it (its build's --out, beside rig/):
|
|
188
|
+
the neighbour pairs the seam reading reads — each limb's consecutive segments. Each
|
|
189
|
+
pair is read by rigc render --seam over the new animation and every span of a gap or
|
|
190
|
+
a ghost thicker than one cell prints a SEAM line and a report seams row; a reading,
|
|
191
|
+
never a gate. Without it no pair is declared (a rig spec carries none): seams: []
|
|
192
|
+
--report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds, seams,
|
|
184
193
|
switches, uncovered, turns, range, artRange, raised, byHip,
|
|
185
194
|
idleDropped, idleKeptOut, merged, reduce) as JSON (default: <out>.report.json beside
|
|
186
195
|
the motion, a trailing .json of --out replaced); its wrote line names the mtime of a
|
|
@@ -315,7 +324,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
|
|
|
315
324
|
const RETARGET_ALIASES = { sequence: 'pose', animation: 'name' } as const;
|
|
316
325
|
|
|
317
326
|
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);
|
|
327
|
+
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', 'reduce-keep-planted', 'verbose', 'summary'], RETARGET_ALIASES);
|
|
319
328
|
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
329
|
const bends = f.get('bends') ?? 'refuse';
|
|
321
330
|
if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
|
|
@@ -352,6 +361,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
|
|
|
352
361
|
profile,
|
|
353
362
|
build: f.get('build'),
|
|
354
363
|
render: f.get('render'),
|
|
364
|
+
parts: f.get('parts'),
|
|
355
365
|
report: f.get('report'),
|
|
356
366
|
camera: f.get('camera'),
|
|
357
367
|
idle: f.get('idle'),
|
|
@@ -378,14 +388,19 @@ function floorOf(f: Map<string, string>): { floor?: number } {
|
|
|
378
388
|
return { floor: v };
|
|
379
389
|
}
|
|
380
390
|
|
|
381
|
-
/** --reduce and --reduce-hip (rig-c/pose#32): off unless given; the hip only with the bones. */
|
|
382
|
-
function reduceOf(f: Map<string, string>): { reduce?: number; reduceHip?: number } {
|
|
391
|
+
/** --reduce and --reduce-hip (rig-c/pose#32): off unless given; the hip only with the bones. --reduce-keep-planted (rig-c/pose#251): only with both --reduce and --plant-feet. */
|
|
392
|
+
function reduceOf(f: Map<string, string>): { reduce?: number; reduceHip?: number; reduceKeepPlanted?: true } {
|
|
393
|
+
if (f.has('reduce-keep-planted')) {
|
|
394
|
+
const missing = ['reduce', 'plant-feet'].filter((x) => !f.has(x));
|
|
395
|
+
if (missing.length > 0) throw new Usage(`--reduce-keep-planted is given without ${missing.map((x) => `--${x}`).join(' and ')}; it keeps the support spans --plant-feet reads at dense keys inside the reduction --reduce makes`);
|
|
396
|
+
}
|
|
383
397
|
if (!f.has('reduce')) {
|
|
384
398
|
if (f.has('reduce-hip')) throw new Usage('--reduce-hip is given without --reduce; the hip is thinned with the bones, not alone');
|
|
385
399
|
return {};
|
|
386
400
|
}
|
|
387
401
|
const reduce = num(f, 'reduce', 0, (n) => n > 0 && n < 180, 'degrees in (0, 180)');
|
|
388
|
-
|
|
402
|
+
const keep = f.has('reduce-keep-planted') ? { reduceKeepPlanted: true as const } : {};
|
|
403
|
+
return f.has('reduce-hip') ? { reduce, reduceHip: num(f, 'reduce-hip', 0, (n) => n > 0, 'rig units > 0'), ...keep } : { reduce, ...keep };
|
|
389
404
|
}
|
|
390
405
|
|
|
391
406
|
function compareArgs(argv: string[]): CompareArgs {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/pose",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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). */
|
|
@@ -55,6 +58,8 @@ export interface RetargetArgs extends RetargetOptions {
|
|
|
55
58
|
reduce?: number;
|
|
56
59
|
/** With reduce: thin the hip's translate keys within this many rig units. */
|
|
57
60
|
reduceHip?: number;
|
|
61
|
+
/** With reduce and plantFeet: keep each support span's frames on the supporting leg at its dense keys (#251). */
|
|
62
|
+
reduceKeepPlanted?: boolean;
|
|
58
63
|
/**
|
|
59
64
|
* What stdout carries (rig-c/pose#238). `default`: the CLEAN line's hold record
|
|
60
65
|
* as a count and each hinge's wrong-way frames as one line; `verbose`: every
|
|
@@ -112,6 +117,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
112
117
|
if (!(idleName in animations) && (a.idle !== undefined || a.carryIdle === true)) {
|
|
113
118
|
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
119
|
}
|
|
120
|
+
// #216: the neighbour pairs, read before any work so a bad --parts is refused first.
|
|
121
|
+
const seamDecl = a.parts === undefined ? null : (() => {
|
|
122
|
+
const path = partsPath(a.parts);
|
|
123
|
+
return { path, pairs: readSeamPairs(readFileSync(path, 'utf8'), path) };
|
|
124
|
+
})();
|
|
115
125
|
const fragment = a.merge === undefined ? [] : readFragment(parseJson(readFileSync(a.merge, 'utf8'), a.merge, 'MERGE_JSON'), a.merge);
|
|
116
126
|
const mapSnapshot = readReportInput(a.map), poseSnapshot = readReportInput(a.pose);
|
|
117
127
|
const map = readMap(mapSnapshot.bytes.toString('utf8'), a.map);
|
|
@@ -151,8 +161,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
151
161
|
}
|
|
152
162
|
}
|
|
153
163
|
}
|
|
154
|
-
tell(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.raisedByHead === true ? ', raised-by-head' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
|
|
164
|
+
tell(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.raisedByHead === true ? ', raised-by-head' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}${a.reduceKeepPlanted === true ? ', planted legs kept dense' : ''}` : ''}`);
|
|
155
165
|
say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
|
|
166
|
+
// #250: the gate was green without it, so nothing else would say the rig's own animations are not in --out.
|
|
167
|
+
const unread = unreadMotionLine(a.rig, input.motion);
|
|
168
|
+
if (unread !== null) tell(` MOTION ${unread}`);
|
|
156
169
|
say(` CLEAN ${output === 'verbose' ? cleanLine(seq.cleaned!) : cleanSummaryLine(seq.cleaned!)}`);
|
|
157
170
|
if (a.smoothAlias !== undefined) say(` SMOOTH --smooth is carried by clean; ignored (given ${a.smoothAlias}; this sequence was cleaned with legs ${seq.cleaned!.params.legs}, rest ${seq.cleaned!.params.rest})`);
|
|
158
171
|
if (seq.camera !== undefined) say(` CAMERA ${cameraLine(seq.camera, times, seq.cameraSource)}`);
|
|
@@ -214,7 +227,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
214
227
|
if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
|
|
215
228
|
|
|
216
229
|
// --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
|
|
217
|
-
const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
|
|
230
|
+
const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip, a.reduceKeepPlanted === true);
|
|
218
231
|
if (reduced !== null) {
|
|
219
232
|
for (const row of reduced.rows) {
|
|
220
233
|
const [who, track, what, unit] = row.kind === 'angle' ? ['bone', 'rotate', 'aim', '°'] : row.kind === 'origin' ? ['hip', 'translate', 'origin', ' units'] : ['bone', 'scalex', 'scale', ' of its setup length'];
|
|
@@ -286,17 +299,25 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
286
299
|
// reduced bones above it may move it by (#94, `footReduce`). A scale keeps its own: it is reduced within half of it.
|
|
287
300
|
// #240: each ankle's allowance is computed once, here, and both the LAND tolerance and the drift line read it.
|
|
288
301
|
const drift: AnkleDrift[] = [];
|
|
289
|
-
|
|
302
|
+
// #251: with --reduce-keep-planted, each ankle is held apart on its support frames, which were kept dense.
|
|
303
|
+
const support = a.reduce !== undefined && a.reduceKeepPlanted === true && r.feet !== null ? supportFrames(r.feet.supports) : undefined;
|
|
304
|
+
const landings = measureLanding(doc, a.name, map, r, posed, support).map((l) => {
|
|
290
305
|
if (a.reduce === undefined) return l;
|
|
291
306
|
if (l.kind === 'angle') return { ...l, tolerance: l.tolerance + a.reduce };
|
|
292
307
|
if (l.kind === 'origin' && a.reduceHip !== undefined) return { ...l, tolerance: l.tolerance + a.reduceHip };
|
|
293
308
|
if (l.kind !== 'foot') return l;
|
|
294
309
|
const ankle = l.bone.replace(/:tip$/, '');
|
|
295
310
|
const by = footReduce(rest, r, ankle, a.reduce, a.reduceHip);
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
drift.
|
|
299
|
-
|
|
311
|
+
// #251: on its support frames every mapped bone from the hip down was kept at its target, so only the hip's tolerance moves it.
|
|
312
|
+
const hip = a.reduceHip ?? 0;
|
|
313
|
+
if (!drift.some((d) => d.bone === l.bone)) {
|
|
314
|
+
const lb = legBonesOf(map);
|
|
315
|
+
const side = lb?.r.ankle === ankle ? 'r' : lb?.l.ankle === ankle ? 'l' : null;
|
|
316
|
+
const legs = (u: number) => (side === null || r.rigLeg === null ? null : u / r.rigLeg[side]);
|
|
317
|
+
const held = support === undefined ? {} : { support: side === null || support[side].size === 0 ? null : { reduce: hip, units: l.tolerance + hip, legLengths: legs(l.tolerance + hip) } };
|
|
318
|
+
drift.push({ bone: l.bone, side, plant: l.tolerance, reduce: by, units: l.tolerance + by, legLengths: legs(l.tolerance + by), ...held });
|
|
319
|
+
}
|
|
320
|
+
return { ...l, tolerance: l.tolerance + (l.on === 'support' ? hip : by) };
|
|
300
321
|
});
|
|
301
322
|
const p = new Problems();
|
|
302
323
|
// A carried idle track on a bone above a mapped one moves it where the keys did not ask (the key formula holds unmapped bones at setup).
|
|
@@ -311,10 +332,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
311
332
|
};
|
|
312
333
|
for (const l of landings) {
|
|
313
334
|
const unit = l.kind === 'angle' ? '°' : l.kind === 'scale' ? ' of its setup length' : ' units';
|
|
314
|
-
const what = l.kind === 'angle' ? 'aim' : l.kind === 'scale' ? 'scale' : l.kind === 'foot' ? 'ankle' : 'origin';
|
|
335
|
+
const what = l.kind === 'angle' ? 'aim' : l.kind === 'scale' ? 'scale' : l.kind === 'foot' ? (l.on === 'support' ? 'ankle on its support frames (kept dense)' : l.on === 'off-support' ? 'ankle off its support frames' : 'ankle') : 'origin';
|
|
315
336
|
const tol = l.kind === 'foot' ? Number(l.tolerance.toPrecision(3)) : l.tolerance;
|
|
316
337
|
const line = `bone "${l.bone}" ${what} — worst |reached − asked| ${l.worst.toPrecision(3)}${unit} at frame ${l.frame} (t=${r.times[l.frame]!.toFixed(3)}s); the tolerance is ${tol}${unit}`;
|
|
317
338
|
if (landed(l)) say(` LAND ${line}`);
|
|
339
|
+
else if (l.on === 'support') p.add('POSE_LANDS', `${line} — --reduce-keep-planted holds every mapped bone from the hip down at its dense key on its side's support frames, so only the hip's --reduce-hip may move it; a frame missed here was not kept${carriedAbove(l.bone)}`);
|
|
318
340
|
else p.add('POSE_LANDS', `${line} — the rig does not turn the way the key formula assumes (a constraint, a scaled or sheared parent, a non-normal inherit mode)${carriedAbove(l.bone)}`);
|
|
319
341
|
}
|
|
320
342
|
p.throwIfAny();
|
|
@@ -347,6 +369,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
347
369
|
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
370
|
}
|
|
349
371
|
|
|
372
|
+
// #216: the seam reading over the declared neighbour pairs — a reading, never a gate.
|
|
373
|
+
const seamRead = readSeams(seamDecl, input.rig, doc.slots.map((s) => s.name), g1.build, a.name, join(scratch, 'seams'), fps);
|
|
374
|
+
for (const l of seamRead.lines) say(` SEAM ${l}`);
|
|
375
|
+
|
|
350
376
|
// #240: what --reduce allowed each ankle, at the end of the run, from the same figure the LAND check applied.
|
|
351
377
|
for (const d of drift) say(` REDUCE ${ankleDriftLine(d)}`);
|
|
352
378
|
|
|
@@ -359,7 +385,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
359
385
|
frames: seq.frames.length,
|
|
360
386
|
duration: r.animation.duration,
|
|
361
387
|
cleaned: seq.cleaned,
|
|
362
|
-
options: { minScore: a.minScore, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', floor: a.floor ?? null, raisedByHead: a.raisedByHead === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
|
|
388
|
+
options: { minScore: a.minScore, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', floor: a.floor ?? null, raisedByHead: a.raisedByHead === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off', ...(a.reduceKeepPlanted === true ? { reduceKeepPlanted: true } : {}) },
|
|
363
389
|
scale: r.scale,
|
|
364
390
|
scaleFit: r.scaleFit,
|
|
365
391
|
holds: r.holds,
|
|
@@ -372,6 +398,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
372
398
|
jitter,
|
|
373
399
|
posture,
|
|
374
400
|
folds: f.meshes,
|
|
401
|
+
seams: seamRead.seams,
|
|
402
|
+
seamPairs: seamRead.seamPairs,
|
|
375
403
|
switches: r.switches,
|
|
376
404
|
uncovered,
|
|
377
405
|
turns: r.turns,
|
|
@@ -411,7 +439,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
411
439
|
idleKeptOut: idle === null ? null : idle.keptOut,
|
|
412
440
|
merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
|
|
413
441
|
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});
|
|
442
|
+
}, 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
443
|
if (a.build !== undefined) {
|
|
416
444
|
// Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
|
|
417
445
|
rmSync(a.build, { recursive: true, force: true });
|
|
@@ -556,6 +584,11 @@ const spanText = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}
|
|
|
556
584
|
* chains it changes, and the edges of every FLIP spread, SHORT span and RANGE
|
|
557
585
|
* run on it (the first and last frame the reducer keeps).
|
|
558
586
|
*
|
|
587
|
+
* With `keepPlanted` (#251): every frame of each support span, on every mapped
|
|
588
|
+
* bone from the hip down to that side's ankle — kept frames are keyed at their
|
|
589
|
+
* target, so the planted ankle lands as dense there, but for the hip's own
|
|
590
|
+
* tolerance.
|
|
591
|
+
*
|
|
559
592
|
* With scaled bones (`--leg-scale`, the stretches; #94): a scaled bone's
|
|
560
593
|
* `scalex` is thinned within `SCALE_REDUCE`, and a mapped bone under a scaled
|
|
561
594
|
* bone — keyed through its parent's posed matrix (legs.ts) — is re-solved
|
|
@@ -563,7 +596,7 @@ const spanText = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}
|
|
|
563
596
|
* aim and length it lands on (`reduceUnderScale`). With none, this is #32's
|
|
564
597
|
* reduction, unchanged.
|
|
565
598
|
*/
|
|
566
|
-
function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest, tolerance: number, hipTolerance: number | undefined): Reduced {
|
|
599
|
+
function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest, tolerance: number, hipTolerance: number | undefined, keepPlanted: boolean): Reduced {
|
|
567
600
|
const n = r.times.length;
|
|
568
601
|
const keep = new Map<string, Set<number>>();
|
|
569
602
|
const add = (bone: string, ...frames: number[]) => {
|
|
@@ -576,6 +609,15 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
|
|
|
576
609
|
}
|
|
577
610
|
for (const x of [...r.flips, ...r.short, ...r.range]) add(x.bone, x.frames[0], x.frames[1]);
|
|
578
611
|
const mapped = new Set(r.targets.keys());
|
|
612
|
+
if (keepPlanted && r.feet !== null && r.ankles !== null) {
|
|
613
|
+
for (const s of r.feet.supports) {
|
|
614
|
+
const frames = Array.from({ length: s.frames[1] - s.frames[0] + 1 }, (_, k) => s.frames[0] + k);
|
|
615
|
+
for (let b: string | null = r.ankles.bones[s.side]; b !== null; b = rest.get(b)!.parent) {
|
|
616
|
+
if (mapped.has(b)) add(b, ...frames);
|
|
617
|
+
if (b === r.origin?.bone) break;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
579
621
|
const above = (b: string): string | null => {
|
|
580
622
|
for (let p = rest.get(b)!.parent; p !== null; p = rest.get(p)!.parent) if (mapped.has(p)) return p;
|
|
581
623
|
return null;
|
|
@@ -1016,6 +1058,53 @@ export function assertReportInputs(inputs:readonly ReportInputSnapshot[]):void {
|
|
|
1016
1058
|
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
1059
|
}
|
|
1018
1060
|
|
|
1061
|
+
/**
|
|
1062
|
+
* #216: the seam reading end to end — the declared pairs, each pair whose part
|
|
1063
|
+
* has no slot of its name left unread (rigc would refuse the whole render for
|
|
1064
|
+
* it), one `rigc render --seam` over the rest, and its projection. Whatever it
|
|
1065
|
+
* finds, or fails to read, it says so and refuses nothing.
|
|
1066
|
+
*/
|
|
1067
|
+
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 } {
|
|
1068
|
+
if (decl === null || decl.pairs.length === 0) {
|
|
1069
|
+
let why: string;
|
|
1070
|
+
if (decl !== null) why = `${decl.path} holds no limb segment below another (a part's "limb" record), so it declares no pair`;
|
|
1071
|
+
else {
|
|
1072
|
+
// @rig-c/parts writes rig/ and parts.json side by side under its --out; a rig spec itself carries no pair.
|
|
1073
|
+
const home = dirname(dirname(rig));
|
|
1074
|
+
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)` : ''}`;
|
|
1075
|
+
}
|
|
1076
|
+
return { seams: [], seamPairs: [], frames: null, lines: [`no neighbour pair declared — ${why}; seams: []`] };
|
|
1077
|
+
}
|
|
1078
|
+
const have = new Set(slotNames);
|
|
1079
|
+
const unread = new Map<string, string>();
|
|
1080
|
+
for (const p of decl.pairs) {
|
|
1081
|
+
const missing = [p.a, p.b].filter((n) => !have.has(n));
|
|
1082
|
+
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)`);
|
|
1083
|
+
}
|
|
1084
|
+
const toRead = decl.pairs.filter((p) => !unread.has(p.pair));
|
|
1085
|
+
let rows: ReturnType<typeof readSeamDoc> = [];
|
|
1086
|
+
let frames: unknown = null;
|
|
1087
|
+
if (toRead.length > 0) {
|
|
1088
|
+
const s = seamRender(build, animation, dir, fps, toRead.map((p) => [p.a, p.b] as const));
|
|
1089
|
+
let why: string | null = null;
|
|
1090
|
+
if (s.seam === null) {
|
|
1091
|
+
const said = s.run.out.split('\n').map((l) => l.trim()).find((l) => l.startsWith('rigc: ') && !l.startsWith('rigc: wrote'));
|
|
1092
|
+
why = `rigc render --seam did not read (exit ${s.run.status})${said === undefined ? '' : `: ${said}`}`;
|
|
1093
|
+
} else {
|
|
1094
|
+
try {
|
|
1095
|
+
rows = readSeamDoc(s.seam.text, s.seam.path);
|
|
1096
|
+
frames = s.frames;
|
|
1097
|
+
} catch (e) {
|
|
1098
|
+
if (!(e instanceof Refusal)) throw e;
|
|
1099
|
+
why = e.message;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (why !== null) for (const p of toRead) unread.set(p.pair, why);
|
|
1103
|
+
}
|
|
1104
|
+
const projected = projectSeams(decl.pairs, rows, unread);
|
|
1105
|
+
return { ...projected, frames, lines: seamLines(projected, fps) };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1019
1108
|
/** 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
1109
|
export function defaultReportPath(out: string): string {
|
|
1021
1110
|
return `${out.endsWith('.json') ? out.slice(0, -'.json'.length) : out}.report.json`;
|
|
@@ -1042,6 +1131,17 @@ export function unreadMotionHint(rigArg: string, motion: string | null): string
|
|
|
1042
1131
|
return `; ${beside} is beside it and was not read — --rig naming the rig.json reads no motion spec: pass --rig ${dirname(rigArg)} (the directory, which reads the motion.json beside its rig.json) or --base-motion ${beside}`;
|
|
1043
1132
|
}
|
|
1044
1133
|
|
|
1134
|
+
/**
|
|
1135
|
+
* rig-c/pose#250: the same reading when the base gate is green — the run goes on, and --out would carry
|
|
1136
|
+
* none of the rig's own animations without a word. One line, whatever the gate said; null when nothing was left unread.
|
|
1137
|
+
*/
|
|
1138
|
+
export function unreadMotionLine(rigArg: string, motion: string | null): string | null {
|
|
1139
|
+
if (motion !== null) return null;
|
|
1140
|
+
const beside = join(dirname(rigArg), 'motion.json');
|
|
1141
|
+
if (!existsSync(beside) || statSync(rigArg).isDirectory()) return null;
|
|
1142
|
+
return `${beside} is beside ${rigArg} and was not read — --rig naming the rig.json reads no motion spec, so --out carries none of the rig's own animations: pass --rig ${dirname(rigArg)} (the directory, which reads the motion.json beside its rig.json) or --base-motion ${beside}`;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1045
1145
|
/** The SCALE line's own clause (rig-c/pose#240): the median it divides by, against the segment at its longest. */
|
|
1046
1146
|
export function segmentClause(f: ScaleFit): string {
|
|
1047
1147
|
const sg = f.segment;
|
|
@@ -1102,10 +1202,28 @@ export interface AnkleDrift {
|
|
|
1102
1202
|
units: number;
|
|
1103
1203
|
/** `units` in that side's rig leg at setup (thigh origin → ankle); null without both legs. */
|
|
1104
1204
|
legLengths: number | null;
|
|
1205
|
+
/**
|
|
1206
|
+
* With --reduce-keep-planted (#251): the allowance on the frames its side supports, kept dense —
|
|
1207
|
+
* the plant's plus the hip's --reduce-hip (`reduce`, 0 without it); null when its side never supports.
|
|
1208
|
+
* Absent without the flag.
|
|
1209
|
+
*/
|
|
1210
|
+
support?: { reduce: number; units: number; legLengths: number | null } | null;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** #251: each side's support frames, from the spans --plant-feet read. */
|
|
1214
|
+
function supportFrames(supports: ReadonlyArray<{ side: 'r' | 'l'; frames: [number, number] }>): Record<'r' | 'l', Set<number>> {
|
|
1215
|
+
const out = { r: new Set<number>(), l: new Set<number>() };
|
|
1216
|
+
for (const s of supports) for (let i = s.frames[0]; i <= s.frames[1]; i++) out[s.side].add(i);
|
|
1217
|
+
return out;
|
|
1105
1218
|
}
|
|
1106
1219
|
|
|
1107
1220
|
/** The drift line: the allowance on one frame, and what it lets a planted ankle slide between two. */
|
|
1108
1221
|
export function ankleDriftLine(d: AnkleDrift): string {
|
|
1109
1222
|
const legs = (v: number) => (d.legLengths === null ? '' : ` = ${(d.legLengths * v / d.units).toPrecision(3)} leg lengths`);
|
|
1110
|
-
|
|
1223
|
+
const line = `ankle drift allowed: bone "${d.bone}" ${d.units.toPrecision(3)} units${legs(d.units)} on any one frame (plant ${d.plant.toPrecision(3)} + reduce ${d.reduce.toPrecision(3)}); between two frames up to ${(2 * d.units).toPrecision(3)} units${legs(2 * d.units)}`;
|
|
1224
|
+
if (d.support === undefined) return `${line} — a tighter slide bar needs a smaller --reduce`;
|
|
1225
|
+
if (d.support === null) return `${line}; its side never supports, so --reduce-keep-planted keeps no frame of it`;
|
|
1226
|
+
const s = d.support;
|
|
1227
|
+
const sLegs = (v: number) => (s.legLengths === null ? '' : ` = ${(s.legLengths * v / s.units).toPrecision(3)} leg lengths`);
|
|
1228
|
+
return `${line} off its support frames; on them, kept dense (--reduce-keep-planted), ${s.units.toPrecision(3)} units${sLegs(s.units)} (plant ${d.plant.toPrecision(3)} + hip ${s.reduce.toPrecision(3)}), between two frames of a support span up to ${(2 * s.units).toPrecision(3)} units${sLegs(2 * s.units)}`;
|
|
1111
1229
|
}
|
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
|
+
}
|
package/src/verify.ts
CHANGED
|
@@ -26,6 +26,11 @@ export interface Landing {
|
|
|
26
26
|
kind: 'angle' | 'origin' | 'scale' | 'foot';
|
|
27
27
|
/** The tolerance it is held to. */
|
|
28
28
|
tolerance: number;
|
|
29
|
+
/**
|
|
30
|
+
* A `foot` split by `--reduce-keep-planted` (#251): `support`, the frames its side supports
|
|
31
|
+
* (each support span kept dense); `off-support`, the rest. Absent: every frame, one row.
|
|
32
|
+
*/
|
|
33
|
+
on?: 'support' | 'off-support';
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/**
|
|
@@ -60,7 +65,7 @@ export function poseFrames(doc: CompiledDocument, animation: string, times: read
|
|
|
60
65
|
return poses.map((p) => new Map(p.bones.map((b) => [b.name, b])));
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
export function measureLanding(doc: CompiledDocument, animation: string, map: JointMap, r: Retarget, posed = poseFrames(doc, animation, r.times)): Landing[] {
|
|
68
|
+
export function measureLanding(doc: CompiledDocument, animation: string, map: JointMap, r: Retarget, posed = poseFrames(doc, animation, r.times), support?: Record<'r' | 'l', ReadonlySet<number>>): Landing[] {
|
|
64
69
|
const out: Landing[] = [];
|
|
65
70
|
const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
|
|
66
71
|
const faceBones = new Set([r.face?.yaw?.bone, r.face?.pitch?.bone].filter((b): b is string => b !== undefined));
|
|
@@ -163,18 +168,23 @@ export function measureLanding(doc: CompiledDocument, animation: string, map: Jo
|
|
|
163
168
|
const tip0: [number, number] = [s0.worldX + s0.a * (lengths.get(bone) ?? 0), s0.worldY + s0.c * (lengths.get(bone) ?? 0)];
|
|
164
169
|
const h0 = top === undefined ? null : setup.get(top)!;
|
|
165
170
|
const lever = h0 === null ? Math.hypot(tip0[0], tip0[1]) : Math.hypot(tip0[0] - h0.worldX, tip0[1] - h0.worldY);
|
|
166
|
-
|
|
167
|
-
|
|
171
|
+
const tolerance = ORIGIN_TOLERANCE + FOOT_TOLERANCE_PER_UNIT * lever;
|
|
172
|
+
const rows = support === undefined ? [{ on: undefined, worst: -1, frame: 0, n: 0 }] : (['support', 'off-support'] as const).map((on) => ({ on, worst: -1, frame: 0, n: 0 }));
|
|
168
173
|
posed.forEach((bones, i) => {
|
|
169
174
|
const got = pointAt(bones, lengths, `${bone}:tip`)!;
|
|
170
175
|
const want = r.ankles!.at[side][i]!;
|
|
171
176
|
const e = Math.hypot(got[0] - want[0], got[1] - want[1]);
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
177
|
+
const row = support === undefined ? rows[0]! : rows[support[side].has(i) ? 0 : 1]!;
|
|
178
|
+
row.n++;
|
|
179
|
+
if (e > row.worst) {
|
|
180
|
+
row.worst = e;
|
|
181
|
+
row.frame = i;
|
|
175
182
|
}
|
|
176
183
|
});
|
|
177
|
-
|
|
184
|
+
for (const x of rows) {
|
|
185
|
+
if (x.n === 0) continue;
|
|
186
|
+
out.push({ bone: `${bone}:tip`, worst: x.worst, frame: x.frame, kind: 'foot', tolerance, ...(x.on === undefined ? {} : { on: x.on }) });
|
|
187
|
+
}
|
|
178
188
|
}
|
|
179
189
|
}
|
|
180
190
|
return out;
|