@rig-c/pose 0.33.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 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.
@@ -151,7 +152,7 @@ and what the run cannot decide for you it lists:
151
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.
152
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`.
153
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)).
154
- - `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.
155
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).
156
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`.
157
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`.
@@ -167,7 +168,7 @@ and what the run cannot decide for you it lists:
167
168
 
168
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.
169
170
 
170
- `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `spec`, `rigc` (`{ version, entry }`: the rig-c that gated it), `rig`, `animation`, `inputs` (consumed rig spec, map and pose files, each with invocation-relative `path` and exact-byte `sha256`; a folder rig resolves to its actual rig.json; these three identities do not claim to bind optional base motion, sets or images), `frames`, `duration`, `frameTimes` (the exact motion-key time for each pose frame; never FPS inferred from duration), `render` (the separate renderer sampling domain, `{ fps, frames }` from frame-set metadata, or `null` when not measured), `cleaned` (the sequence's `cleaned` record, [above](#clean-first-rig-pose-clean)), `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale`, `plantFeet`, `stance`, `floor` and `raisedByHead` included), `scale`, `scaleFit` (the `SCALE` reading: `segment`, `leg`, `hip`, `outside`, `travel`; `null` without a scale), `holds`, `short`, `endOn`, `bends`, `flips`, `gate` (rig-c's gate summary, a string), `landings`, `jitter`, `reach`, `posture`, `folds`, `seams`, `seamPairs`, `body` (`standing`, `read`, `over45`, `framesOver45`, and with `body.yaw` or `body.sets` in the map, `yaw` and `sets`), `legScale`, `pivot`, `feet` (`planted`, `legLength`, `straight`, `rigLeg`, `segments`, `supports`, `jumps`, `jumpEvents`, `steps`, `changes`, `lowered`, `floor`, `shifts`, `blend`, `closure`, and the posed `reading`; `straight` is each side's 95th-percentile picture length hip → ankle in px and `rigLeg` each side's rig leg at setup (thigh origin → ankle) in world units, both `null` without both legs; `segments` holds one `{ chain, bone, joints, straight, ratio: { min, median, max }, why? }` per mapped leg segment, read with or without `--leg-scale` (`straight` and `ratio` `null`, and `why` said, where it was not measured), [#237](https://github.com/rig-c/pose/issues/237); a jump is an inclusive `[first, last]` pair of frames; `lowered` holds `{ frames, was, now, cause? }` runs (`cause`, where `now` is `null`: `released` or `higher`), present only with `--plant-feet` on a sequence not declared in place ([#113](https://github.com/rig-c/pose/issues/113)); `floor` is the world y every support span was planted on and `shifts` holds one `{ side, frames, shift }` per support span in world units, `null` and empty without `--plant-feet`; `blend` is `{ bounds, runs: [{ side, frames, k: { min, max } }], over: [{ side, frames, needed, left }] }`, the supporting leg stretched on the blend frames, `null` without `--plant-feet`; `closure` is `{ drift, frames }`, the hip's x drift closed over an in-place loop, `null` unless the sequence is `"in-place"` and `--plant-feet` is given), `stance` (`tolerance`, `release`, `minSeconds`, `bounds`, `runs`: `{ state, side, frames, maxHeight, maxDepth?, k, absorbed?, moved? }` (`state` is `both`, `lifted` or `below`; `maxDepth`, on a `below` run, is the free ankle's largest distance below the supporting one in leg lengths; `moved`, on a `lifted` or `below` run, the frames whose free ankle a `--leg-scale` clamp moved off the pose's height, as `legScale[].free` is written, [#237](https://github.com/rig-c/pose/issues/237)), `over`: `{ side, frames, needed, left }`; `null` without `--stance`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), `reduce` (the tolerances, each row of `REDUCE` and `ankleDrift`, [below](#few-keys---reduce)), and the demand lists. The fields a run is graded by:
171
+ `--report <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:
171
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`.
172
173
 
173
174
 
@@ -176,7 +177,7 @@ Report event objects with a pose `frame` or inclusive `frames` also carry exact
176
177
  | `holds` | `{ point, from, to, source, why }`: a joint held over frames `from`–`to` at frame `source`'s value |
177
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) |
178
179
  | `endOn` | `{ chain, bone, joints, frames, seconds, minFraction }`: a limb pointing at the camera for ≥ 0.15 s |
179
- | `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` |
180
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"` |
181
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` |
182
183
  | `folds` | `{ slot, attachment, times }`: every mesh `rigc render --folds` saw fold, at those seconds; `null` when it did not read |
@@ -282,6 +283,7 @@ The retarget writes one key per pose frame on every mapped bone. That is faithfu
282
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.
283
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.
284
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).
285
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).
286
288
 
287
289
  Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
@@ -520,10 +522,15 @@ The answer, since 0.2, is a second drawing of the arm for each of those poses, s
520
522
 
521
523
  ## What it does not do
522
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
+
523
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.
524
- - **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.
525
- - **Read a moving camera.** A reference whose camera zooms or pans moves the planted floor with it under `--plant-feet` ([#52](https://github.com/rig-c/pose/issues/52)).
526
- - **Front and behind, blinks, mouth shapes.** The detector's scores and face points do not carry them ([#33](https://github.com/rig-c/pose/issues/33), [#46](https://github.com/rig-c/pose/issues/46)).
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.
527
534
  - **Hands and fingers.** There are no bones for them yet (rig-c/parts#171).
528
535
 
529
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)
@@ -320,7 +324,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
320
324
  const RETARGET_ALIASES = { sequence: 'pose', animation: 'name' } as const;
321
325
 
322
326
  function retargetArgs(argv: string[]): RetargetArgs {
323
- const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'parts', 'report', 'camera', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip', 'stance', 'floor'], ['coco', 'plant-feet', 'carry-idle', 'raised-by-head', 'verbose', 'summary'], RETARGET_ALIASES);
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);
324
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');
325
329
  const bends = f.get('bends') ?? 'refuse';
326
330
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
@@ -384,14 +388,19 @@ function floorOf(f: Map<string, string>): { floor?: number } {
384
388
  return { floor: v };
385
389
  }
386
390
 
387
- /** --reduce and --reduce-hip (rig-c/pose#32): off unless given; the hip only with the bones. */
388
- 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
+ }
389
397
  if (!f.has('reduce')) {
390
398
  if (f.has('reduce-hip')) throw new Usage('--reduce-hip is given without --reduce; the hip is thinned with the bones, not alone');
391
399
  return {};
392
400
  }
393
401
  const reduce = num(f, 'reduce', 0, (n) => n > 0 && n < 180, 'degrees in (0, 180)');
394
- return f.has('reduce-hip') ? { reduce, reduceHip: num(f, 'reduce-hip', 0, (n) => n > 0, 'rig units > 0') } : { reduce };
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 };
395
404
  }
396
405
 
397
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.33.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": {
package/src/run.ts CHANGED
@@ -58,6 +58,8 @@ export interface RetargetArgs extends RetargetOptions {
58
58
  reduce?: number;
59
59
  /** With reduce: thin the hip's translate keys within this many rig units. */
60
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;
61
63
  /**
62
64
  * What stdout carries (rig-c/pose#238). `default`: the CLEAN line's hold record
63
65
  * as a count and each hinge's wrong-way frames as one line; `verbose`: every
@@ -159,8 +161,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
159
161
  }
160
162
  }
161
163
  }
162
- 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' : ''}` : ''}`);
163
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}`);
164
169
  say(` CLEAN ${output === 'verbose' ? cleanLine(seq.cleaned!) : cleanSummaryLine(seq.cleaned!)}`);
165
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})`);
166
171
  if (seq.camera !== undefined) say(` CAMERA ${cameraLine(seq.camera, times, seq.cameraSource)}`);
@@ -222,7 +227,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
222
227
  if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
223
228
 
224
229
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
225
- 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);
226
231
  if (reduced !== null) {
227
232
  for (const row of reduced.rows) {
228
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'];
@@ -294,17 +299,25 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
294
299
  // reduced bones above it may move it by (#94, `footReduce`). A scale keeps its own: it is reduced within half of it.
295
300
  // #240: each ankle's allowance is computed once, here, and both the LAND tolerance and the drift line read it.
296
301
  const drift: AnkleDrift[] = [];
297
- const landings = measureLanding(doc, a.name, map, r, posed).map((l) => {
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) => {
298
305
  if (a.reduce === undefined) return l;
299
306
  if (l.kind === 'angle') return { ...l, tolerance: l.tolerance + a.reduce };
300
307
  if (l.kind === 'origin' && a.reduceHip !== undefined) return { ...l, tolerance: l.tolerance + a.reduceHip };
301
308
  if (l.kind !== 'foot') return l;
302
309
  const ankle = l.bone.replace(/:tip$/, '');
303
310
  const by = footReduce(rest, r, ankle, a.reduce, a.reduceHip);
304
- const lb = legBonesOf(map);
305
- const side = lb?.r.ankle === ankle ? 'r' : lb?.l.ankle === ankle ? 'l' : null;
306
- drift.push({ bone: l.bone, side, plant: l.tolerance, reduce: by, units: l.tolerance + by, legLengths: side === null || r.rigLeg === null ? null : (l.tolerance + by) / r.rigLeg[side] });
307
- return { ...l, tolerance: l.tolerance + by };
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) };
308
321
  });
309
322
  const p = new Problems();
310
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).
@@ -319,10 +332,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
319
332
  };
320
333
  for (const l of landings) {
321
334
  const unit = l.kind === 'angle' ? '°' : l.kind === 'scale' ? ' of its setup length' : ' units';
322
- 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';
323
336
  const tol = l.kind === 'foot' ? Number(l.tolerance.toPrecision(3)) : l.tolerance;
324
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}`;
325
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)}`);
326
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)}`);
327
341
  }
328
342
  p.throwIfAny();
@@ -371,7 +385,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
371
385
  frames: seq.frames.length,
372
386
  duration: r.animation.duration,
373
387
  cleaned: seq.cleaned,
374
- 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 } : {}) },
375
389
  scale: r.scale,
376
390
  scaleFit: r.scaleFit,
377
391
  holds: r.holds,
@@ -570,6 +584,11 @@ const spanText = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}
570
584
  * chains it changes, and the edges of every FLIP spread, SHORT span and RANGE
571
585
  * run on it (the first and last frame the reducer keeps).
572
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
+ *
573
592
  * With scaled bones (`--leg-scale`, the stretches; #94): a scaled bone's
574
593
  * `scalex` is thinned within `SCALE_REDUCE`, and a mapped bone under a scaled
575
594
  * bone — keyed through its parent's posed matrix (legs.ts) — is re-solved
@@ -577,7 +596,7 @@ const spanText = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}
577
596
  * aim and length it lands on (`reduceUnderScale`). With none, this is #32's
578
597
  * reduction, unchanged.
579
598
  */
580
- 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 {
581
600
  const n = r.times.length;
582
601
  const keep = new Map<string, Set<number>>();
583
602
  const add = (bone: string, ...frames: number[]) => {
@@ -590,6 +609,15 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
590
609
  }
591
610
  for (const x of [...r.flips, ...r.short, ...r.range]) add(x.bone, x.frames[0], x.frames[1]);
592
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
+ }
593
621
  const above = (b: string): string | null => {
594
622
  for (let p = rest.get(b)!.parent; p !== null; p = rest.get(p)!.parent) if (mapped.has(p)) return p;
595
623
  return null;
@@ -1103,6 +1131,17 @@ export function unreadMotionHint(rigArg: string, motion: string | null): string
1103
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}`;
1104
1132
  }
1105
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
+
1106
1145
  /** The SCALE line's own clause (rig-c/pose#240): the median it divides by, against the segment at its longest. */
1107
1146
  export function segmentClause(f: ScaleFit): string {
1108
1147
  const sg = f.segment;
@@ -1163,10 +1202,28 @@ export interface AnkleDrift {
1163
1202
  units: number;
1164
1203
  /** `units` in that side's rig leg at setup (thigh origin → ankle); null without both legs. */
1165
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;
1166
1218
  }
1167
1219
 
1168
1220
  /** The drift line: the allowance on one frame, and what it lets a planted ankle slide between two. */
1169
1221
  export function ankleDriftLine(d: AnkleDrift): string {
1170
1222
  const legs = (v: number) => (d.legLengths === null ? '' : ` = ${(d.legLengths * v / d.units).toPrecision(3)} leg lengths`);
1171
- return `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)} — a tighter slide bar needs a smaller --reduce`;
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)}`;
1172
1229
  }
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
- let worst = -1;
167
- let frame = 0;
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
- if (e > worst) {
173
- worst = e;
174
- frame = i;
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
- out.push({ bone: `${bone}:tip`, worst, frame, kind: 'foot', tolerance: ORIGIN_TOLERANCE + FOOT_TOLERANCE_PER_UNIT * lever });
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;