@rig-c/pose 0.41.0 → 0.42.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
@@ -141,10 +141,12 @@ A sequence carries x and y only, so nothing downstream can say which limb is nea
141
141
  | segment | leans | why |
142
142
  | --- | --- | --- |
143
143
  | thigh | forward | a hip flexes forward, and extends back too little to foreshorten past the band |
144
- | shin | forward in a walk, back in a run | the gait rule ([#281](https://github.com/rig-c/pose/issues/281)): a walk's lifted foot comes forward, a run's is kicked back. The knee bends forward in both, so the gait is the flag's value |
144
+ | shin | back | the owner's walk statement ([#336](https://github.com/rig-c/pose/issues/336)): the lifted foot is behind the supporting leg. A knee folds the shin back, in a walk and in a run |
145
145
  | upper arm | with the opposite leg | in a gait each arm swings with the other side's leg: forward when the other thigh is the more foreshortened, back when its own is, forward (a shoulder flexes forward) when neither is |
146
146
  | forearm | forward | an elbow bends toward the back of the body, so the forearm folds forward |
147
147
 
148
+ The thigh's row is the owner's run statement ([#336](https://github.com/rig-c/pose/issues/336)): the lifted leg is in front, its knee protruding. The table is the same for both gaits, so `--depth walk` and `--depth run` write the same signs; the gait the flag records decides which joint `--draw-order depth` orders the legs by ([below](#draw-order-from-depth---draw-order-depth)).
149
+
148
150
  The trunk faces the camera (forward is +) when its left shoulder and hip are drawn right of its right ones, away (forward is −) when they are drawn left, and is **side-on** when the shoulders and hips are drawn, on average, less than 0.1 trunk lengths (neck → mid-hip) wide: forward is then across the picture, so every sign on that frame is 0 — undecided, not guessed.
149
151
  - **`source`** is `"derived"`. `"measured"` is for a 3D detector, which may fill the same field: it is read, never written here. `clean` carries a measured depth through as it was read (`DEPTH measured depth … carried through`), and refuses `--depth` on one (`CLEAN_DEPTH`), since a derived reading would replace a measurement. A sequence carrying derived depth is refused without `--depth` (`CLEAN_DEPTH`): its depth was read off positions the clean moves.
150
152
 
@@ -154,7 +156,7 @@ The record says it: `"depth": { "gait" }` in `params`, and the `CLEAN` line ends
154
156
 
155
157
  **Layered** — `retarget` orders the limbs (`r_arm`, `l_arm`, `r_leg`, `l_leg`) front to back on every frame of a sequence that carries depth. Each joint's depth is its parent's plus its segment's, sign × rest × √(1 − ratio²) px (rest = drawn length ÷ ratio), from the shoulders and hips at 0; a limb's depth is the mean of its middle and end joints'; the nearest comes first, and equal depths (to 0.01 px) share a layer. The `DEPTH` line counts the frames ordered and which limb was in front alone how often, and the report carries `layers` ([below](#what-it-does)). Nothing is keyed from it unless `--draw-order depth` asks ([below](#draw-order-from-depth---draw-order-depth)): without the flag the motion is byte for byte the same with and without depth.
156
158
 
157
- On the synthetic fixtures ([`examples/walk/make-depth.ts`](https://github.com/rig-c/pose/blob/main/examples/walk/make-depth.ts): the walk's proportions, made in 3D from joint angles and drawn by a camera in front, behind or beside, so the generator knows every joint's true depth; `test/depth.test.ts`): on the frontal run in place, the 24 shin-frames drawn at ≤ 0.5 of rest (down to 0.409), four on every stride of each leg, read −1 under `--depth run`, as the generator draws them, and +1 under `--depth walk`; on the frontal walk, the 32 shin-frames the generator draws toward the camera read +1, and none reads −1; seen from behind, the run's kicked-back shins read +1; seen from the side, the 480 segment-frames read 0.995–1.000, every sign 0, and no segment is drawn warm or cool. `layers` agrees with the generator on 60 of the run's 60 frames and 44 of the walk's 48, two limbs counting as ordered when their true depths differ by at least the depth a limb can hold while both its segments read flat (√(1 − 0.95²) × (upper + lower ÷ 2): 31.2 px for an arm, 35.9 for a leg), and tied otherwise. The walk's four (frames 6, 18, 30, 42) are where both thighs read flat while the generator has the forward leg 36.4 px in front: the band hides it.
159
+ On the synthetic fixtures ([`examples/walk/make-depth.ts`](https://github.com/rig-c/pose/blob/main/examples/walk/make-depth.ts): the walk's proportions, made in 3D from joint angles and drawn by a camera in front, behind or beside, so the generator knows every joint's true depth; `test/depth.test.ts`): on the frontal run in place, the 24 shin-frames drawn at ≤ 0.5 of rest (down to 0.409), four on every stride of each leg, read −1 under `--depth run` and under `--depth walk`, as the generator draws them, and the two files differ only in the gait they record; on the frontal walk in place, the 28 shin-frames the generator draws away from the camera (the lifted foot behind) read −1, and none reads +1; seen from behind, the run's kicked-back shins read +1; seen from the side, the 480 segment-frames read 0.995–1.000, every sign 0, and no segment is drawn warm or cool. `layers` agrees with the generator on 60 of the run's 60 frames and 48 of the walk's 48, two limbs counting as ordered when their true depths differ by at least the depth a limb can hold while both its segments read flat (√(1 − 0.95²) × (upper + lower ÷ 2): 31.2 px for an arm, 35.9 for a leg), and tied otherwise.
158
160
 
159
161
  ### Draw order from depth: `--draw-order depth`
160
162
 
@@ -162,13 +164,19 @@ On the synthetic fixtures ([`examples/walk/make-depth.ts`](https://github.com/ri
162
164
  rig-pose retarget --rig rig/ --map map.json --sequence run.pose.json --out motion.json --name run --draw-order depth
163
165
  ```
164
166
 
165
- With `--draw-order depth` (off by default; without it nothing changes) `retarget` keys the legs' draw order from the sequence's depth ([#304](https://github.com/rig-c/pose/issues/304)), so a run's kicked-back leg is drawn behind the standing one and a walk's forward foot in front. It writes rig-c's draw-order timeline, `"drawOrder": [{ "t", "offsets": [{ "slot", "offset" }] }, …]` on the animation beside its `tracks`: each key moves slots against the setup order, and a key with no `offsets` is the setup order again.
167
+ With `--draw-order depth` (off by default; without it nothing changes) `retarget` keys the legs' draw order from the sequence's depth ([#304](https://github.com/rig-c/pose/issues/304)), so a walk's lifted foot is drawn behind the supporting leg and a run's lifted leg, its knee forward, in front ([#336](https://github.com/rig-c/pose/issues/336)). It writes rig-c's draw-order timeline, `"drawOrder": [{ "t", "offsets": [{ "slot", "offset" }] }, …]` on the animation beside its `tracks`: each key moves slots against the setup order, and a key with no `offsets` is the setup order again. A key's `t` is half a frame before the frame it shows on (0 on the first frame), as the switches' attachment keys are: a key exactly on a frame time is a tie, and the render breaks it a frame late on about half of them ([#325](https://github.com/rig-c/pose/issues/325)).
166
168
 
167
169
  - **The pair.** The legs: a leg is the map's chains naming its knee or ankle, their bones and the bones under them no other chain maps. Its slots are the slots on those bones. A slot of an alternate set (`alternates`) on none of them goes with the leg the set depicts, so a set keeps its own switch and takes the order of the leg it replaces. The legs are keyed when their slots overlap in the draw order, filling one unbroken run of the setup order. A slot of anything else drawn between them would be re-layered by the swap, and the setup order is not changed here. The pair is then not keyed, and the line names that slot. The arms across the trunk are a later card.
168
- - **The rule.** On each frame the leg whose depth (`layers`, above) is the lower is drawn behind: its slots first in the pair's run, in their setup order, then the other's. The depths must differ by at least #281's tie, √(1 − 0.95²) × (thigh + shin ÷ 2) of the rest lengths (35.89 px on the fixtures). Inside the tie, or where a leg is not ordered, the order before holds. Before the first decided frame it is the setup order, or on a looped sequence the order the clip ends in. A key is written where the order changes.
170
+ - **The rule.** On each frame the leg whose depth is the lower is drawn behind: its slots first in the pair's run, in their setup order, then the other's. The depths must differ by at least a tie, the depth a flat chain can hold there. Inside the tie, or where a leg is not ordered, the order before holds. Before the first decided frame it is the setup order, or on a looped sequence the order the clip ends in. A key is written where the order changes.
171
+ - **The joint the gait names** ([#336](https://github.com/rig-c/pose/issues/336), the owner's two statements). On a sequence cleaned with `--depth walk` a leg's depth is its ankle's (the hip at 0, plus the thigh's, plus the shin's), the tie √(1 − 0.95²) × (thigh + shin) of the rest lengths (46.83 px on the fixtures): in a walk the lifted foot is behind the supporting leg. With `--depth run` it is its knee's (the thigh's), the tie √(1 − 0.95²) × thigh (24.97 px): in a run the lifted leg is in front, its knee protruding. The limb mean (`layers`, above) says neither: a lifted leg's thigh forward and shin back cancel in it, and a walk whose thighs stay inside the band leaves it at most half a shin (35 px on the fixtures), under its tie of √(1 − 0.95²) × (thigh + shin ÷ 2) (35.89 px). A measured depth names no gait and keeps the limb mean and its tie ([#304](https://github.com/rig-c/pose/issues/304)).
169
172
  - **No depth.** On a sequence without depth the flag is refused (`DRAWORDER_NO_DEPTH`): the order would come from nowhere, and a motion written without it would read as ordered.
173
+ - **A walk cleaned before #336** ([#336](https://github.com/rig-c/pose/issues/336)). A derived depth whose foreshortened shin leans the way the trunk faces carries the sign table before #336, where a walk's lifted foot read forward; the ankle would draw that foot in front. The flag refuses it by name (`DEPTH_RECLEAN`), listing the frames, until it is cleaned again with this version. The table since leans the shin back in both gaits, so a record it wrote never matches. A run's signs did not change, so a run cleaned by an earlier release is read as it is, and so is a walk with no foreshortened shin (its signs read the same under both tables). Measured depth is not checked. The file format does not change.
174
+
175
+ On #281's fixtures, retargeted onto a limb-cut rig whose legs overlap (the left leg drawn first; `test/draworder.test.ts`):
170
176
 
171
- On #281's fixtures, retargeted onto a limb-cut rig whose legs overlap (the left leg drawn first; `test/draworder.test.ts`): on the frontal run the order changes on frames 8, 18, … 58, within a frame of each change of support (the last within two of the loop's seam). On each of the 53 frames where the swinging shin reads away, that leg is drawn behind. On frame 12 none of the 8,763 pixels the standing leg draws shows the kicked-back leg, against 2,192 in the setup order. On the walk, each of the 32 shin-frames read toward draws that leg in front. The tie costs 18 of the 36 run frames the generator orders: the late swing (frames 5–7, 15–17, …), where the folding knee comes forward of the push-off leg by more than the tie in 3D but by 22.7–31.7 px in the derived depth, so the order holds. Without the tie the run would change order mid-swing (frames 3, 13, …), and 30 of the 53 kicked-back frames would draw the kicked-back leg in front.
177
+ - **The walk** (by the ankle): each of the 36 frames with one leg lifted draws that leg behind; the order changes on frames 3, 15, 27 and 39, where a lifted foot first goes back past the tie, and holds through the double support between. The generator orders the true ankles on 16 of the 48 frames, and the keyed order agrees on all 16.
178
+ - **The run** (by the knee): the order changes on frames 2, 12, … 52. Of the 42 frames with one leg lifted, the 30 whose lifted knee protrudes (its thigh leaning forward past the band) draw that leg in front. The other 12 (frames 0–1, 10–11, …) are the early swing: the lifted thigh is still inside the band, the generator's knees are within the tie, and the order holds — the landing leg in front, as the generator's ankles have it. The generator orders the true knees on 42 of the 60 frames, and the keyed order agrees on all 42. On frame 12, on a rig that draws the right leg first, none of the 8,763 pixels the lifted right leg draws shows the standing leg, against 2,192 in the setup order.
179
+ - **Read otherwise**: by the limb mean or by the ankle, the run draws the lifted leg behind on all 30 frames whose knee protrudes; by the limb mean or by the knee, the walk keys nothing (the setup order) and draws the lifted right leg in front on its 18 frames. Knees planted 10 px apart, inside the knee tie, hold the order before; without the tie they swap it.
172
180
 
173
181
  ## What it does
174
182
 
@@ -220,7 +228,7 @@ and what the run cannot decide for you it lists:
220
228
  - `REACH` — with `--raised-by-head`, or a `"by-hip"` set: a placed span whose target was out of the arm's reach, too far or too near. It names the frames, which way, and the shortfall in eye distances (hip widths for `"by-hip"`). The wrist was placed at the reach limit instead. This is not `ART-RANGE`: the rig's proportions were the limit, not the art's range. A too-near frame is placed at the limit like a too-far one, and graded at the placed distance.
221
229
  - `LEG-SCALE` — with `--leg-scale` ([below](#the-legs-foreshortening-and-planted-feet)): per leg segment, its straight length in px and as a share of `FEET`'s leg length, the spread of its picture length over it, the limits in px, the frames clamped at each limit, where its leg was free on clamped frames how far the clamps on that leg left the free ankle above or below the picture's (in the rig's leg at setup, thigh origin → ankle, and in `FEET`'s px; `legScale[].free` in the report, [#237](https://github.com/rig-c/pose/issues/237)), and (with `--plant-feet`) one `past the floor (support)` line per supporting run that asked under the floor ([#133](https://github.com/rig-c/pose/issues/133), `legScale[].support` in the report).
222
230
  - `DEPTH` — when the sequence carries depth ([above](#derived-depth-clean---depth)): where it came from, on how many frames the limbs were ordered front to back, and which limb was in front alone how often — `DEPTH layers from the sequence's derived depth (clean --depth run): the limbs ordered front to back on N of N frame(s); in front alone: r_arm a, l_arm b, r_leg c, l_leg d; tied in front on k (the report's layers)`.
223
- - `DRAWORDER` — with `--draw-order depth` ([above](#draw-order-from-depth---draw-order-depth)): the keys and the spans they key, per leg drawn behind, cut to 260 characters — `DRAWORDER from depth, 6 key(s) over 3 span(s) (leg_r behind leg_l 8–17, 28–37, 48–57)` — and a line per pair not keyed, saying why.
231
+ - `DRAWORDER` — with `--draw-order depth` ([above](#draw-order-from-depth---draw-order-depth)): the keys and the spans they key, per leg drawn behind, cut to 260 characters — `DRAWORDER from depth by the knee (run; tie 24.97 px), 6 key(s) over 3 span(s) (leg_r behind leg_l 2–11, 22–31, 42–51)`, naming the joint and its tie for a keyed pair ([#336](https://github.com/rig-c/pose/issues/336)) — and a line per pair not keyed, saying why.
224
232
  - `PELVIS` — when the map has a pelvis chain (one bone, joints `r_hip → l_hip`) or the sequence a pelvis track ([above](#the-pelvis-track-rig-pose-pelvis)): where the pelvis bone's rotation came from and its swing — `PELVIS: from <source>, swing N°`, or `PELVIS: from hip line (no track)` — with the frames keyed from the hip line on a track that declined them.
225
233
  - `PIVOT` — when the map translates a bone and has both legs: the translated bone's origin against its thigh roots' midpoint at setup, in units and hip widths — the pivot every hip turn swings the legs about.
226
234
  - `FEET` — when the map has both legs: one `segment` line per mapped leg segment with its picture length ÷ straight length (min · median · max), the scale `--leg-scale` would ask before its clamp, printed with or without the flag so the minimum can be read before choosing the range ([#237](https://github.com/rig-c/pose/issues/237)); the supports and jumps read off the reference ankles (planted or not), and on the posed build, outside the jumps, the lowest ankle's vertical range and each support span's worst vertical range and horizontal drift of the supporting ankle, in leg lengths.
@@ -244,7 +252,7 @@ and what the run cannot decide for you it lists:
244
252
  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.
245
253
 
246
254
  `--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`; `reduceChains` only with a per-chain `--reduce`), `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`, `pelvis` (the `PELVIS` reading, [above](#the-pelvis-track-rig-pose-pelvis): `{ source, trackSource, bone, chain, swing, track, hipLine, declined }` — `source` the track's or `"hip line"`, `trackSource` the sequence's `pelvisSource` or `null`, `swing` the peak-to-peak of the bone's rotate keys in degrees, `track` and `hipLine` the frames each keyed, `declined` the frames the track declined as `[first, last]` pairs; `null` when the map has no pelvis chain and the sequence no track), `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:
247
- With a sequence that carries depth ([#281](https://github.com/rig-c/pose/issues/281), [above](#derived-depth-clean---depth)) the report also carries `layers`, one `{ frame, t, order, depth }` per frame: `order` the limbs front to back as layers, each an array of the limbs that share it (`[["r_arm"], ["l_arm", "r_leg"], ["l_leg"]]`), and `depth` each ordered limb's depth in px, + toward the camera. With `--draw-order depth` ([#304](https://github.com/rig-c/pose/issues/304)) it carries `drawOrder`: `{ source: "depth", pairs, keys, spans }`. `pairs` gives per pair `{ a, b, limbs, slots, keyed, tie }` (and `why` when not keyed), `keys` the timeline with each key's `frame`, and `spans` every span of one order `{ behind, front, frames, keyed }` (`keyed` false where the setup order already draws it so). The options carry `drawOrder: "depth"`. A run without the flag writes neither. A sequence without depth writes no `layers` key, and its report is what it was.
255
+ With a sequence that carries depth ([#281](https://github.com/rig-c/pose/issues/281), [above](#derived-depth-clean---depth)) the report also carries `layers`, one `{ frame, t, order, depth }` per frame: `order` the limbs front to back as layers, each an array of the limbs that share it (`[["r_arm"], ["l_arm", "r_leg"], ["l_leg"]]`), and `depth` each ordered limb's depth in px, + toward the camera. With `--draw-order depth` ([#304](https://github.com/rig-c/pose/issues/304)) it carries `drawOrder`: `{ source: "depth", pairs, keys, spans }`. `pairs` gives per pair `{ a, b, limbs, slots, keyed, tie }` (`by`, `"knee"` or `"ankle"`, when the sequence's gait names the joint the pair was read at, [#336](https://github.com/rig-c/pose/issues/336), the tie then that joint's; and `why` when not keyed), `keys` the timeline with each key's `frame` (the frame it first shows on; `t` there is that frame's time, as everywhere in the report, and the motion's key sits half a frame before it), and `spans` every span of one order `{ behind, front, frames, keyed }` (`keyed` false where the setup order already draws it so). The options carry `drawOrder: "depth"`. A run without the flag writes neither. A sequence without depth writes no `layers` key, and its report is what it was.
248
256
 
249
257
  With a map that has a `reach` chain ([#282](https://github.com/rig-c/pose/issues/282)) the report also carries `reachOrigin`, one `{ chain, bone, joints, anchor, setup, posed: { max, frame, t }, segment, ofSegment, warn, threshold }` per reach chain in map order: the `REACH-ORIGIN` line's figures. `setup` and `posed.max` are the distance from the bone's origin to the translated bone's (where the rig puts the source joint), in rig units; `segment` the reached length; `ofSegment` the larger distance ÷ `segment`; `warn` whether it is past `threshold` (0.2). A map without one writes no `reachOrigin` key.
250
258
 
@@ -339,6 +347,7 @@ rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inh
339
347
 
340
348
  - The supporting foot is the one whose reference ankle is lower in the picture. It changes only when the other ankle has been at least **0.02 leg lengths** lower for at least **0.1 s** (timed as `END-ON` runs are), and once confirmed takes support from the frame it crossed that margin ([#102](https://github.com/rig-c/pose/issues/102)); a leg length is each side's median picture length hip → ankle, and the switch compares against their mean.
341
349
  - **Frontal views** ([#280](https://github.com/rig-c/pose/issues/280)). Seen from the front, a foot's depth is drawn as height: the nearer foot is the lower one, and it may be the one in the air. So every clip is also read by a second signal, the ankle's **vertical speed**: a planted foot moves with the ground — still under a fixed camera, or sliding back at the ground's speed in a gait drawn in place. Each side's ground speed is the median of its ankle's vertical step over the frames outside every jump (a walking foot is down on more than half of them), and a foot is down on a frame when the step into it or the step out of it departs from that speed by less than **0.01 leg lengths** (the re-plant's stillness); both feet can be down. The two signals **contradict** on a frame where the lower ankle (by 0.02 leg lengths) moves while the higher moves with the ground. A clip is read as frontal when that happens on more than **5 %** of the frames outside the jumps **and** its stride shows in depth: on each side the ankle's range against its hip (5th to 95th percentile) is larger in y than in x — a side view's stride is in x, and its stance ankle rises and falls with the leg's angle, so there the vertical speed does not decide. 5 % sits below the shortest contradiction a frontal walk makes (one frame a step: 2 of the 24 frames of a 1 s cycle, 8.3 %) and above every fixture the tests read in depth (0 %); the tests' synthetic frontal walk contradicts on 18 of 96 frames (18.8 %). On a frontal clip each frame's support is the vertical speed's: the one foot down; on frames both are down, the foot that landed, so support passes on the frame it lands (as #102 takes it from the landing frame); ankle height's only where neither foot moves with the ground. A plant then ends where the other foot lands, so no step releases one and #113's and #119's readings are not made. On a frontal clip `FEET` prints two `support:` lines — the frames contradicted and their share against 5 %, then the ankle ranges — and one `stride` line per support span: the signal that decided its frames, the frames both feet were down, and what ankle height alone would have read there, e.g. `stride l 8–15: ankle vertical speed on 8 frame(s) (both feet down on 3); ankle height alone would read r on 2 of them`. On any other clip ankle height decided as before and nothing is printed, in any output mode, so such a run's output is as it was. On the tests' synthetic frontal walk (an 8-drawing cycle on twos interpolated to 24 fps, both feet down on 4 of the 8 drawings, the nearer foot lower, the ankles moving 17 and 23 px in y) the feet read down are its contact table on all 96 frames, where ankle height alone supported a foot in the air on 16. The third signal the card names, the sole's distance to a floor line, is not read: no floor line in the picture can be declared (`--floor` is a world y, placed from the support it would have to decide).
350
+ - **Contact keys, by hand** ([#303](https://github.com/rig-c/pose/issues/303), the route for drawn references that [#313](https://github.com/rig-c/pose/issues/313) asked for). A drawn frontal walk can defeat every reading above: its nearer foot is drawn lower, so ankle height names the foot in the air, and a slow walk can step under the vertical speed's 0.01-leg-length stillness, so #280 never reads it as frontal. A single floor line in the picture does not help either: seen from the front the ground is a band, not a line (#303's reading). The artist's key sheet already carries the contact table, so `--contact-keys <file>` takes it: `rig-pose-contact-keys/1`, `{ "spec": "rig-pose-contact-keys/1", "keys": [ { "frame", "side" } ], "note"? }`, `side` one of `l`, `r`, `both`, `none`. Frames are the sequence's, may be fractional (a key sheet timed off the frame grid, as `--pelvis-keys`, #294), and strictly increase within `[0, frames − 1]`. **A key holds until the next one**, and the last to the clip's end: contact is a state, not an angle, so there is nothing between `l` and `r` to interpolate. A fractional key takes effect on the first frame at or after it; frames before the first key are not covered and keep the reading without keys. **The precedence is contact keys, then the vertical speed (#280, on a frontal clip), then ankle height**: the keys are applied last, over whatever the readings decided, on every frame they cover. `l` or `r` is that foot; `none` is flight, no foot supports; `both` is the foot that landed, as #280 hands over on landing: the other side from the `l` or `r` key before the run, or, where the run starts the cover or follows a `none` key (flight), the side the next `l` or `r` key names (the side that stays down after). Those neighbours are read from the keys, so a key that reaches no whole frame still counts: `l@0, none@1.1, both@1.2, l@3` holds `l` on frame 2 (rig-c/pose#338). The support spans and changes are then rebuilt from the per-frame support, as #280's are; a step, #113's `lowered` or #119's `tracked` run is kept only where it starts before the cover, cut at the frame before it (a step's re-plant inside the cover is not read), and jumps are kept as read. `FEET` prints one `support: contact keys` line (the keys, the frames they cover, how many frames they changed, the flight they declare, the frames before the first key) and one `stride` line per support span naming what decided its frames and what the reading without keys would have named, e.g. `stride l 19–37: of 19 frame(s), decided by contact keys 19; the reading without keys would name r on 4 of them`; these stride lines stand in for #280's. The report gains a `contactKeys` key (the file's path and SHA-256, the key count, the first covered frame, the runs changed) and `options.contactKeys`, only with the flag. On the tests' slow synthetic frontal walk (8 drawings a stride, each held 4.75 frames at 24 fps, keyed from its third drawing on), the support equals the keys on all 142 covered frames, where the reading without keys misses them on 16; the 10 frames before the first key keep that reading (`test/contactkeys.test.ts`). A malformed file, a `side` outside the four, a frame outside the span or one not after the key before it is refused by name (`CONTACT_KEYS`), every problem in one run, and so are keys on a map whose legs give no support to decide. Without the flag nothing is read and the output is as it was.
342
351
  - The new support is held where it is on the frame it takes over, and the hip blends in y from the old derivation to the new over **3 frames** while taking the new derivation's x immediately ([#102](https://github.com/rig-c/pose/issues/102)), so the planted foot does not slide across the blend.
343
352
  - A **jump** — both reference ankles **and** mid_hip more than **0.05** leg lengths above their **rolling floor**, the 95th-percentile picture y over ±1.0 s around the frame, for at least **2 frames**, starting from the first immediately preceding push-off frame where both ankles are already above their rolling floor while mid_hip lags ([#142](https://github.com/rig-c/pose/issues/142)) — releases the plant: the hip follows the pose's travel from where it was, and re-plants on landing (blended the same way). At a walking hand-over the front foot has not landed yet while the back ankle lifts its heel and the hip rises 0.051–0.065 leg lengths; when the front foot lands while the back ankle stays above its floor, a back ankle that holds its height in y (< **0.01** leg lengths a frame for **2 frames**) is on a raised heel rather than in flight and is not read as a jump ([#101](https://github.com/rig-c/pose/issues/101)). The body has to lift: an ankle's picture y drifts with depth, and under one floor for the whole sequence the subject in `dance-a` standing still (frames 0–29 and 277–312, mid_hip moving 10 and 14 px) read as jumping, 152–155 of 313 frames in all.
344
353
  - **In place** ([#92](https://github.com/rig-c/pose/issues/92)). On a sequence with `"travel": "in-place"`, the supporting foot of a figure that does not travel moves back in the picture while the body stays. Holding it where it planted turns that slide into the body's travel, so the hip walks off and never comes back. Under the declaration each plant's anchor follows its own ankle's picture x, scaled into the rig, from the frame it planted, and keeps its y on the one floor. No step releases a foot, since a foot that follows its track has no plant to drag. Every foot may take support, and the lower one does, by the rules above. The loop is then closed: the hip's x on frame i moves by −drift · i / (n − 1), where drift is its x on the last frame less its x on the first. The last frame's hip x is then the first's, and the loop carries no net travel. The y is the floor's and is not touched. `FEET` prints the drift closed, in place of the steps line, and the report's `feet.closure` carries `{ drift, frames }` (`null` otherwise). On the tests' synthetic in-place loop (40 frames, each supporting ankle sliding back 2 px a frame, the hips still), the hip moves less than 10⁻⁹ px. The same joints without the declaration put it 68 px forward over the loop. With a slide of 0.04 leg lengths a frame, both feet are released as stepping and never re-plant without the declaration, and the supports alternate with it. Jumps are read as before. **What planting is and is not needed for** ([#242](https://github.com/rig-c/pose/issues/242)): the declaration is the sequence's, so the support reading (no step releases a foot), the camera-zoom normalisations and the head nod (no detrend window, no clamp) take it with or without `--plant-feet`. Planting is needed only for what acts on a planted hip: the anchor following its ankle's track and the loop closure, since without a plant there is no anchor to follow and the hip is the pose's own travel. Without `--plant-feet` the run says so in a `TRAVEL` line: `sequence declares travel: in-place but --plant-feet is off — its supports and the head nod are read in place as with it; the loop closure is --plant-feet's (it closes the planted hip), so the hip follows the pose's own travel: x +0.00 units from the first frame to the last, not closed` (the figure is the hip's measured x drift; `and the map translates no bone` when nothing is translated). On the tests' synthetic walk in place (7 cycles of 24 frames, the supporting ankle sliding up to 0.09 leg lengths a frame, the eyes bobbing 3 px twice a cycle), without `--plant-feet` the supports are 15 spans covering all 168 frames (the seam splits the first) and the head's rotate key and nod are the same on cycle 1 and cycle 7 to 0.001; reading the declaration only under `--plant-feet`, as before, they were 2 spans covering 3 frames, and the head's cycle 7 differed from cycle 1 by 1.057° and 0.661 units. That head figure came from the nod's detrend window cut at the clip's ends, which [#241](https://github.com/rig-c/pose/issues/241) wraps on a loop, so it no longer reproduces under that reading either.
@@ -458,7 +467,7 @@ Which bones carry which joints. You write it for your rig:
458
467
  ⚠️ **Seen from the front, an elbow's side is not a joint limit.** An elbow flexes toward the camera, and its picture falls on either side of the upper arm depending on how the arm is turned. On a real 13 s front-facing dance (`dance-a`, 313 frames, RTMPose), the right elbow sat on the "wrong" side of the rig's rest on 165 frames (median 23°), and the left on 200 (median 29°, up to 179° with the hands behind the head). Every one was a real elbow bend. The knees strayed at most 8.8° and 14.7°, which is pose noise around a straight leg. So for front-facing material, arms go `"free"`. Legs on `"rest"` with `--bends clamp` hold only while the knees stay near straight. On `dance-b` (a second dance with deeper knee bends, rig-c/pose#27), that setting straightened the left knee for most of the dance: `leg_l1` was 48.3° off at p90, against 0.0–0.3° with free legs. Leave legs `"free"` unless the dance keeps its knees straight.
459
468
  - `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
460
469
  - `translate.scale` is one length measured on both: two points on the rig, two points in the pose (its median over the frames where both are good). Pick a long segment that does not foreshorten much.
461
- - `face` (optional, [#138](https://github.com/rig-c/pose/issues/138), [#164](https://github.com/rig-c/pose/issues/164), [#173](https://github.com/rig-c/pose/issues/173), [#189](https://github.com/rig-c/pose/issues/189), [#190](https://github.com/rig-c/pose/issues/190), [#205](https://github.com/rig-c/pose/issues/205)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15], "sets"?: [{ "set": "T1", "above": 45 }] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] }, "sets"?: [{ "set": "T1", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Reads per-frame yaw and pitch from `nose` against `r_eye + l_eye` (and `r_ear + l_ear` when present — where the eye span is foreshortened below `0.85 × median(de)` and the ear-chord offset `xea = (mid_ears − mid_eyes) · u / da` exceeds both `1.8 × |xne|` and `0.15`, yaw blends toward the ear chord `atan(2 × xea)` with weight `w = clamp((0.85 × median(de) − de) / ((0.85 − 0.80) × median(de)), 0, 1)` so a side gaze whose 2D nose sits near the eye midpoint reaches `atan(2 × xea)` below `0.80 × median(de)` and exits without a step), couples the head nod channel's clamped vertical travel (`clampedY`, `+2.8°/unit` when the `±2%` neck-length clamp acts on the sequence) into `pitch` in phase ([#190](https://github.com/rig-c/pose/issues/190), [#205](https://github.com/rig-c/pose/issues/205)), maps 1:1 inside `0.8 × range` (`[-12, 12]°` on `[-15, 15]°`) and compresses only the overshoot above the knee into the remaining 20% of the range so a single outlier frame does not crush the rest of the clip, and keys the named dial bone's `rotate` track (`FACE`). When `sets` (`[{ "set": "<name>", "above": <deg> }]`, `0 < above ≤ 90`) is declared on `face.yaw.sets` or `face.sets`, each set enters on frames where `|yaw| ≥ above` (or the highest `above` satisfied when multiple sets are declared) and leaves only when `|yaw| < above − 10°` (`10°` hysteresis), emitting stepped slot `attachment` keys for the set's `slots` and `hides` (resolved from `alternates` in the map or `sets.json` (`rig-parts-sets/1`) beside `rig.json`; an unknown set is refused by name as `MAP_SET_EXISTS`).
470
+ - `face` (optional, [#138](https://github.com/rig-c/pose/issues/138), [#164](https://github.com/rig-c/pose/issues/164), [#173](https://github.com/rig-c/pose/issues/173), [#189](https://github.com/rig-c/pose/issues/189), [#190](https://github.com/rig-c/pose/issues/190), [#205](https://github.com/rig-c/pose/issues/205)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15], "sets"?: [{ "set": "T1", "above": 45 }] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] }, "sets"?: [{ "set": "T1", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Reads per-frame yaw and pitch from `nose` against `r_eye + l_eye` (and `r_ear + l_ear` when present — where the eye span is foreshortened below `0.85 × median(de)` and the ear-chord offset `xea = (mid_ears − mid_eyes) · u / da` exceeds both `1.8 × |xne|` and `0.15`, yaw blends toward the ear chord `atan(2 × xea)` with weight `w = clamp((0.85 × median(de) − de) / ((0.85 − 0.80) × median(de)), 0, 1)` so a side gaze whose 2D nose sits near the eye midpoint reaches `atan(2 × xea)` below `0.80 × median(de)` and exits without a step), couples the head nod channel's clamped vertical travel (`clampedY`, `+2.8°/unit` when the `±2%` neck-length clamp acts on the sequence) into `pitch` in phase ([#190](https://github.com/rig-c/pose/issues/190), [#205](https://github.com/rig-c/pose/issues/205)), maps 1:1 inside `0.8 × range` (`[-12, 12]°` on `[-15, 15]°`) and compresses only the overshoot above the knee into the remaining 20% of the range so a single outlier frame does not crush the rest of the clip, and keys the named dial bone's `rotate` track (`FACE`). When `sets` (`[{ "set": "<name>", "above": <deg> }]`, `0 < above ≤ 90`) is declared on `face.yaw.sets` or `face.sets`, each set enters on frames where `|yaw| ≥ above` (or the highest `above` satisfied when multiple sets are declared) and leaves only when `|yaw| < above − 10°` (`10°` hysteresis), emitting stepped slot `attachment` keys for the set's `slots` and `hides`, each half a frame before the frame it shows on as the switches' and the draw order's keys are ([#334](https://github.com/rig-c/pose/issues/334)) (resolved from `alternates` in the map or `sets.json` (`rig-parts-sets/1`) beside `rig.json`; an unknown set is refused by name as `MAP_SET_EXISTS`).
462
471
  - `body` (optional, [#173](https://github.com/rig-c/pose/issues/173)): `{ "yaw"?: { "bone": "body_yaw", "range": [-30, 30], "sets"?: [{ "set": "T8", "above": 45 }] }, "sets"?: [{ "set": "T8", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Maps the per-frame body yaw read from the camera-normalised shoulder and hip width (`BODY`) into `range` with the same `0.8 × range` knee mapping as `face`, keying the named dial bone's `rotate` track, and switches any declared `sets` (`body.sets` or `body.yaw.sets`) with the same `above` / `above − 10°` hysteresis (overriding `face` sets and arm `alternates` on slots they share while active). Without `body` in the map, `BODY` and `--report`'s `body` still report the reading while leaving every track in `motion.json` unchanged.
463
472
 
464
473
  ### A joint's range: `range`
package/cli.ts CHANGED
@@ -82,8 +82,10 @@ clean (required before retarget; rig-c/pose#67)
82
82
  (the trunk side-on). Which way a foreshortened segment leans comes from the body, with
83
83
  "forward" the trunk's facing: thigh and forearm forward (a hip flexes forward, an elbow
84
84
  bends back); an upper arm with the opposite leg (forward when the other thigh is the more
85
- foreshortened); a shin forward in a walk (the lifted foot comes forward) and back in a run
86
- (it is kicked back) — the gait is this flag's value. tools/skeleton.ts colours the segments
85
+ foreshortened); a shin back (a knee folds it back) — the same in a walk and a run, after
86
+ the owner's two statements (rig-c/pose#336): a walk's lifted foot is behind the supporting
87
+ leg, a run's lifted leg is in front, its knee protruding. The gait is this flag's value and
88
+ decides the joint --draw-order depth reads. tools/skeleton.ts colours the segments
87
89
  by it, and retarget --report orders the limbs front to back by it (layers)
88
90
 
89
91
  In order, each step printed with its figures: (1) spikes — a joint more than 0.2 trunk lengths
@@ -191,10 +193,20 @@ retarget
191
193
  (the shortfall listed). RAISED lines (default: off; rig-c/pose#62)
192
194
  --draw-order depth key the legs' draw order from the sequence's depth (clean --depth; rig-c/pose#304): on
193
195
  each frame the leg whose depth reads farther from the camera is drawn behind the other,
194
- a tie (equal depths) holding the order before, in rig-c's drawOrder timeline. The legs'
196
+ a tie (equal depths) holding the order before, in rig-c's drawOrder timeline. The depth is
197
+ read at the joint the gait names (rig-c/pose#336): a walk (clean --depth walk) by the ankle,
198
+ the lifted foot behind; a run by the knee, the lifted leg in front. The legs'
195
199
  slots must fill one unbroken run of the setup draw order (else the pair is not keyed and
196
200
  the line names the slot between); a set's slot goes with the leg it depicts. Refused on
197
- a sequence without depth (DRAWORDER_NO_DEPTH). DRAWORDER lines (default: off)
201
+ a sequence without depth (DRAWORDER_NO_DEPTH), and on a walk cleaned before rig-c/pose#336
202
+ (a shin leaning forward: DEPTH_RECLEAN; clean it again). DRAWORDER lines (default: off)
203
+ --contact-keys <file> which foot supports, by hand (rig-c/pose#303): rig-pose-contact-keys/1, { "spec":
204
+ "rig-pose-contact-keys/1", "keys": [{ "frame", "side" }], "note"? }, side l | r | both |
205
+ none; frames the sequence's, fractional allowed, strictly increasing in [0, N − 1]. A key
206
+ holds until the next one (contact is a state, not interpolated); before the first key the
207
+ support is read as without it. On every frame they cover the keys decide, over the
208
+ vertical speed (#280) and ankle height: none is flight; both, the foot that landed. Malformed
209
+ keys are refused by name (CONTACT_KEYS). FEET support and stride lines (default: off)
198
210
  --idle <name> the rig's animation read as its idle (default: idle). Every idle track the new
199
211
  animation does not key, on any channel, is listed as IDLE-DROPPED (a blink, a
200
212
  breath); one it keys is IDLE-KEPT-OUT, naming the track that keys it, and so is a
@@ -383,7 +395,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
383
395
  const RETARGET_ALIASES = { sequence: 'pose', animation: 'name' } as const;
384
396
 
385
397
  function retargetArgs(argv: string[]): RetargetArgs {
386
- 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', 'reference', 'render', 'parts', 'report', 'camera', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip', 'stance', 'floor', 'draw-order'], ['coco', 'plant-feet', 'carry-idle', 'raised-by-head', 'reduce-keep-planted', 'verbose', 'summary'], RETARGET_ALIASES);
398
+ 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', 'reference', 'render', 'parts', 'report', 'camera', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip', 'stance', 'floor', 'draw-order', 'contact-keys'], ['coco', 'plant-feet', 'carry-idle', 'raised-by-head', 'reduce-keep-planted', 'verbose', 'summary'], RETARGET_ALIASES);
387
399
  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');
388
400
  if (f.has('reference') && !f.has('build')) throw new Usage(`--reference needs --build: the record it goes in is written beside the build (${PROVENANCE_FILE})`);
389
401
  const bends = f.get('bends') ?? 'refuse';
@@ -433,6 +445,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
433
445
  ...reduceOf(f),
434
446
  output: f.has('verbose') ? 'verbose' : f.has('summary') ? 'summary' : 'default',
435
447
  ...(drawOrder !== undefined ? { drawOrder: 'depth' as const } : {}),
448
+ ...(f.has('contact-keys') ? { contactKeysFile: f.get('contact-keys')! } : {}),
436
449
  };
437
450
  }
438
451
 
@@ -558,7 +571,7 @@ function runClean(argv: string[], say: (l: string) => void): void {
558
571
  const input = need(f, 'pose');
559
572
  const out = need(f, 'out');
560
573
  const gait = f.get('depth');
561
- if (gait !== undefined && !(DEPTH_GAITS as readonly string[]).includes(gait)) throw new Usage(`--depth is "${gait}"; it must be ${DEPTH_GAITS.join(' or ')} (the gait decides which way a foreshortened shin leans: forward in a walk, back in a run)`);
574
+ if (gait !== undefined && !(DEPTH_GAITS as readonly string[]).includes(gait)) throw new Usage(`--depth is "${gait}"; it must be ${DEPTH_GAITS.join(' or ')} (the gait decides the joint --draw-order depth orders the legs by: the ankle in a walk, the knee in a run)`);
562
575
  const outPath = new Problems();
563
576
  const outDirs = planOutDirs([['--out', out]], outPath);
564
577
  outPath.throwIfAny();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.41.0",
3
+ "version": "0.42.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/clean.ts CHANGED
@@ -116,9 +116,10 @@ export interface CleanParams {
116
116
  */
117
117
  hold?: { speed: number; reach: number } | 'off';
118
118
  /**
119
- * rig-c/pose#281: derive each frame's depth (`src/depth.ts`) for this gait, on
120
- * the cleaned positions. Absent, no depth is derived and the output is what it
121
- * was before #281.
119
+ * rig-c/pose#281: derive each frame's depth (`src/depth.ts`) on the cleaned
120
+ * positions, and record this gait (rig-c/pose#336: it decides the joint the
121
+ * draw order reads, not a sign). Absent, no depth is derived and the output is
122
+ * what it was before #281.
122
123
  */
123
124
  depth?: DepthGait;
124
125
  }
@@ -841,7 +842,7 @@ export function clean(seq: PoseSequence, params: CleanParams, wanted: string | u
841
842
  // rig-c/pose#281: derived on the cleaned positions, or a measured depth carried through as read.
842
843
  let depth: DerivedDepth | null = null;
843
844
  if (params.depth !== undefined) {
844
- depth = deriveDepth(frames.map((f) => f.people[0]?.joints ?? {}), params.depth);
845
+ depth = deriveDepth(frames.map((f) => f.people[0]?.joints ?? {}));
845
846
  depth.frames.forEach((d, i) => (frames[i]!.depth = d));
846
847
  record.params.depth = { gait: params.depth };
847
848
  } else if (carried === 'measured') seq.frames.forEach((f, i) => (frames[i]!.depth = f.depth ?? {}));
@@ -0,0 +1,250 @@
1
+ /**
2
+ * `--contact-keys` (rig-c/pose#303): the support declared by hand, frame by
3
+ * frame, for a clip no reading gets right — a drawn frontal walk. Seen from the
4
+ * front, depth is drawn as height, so the ground is a band in the picture and
5
+ * the nearer foot is drawn lower (#303's reading: no single floor line matches a
6
+ * contact table); a slow drawn walk can also step under the vertical speed's
7
+ * still tolerance, so #280 never takes over. The artist's key sheet already
8
+ * carries the contact table: these keys are that data, not a guess.
9
+ *
10
+ * `rig-pose-contact-keys/1`: `{ spec, keys: [{ frame, side }], note? }`, `side`
11
+ * one of `l | r | both | none`. Frames are the sequence's, may be fractional (a
12
+ * key sheet timed off the frame grid, as `--pelvis-keys`, #294), and strictly
13
+ * increase within [0, frames − 1].
14
+ *
15
+ * **A key holds until the next one** (a step function; the last holds to the
16
+ * clip's end). Contact is a state, not an angle: a foot is down or it is not, and
17
+ * there is no value between `l` and `r` to interpolate. A key at a fractional
18
+ * frame takes effect on the first frame at or after it. Frames before the first
19
+ * key are not covered: the support there is the reading without keys.
20
+ *
21
+ * Precedence: on every covered frame the keys decide, over #280's vertical
22
+ * speed (a frontal clip) and ankle height. `l`/`r`: that foot supports. `none`:
23
+ * flight, no foot supports. `both`: the foot that landed supports, as #280 hands
24
+ * over on landing. The run's neighbours are read from the keys, not the frames,
25
+ * so a key that reaches no frame counts (rig-c/pose#338): after an `l` or `r`
26
+ * key, the other side; after a `none` key (flight), or where the run starts the
27
+ * cover, the side the next `l` or `r` key names (the side that stays down after);
28
+ * else, at the cover's start, the support on the frame before (the reading
29
+ * without keys); a run none of those name keeps the reading without keys (`kept`).
30
+ */
31
+ import type { Feet, Side } from './legs.ts';
32
+ import { isObject, parseJson, Problems, refuseUnknownKeys, typeName } from './problems.ts';
33
+
34
+ export const CONTACT_KEYS_SPEC = 'rig-pose-contact-keys/1';
35
+
36
+ /** A contact key's value: the foot down, both, or none (flight). */
37
+ export type ContactSide = Side | 'both' | 'none';
38
+ const VALUES: readonly ContactSide[] = ['l', 'r', 'both', 'none'];
39
+
40
+ export interface ContactKeys {
41
+ /** The file's keys, as read: strictly increasing frames within the sequence's span. */
42
+ keys: Array<{ frame: number; side: ContactSide }>;
43
+ /** Per frame, the key in force (the last key at or before the frame), or null before the first key. */
44
+ key: Array<ContactSide | null>;
45
+ /** The frames of keys no frame takes: the next key comes before the next whole frame (two keys between two frames). */
46
+ unreached: number[];
47
+ }
48
+
49
+ /**
50
+ * `rig-pose-contact-keys/1`, read against a sequence of `frames` frames. Every
51
+ * problem is collected and refused by name (`CONTACT_KEYS`): a file that is not
52
+ * JSON or not the shape, a `spec` that is not this one, a `side` outside the
53
+ * four, a frame outside [0, frames − 1], a frame not after the key before it.
54
+ */
55
+ export function readContactKeys(text: string, where: string, frames: number): ContactKeys {
56
+ const doc = parseJson(text, where, 'CONTACT_KEYS');
57
+ const p = new Problems();
58
+ const shape = `{ "spec": "${CONTACT_KEYS_SPEC}", "keys": [{ "frame", "side" }], "note"? }`;
59
+ if (!isObject(doc)) {
60
+ p.add('CONTACT_KEYS', `${where} is ${typeName(doc)}; it must be ${shape}`);
61
+ p.throwIfAny();
62
+ throw new Error('unreachable');
63
+ }
64
+ refuseUnknownKeys(p, 'CONTACT_KEYS', where, doc, ['spec', 'keys', 'note']);
65
+ if (doc.spec !== CONTACT_KEYS_SPEC) p.add('CONTACT_KEYS', `${where} "spec" is ${typeName(doc.spec)}; it must be "${CONTACT_KEYS_SPEC}"`);
66
+ if ('note' in doc && typeof doc.note !== 'string') p.add('CONTACT_KEYS', `${where} "note" is ${typeName(doc.note)}; it must be a string`);
67
+ const keys: ContactKeys['keys'] = [];
68
+ if (!Array.isArray(doc.keys) || doc.keys.length === 0) p.add('CONTACT_KEYS', `${where} "keys" is ${typeName(doc.keys)}; it must be a non-empty array of { frame, side }`);
69
+ else doc.keys.forEach((k: unknown, i: number) => {
70
+ const kw = `${where} keys[${i}]`;
71
+ if (!isObject(k)) {
72
+ p.add('CONTACT_KEYS', `${kw} is ${typeName(k)}; a key is { frame, side }`);
73
+ return;
74
+ }
75
+ refuseUnknownKeys(p, 'CONTACT_KEYS', kw, k, ['frame', 'side']);
76
+ const okF = typeof k.frame === 'number' && Number.isFinite(k.frame) && k.frame >= 0 && k.frame <= frames - 1;
77
+ const okS = typeof k.side === 'string' && (VALUES as readonly string[]).includes(k.side);
78
+ if (!okF) p.add('CONTACT_KEYS', `${kw} "frame" is ${typeName(k.frame)}; it must be a frame of the sequence in [0, ${frames - 1}] (fractional allowed)`);
79
+ if (!okS) p.add('CONTACT_KEYS', `${kw} "side" is ${typeName(k.side)}; it must be one of l, r, both, none (the foot down, both feet, or flight)`);
80
+ if (!okF || !okS) return;
81
+ const prev = keys[keys.length - 1];
82
+ if (prev !== undefined && (k.frame as number) <= prev.frame) p.add('CONTACT_KEYS', `${kw} "frame" is ${k.frame as number}, not after the key before it (${prev.frame}); frames strictly increase`);
83
+ else keys.push({ frame: k.frame as number, side: k.side as ContactSide });
84
+ });
85
+ p.throwIfAny();
86
+ const key: Array<ContactSide | null> = [];
87
+ const reached = new Set<number>();
88
+ let at = -1;
89
+ for (let i = 0; i < frames; i++) {
90
+ while (at + 1 < keys.length && keys[at + 1]!.frame <= i) at++;
91
+ key.push(at < 0 ? null : keys[at]!.side);
92
+ if (at >= 0) reached.add(at);
93
+ }
94
+ return { keys, key, unreached: keys.filter((_, k) => !reached.has(k)).map((k) => k.frame) };
95
+ }
96
+
97
+ /** rig-c/pose#303: what the contact keys did to a clip's support, set on `Feet` by `contactKeysOf`. */
98
+ export interface ContactKeysReading {
99
+ keys: ContactKeys['keys'];
100
+ /** Per frame, the key in force, or null before the first key. */
101
+ key: Array<ContactSide | null>;
102
+ /** The first frame a key covers; every frame from it to the last is covered. */
103
+ from: number;
104
+ /** Per frame, the support read without the keys (#280's or ankle height's). */
105
+ before: Array<Side | null>;
106
+ /** Per frame, whether a key decided its support (false outside the cover, and on a `both` run no side names, `kept`). */
107
+ keyed: boolean[];
108
+ /** The frames of `both` runs that keep the reading without keys: no keyed support before, no single-foot key after. */
109
+ kept: number[];
110
+ /** Runs of frames whose support the keys changed: the support without them (`was`) and with them (`now`; null: flight). */
111
+ changed: Array<{ frames: [number, number]; was: Side | null; now: Side | null }>;
112
+ /** As `ContactKeys.unreached`. */
113
+ unreached: number[];
114
+ }
115
+
116
+ /**
117
+ * The support with the contact keys applied, last, over the reading `supportsOf`
118
+ * made (the header's precedence), then rebuilt as #280's frontal reading
119
+ * rebuilds it: support spans and changes from the per-frame support. Outside
120
+ * the cover the support is the reading without keys, unchanged. A step that
121
+ * released a plant (report only) and #113's `lowered` and #119's `tracked` runs
122
+ * are kept where they start before the cover, cut at the frame before it
123
+ * (rig-c/pose#338; a step's re-plant inside the cover is not read, null): inside
124
+ * it a plant ends where the keys hand support over. Jumps are kept as read (the
125
+ * hip's rise); on a jump frame a key still decides the support.
126
+ */
127
+ export function contactKeysOf(feet: Feet, keys: ContactKeys): Feet {
128
+ const n = feet.support.length;
129
+ const before = feet.support.map((s) => s ?? null);
130
+ const support = [...before];
131
+ const keyed = new Array<boolean>(n).fill(false);
132
+ const kept: number[] = [];
133
+ const other = (s: Side): Side => (s === 'r' ? 'l' : 'r');
134
+ const from = keys.key.findIndex((k) => k !== null);
135
+ /** Per frame, the index in `keys.keys` of the key in force (−1 before the first). */
136
+ const at: number[] = [];
137
+ for (let i = 0, k = -1; i < n; i++) {
138
+ while (k + 1 < keys.keys.length && keys.keys[k + 1]!.frame <= i) k++;
139
+ at.push(k);
140
+ }
141
+ for (let i = 0; i < n; i++) {
142
+ const k = keys.key[i] ?? null;
143
+ if (k === null) continue;
144
+ if (k !== 'both') {
145
+ support[i] = k === 'none' ? null : k;
146
+ keyed[i] = true;
147
+ continue;
148
+ }
149
+ let b = i;
150
+ while (b + 1 < n && keys.key[b + 1] === 'both') b++;
151
+ // rig-c/pose#338: the states either side come from the keys themselves, not from the frames — a key that reaches no frame (none@1.1 before both@1.2) is the state the run follows.
152
+ let k0 = at[i]!;
153
+ while (k0 > 0 && keys.keys[k0 - 1]!.side === 'both') k0--;
154
+ let k1 = at[b]! + 1;
155
+ while (k1 < keys.keys.length && keys.keys[k1]!.side === 'both') k1++;
156
+ const prevSide = k0 > 0 ? keys.keys[k0 - 1]!.side : null;
157
+ const nextSide = k1 < keys.keys.length ? keys.keys[k1]!.side : null;
158
+ const landed: Side | null = prevSide === 'r' || prevSide === 'l' ? other(prevSide) : nextSide === 'r' || nextSide === 'l' ? nextSide : prevSide === null && i > 0 ? support[i - 1]! : null;
159
+ for (let f = i; f <= b; f++) {
160
+ if (landed === null) kept.push(f);
161
+ else {
162
+ support[f] = landed;
163
+ keyed[f] = true;
164
+ }
165
+ }
166
+ i = b;
167
+ }
168
+ const supports: Feet['supports'] = [];
169
+ const changes: Feet['changes'] = [];
170
+ const changed: ContactKeysReading['changed'] = [];
171
+ for (let i = 0; i < n; i++) {
172
+ const s = support[i] ?? null;
173
+ if (s !== before[i]) {
174
+ const last = changed[changed.length - 1];
175
+ if (last !== undefined && last.frames[1] === i - 1 && last.was === before[i] && last.now === s) last.frames[1] = i;
176
+ else changed.push({ frames: [i, i], was: before[i]!, now: s });
177
+ }
178
+ if (s === null) continue;
179
+ const prev = i > 0 ? (support[i - 1] ?? null) : null;
180
+ if (prev !== s) changes.push({ frame: i, from: prev, to: s });
181
+ const last = supports[supports.length - 1];
182
+ if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
183
+ else supports.push({ side: s, frames: [i, i] });
184
+ }
185
+ const cut = <T extends { frames: [number, number] }>(runs: readonly T[] | undefined): T[] => (runs ?? []).filter((r) => r.frames[0] < from).map((r) => ({ ...r, frames: [r.frames[0], Math.min(r.frames[1], from - 1)] as [number, number] }));
186
+ const lowered = feet.lowered === undefined ? undefined : cut(feet.lowered);
187
+ const tracked = feet.tracked === undefined ? undefined : cut(feet.tracked);
188
+ return {
189
+ legLength: feet.legLength,
190
+ support,
191
+ supports,
192
+ jumps: feet.jumps,
193
+ // rig-c/pose#338: a step running into the cover ends on the frame before it, as lowered and tracked do; a re-plant inside the cover is the keys' to decide, so it is not read (null).
194
+ steps: cut(feet.steps).map((s) => (s.replant !== null && s.replant >= from ? { ...s, replant: null } : s)),
195
+ changes,
196
+ ...(lowered !== undefined && lowered.length > 0 ? { lowered } : {}),
197
+ ...(tracked !== undefined && tracked.length > 0 ? { tracked } : {}),
198
+ ...(feet.signal !== undefined ? { signal: feet.signal } : {}),
199
+ contactKeys: { keys: keys.keys, key: keys.key, from, before, keyed, kept, changed, unreached: keys.unreached },
200
+ };
201
+ }
202
+
203
+ /** The signal names a stride line uses for the frames a key did not decide. */
204
+ const SIGNAL_NAMES: Record<string, string> = { speed: 'ankle vertical speed', height: 'ankle height' };
205
+
206
+ /**
207
+ * The FEET lines of a run with contact keys (rig-c/pose#303), in place of #280's
208
+ * stride lines: one `support: contact keys` line — the keys, the frames they
209
+ * cover, how many they changed, the flight they declare, what stands outside —
210
+ * then one `stride` line per support span naming the signal that decided its
211
+ * frames (`contact keys`, else the reading without keys) and what that reading
212
+ * alone would have named.
213
+ */
214
+ export function contactKeysLines(feet: Feet): string[] {
215
+ const c = feet.contactKeys;
216
+ if (c === undefined) return [];
217
+ const n = feet.support.length;
218
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
219
+ const changed = c.changed.reduce((s, r) => s + r.frames[1] - r.frames[0] + 1, 0);
220
+ const flight = c.key.filter((k) => k === 'none').length;
221
+ const tail = [
222
+ ...(flight > 0 ? [`${flight} keyed none (flight, no foot supports)`] : []),
223
+ ...(c.kept.length > 0 ? [`${c.kept.length} keyed both with no side to hold (no keyed support before, no single-foot key after) keep it`] : []),
224
+ ...(c.from > 0 ? [`frames ${span([0, c.from - 1])}, before the first key, keep it`] : []),
225
+ ...(c.unreached.length > 0 ? [`key(s) at ${c.unreached.join(', ')} reach no frame (the next key comes first)`] : []),
226
+ ];
227
+ const out = [`support: contact keys (rig-c/pose#303, --contact-keys) — ${c.keys.length} key(s) cover frames ${span([c.from, n - 1])} of ${n}, each holding until the next one; they change the reading without them on ${changed} frame(s)${tail.length > 0 ? `; ${tail.join('; ')}` : ''}`];
228
+ const by = feet.signal?.by;
229
+ for (const sp of feet.supports) {
230
+ let keyed = 0;
231
+ let differ = 0;
232
+ const rest = new Map<string, number>();
233
+ const was = new Set<string>();
234
+ for (let i = sp.frames[0]; i <= sp.frames[1]; i++) {
235
+ if (c.keyed[i]) keyed++;
236
+ else {
237
+ const name = SIGNAL_NAMES[by?.[i] ?? 'height'] ?? 'ankle height';
238
+ rest.set(name, (rest.get(name) ?? 0) + 1);
239
+ }
240
+ if (c.before[i] !== sp.side) {
241
+ differ++;
242
+ was.add(c.before[i] ?? 'none');
243
+ }
244
+ }
245
+ const parts = [...(keyed > 0 ? [`contact keys ${keyed}`] : []), ...[...rest].map(([k, v]) => `${k} ${v}`)];
246
+ const alone = differ === 0 ? 'the reading without keys names the same' : `the reading without keys would name ${[...was].join(' or ')} on ${differ} of them`;
247
+ out.push(`stride ${sp.side} ${span(sp.frames)}: of ${sp.frames[1] - sp.frames[0] + 1} frame(s), decided by ${parts.join(', ')}; ${alone}`);
248
+ }
249
+ return out;
250
+ }
package/src/depth.ts CHANGED
@@ -13,10 +13,17 @@
13
13
  * | segment | sense when foreshortened | why |
14
14
  * | --------- | ------------------------ | --- |
15
15
  * | thigh | forward | a hip flexes forward; it extends back too little to foreshorten past the band |
16
- * | shin | walk: forward · run: back | the gait rule (rig-c/pose#281, HQ): a walk's lifted foot comes forward, a run's is kicked back — the knee alone cannot say, since it bends forward in both |
16
+ * | shin | back | the owner's walk statement (rig-c/pose#336): the lifted foot is behind the supporting leg — a knee folds the shin back |
17
17
  * | upper arm | with the opposite leg | in a gait each arm swings with the opposite leg: forward when the other side's thigh is the more foreshortened of the two, back when its own is; forward (a shoulder flexes forward) when neither thigh is foreshortened |
18
18
  * | forearm | forward | an elbow bends toward the back of the body, so the forearm folds forward |
19
19
  *
20
+ * The thigh's row is the owner's run statement (rig-c/pose#336): the lifted leg
21
+ * is in front, its knee protruding — a raised thigh brings the knee forward.
22
+ * The table is the same in a walk and a run, so `clean --depth walk|run` writes
23
+ * the same signs for both; the gait it records decides which joint
24
+ * `--draw-order depth` orders the legs by (`src/draworder.ts`): a walk by the
25
+ * ankle (the lifted foot behind), a run by the knee (the lifted knee in front).
26
+ *
20
27
  * "Forward" is the trunk's facing: toward the camera (+1) when the figure faces
21
28
  * it (its left shoulder and hip drawn right of its right ones), away (−1) when
22
29
  * it faces away, and undecided (sign 0) when the trunk is side-on — shoulders
@@ -86,8 +93,8 @@ export interface DerivedDepth {
86
93
  signs: { toward: number; away: number; flat: number };
87
94
  }
88
95
 
89
- /** Each frame's depth, derived from the picture's lengths for `gait` (the table above). */
90
- export function deriveDepth(track: readonly Joints[], gait: DepthGait): DerivedDepth {
96
+ /** Each frame's depth, derived from the picture's lengths (the table above; the same for every gait). */
97
+ export function deriveDepth(track: readonly Joints[]): DerivedDepth {
91
98
  const lengths = new Map(DEPTH_SEGMENT_NAMES.map((s) => [s, track.map((j) => drawn(j, s))]));
92
99
  const rest = {} as DerivedDepth['rest'];
93
100
  for (const k of ['upper_arm', 'forearm', 'thigh', 'shin'] as const) {
@@ -112,7 +119,7 @@ export function deriveDepth(track: readonly Joints[], gait: DepthGait): DerivedD
112
119
  if (r === null) continue;
113
120
  let sense: -1 | 1;
114
121
  const kind = kindOf(s);
115
- if (kind === 'shin') sense = gait === 'walk' ? 1 : -1;
122
+ if (kind === 'shin') sense = -1;
116
123
  else if (kind === 'upper_arm') {
117
124
  // The arm swings with the opposite leg: its own thigh and the other side's, by how foreshortened each is.
118
125
  const own = ratio(`${s.slice(0, 2)}thigh` as DepthSegment);
@@ -143,19 +150,52 @@ export function segmentDepth(d: SegmentDepth, length: number): number {
143
150
  return d.sign * (length / d.ratio) * Math.sqrt(1 - r * r);
144
151
  }
145
152
 
153
+ /**
154
+ * A limb's two joint depths on one frame, px (+ toward the camera): its middle joint (the upper segment's) and its end
155
+ * joint (the middle's plus the lower segment's), from the shoulder or hip at 0, unrounded. Null where the upper segment
156
+ * is not named and placed; `end` null where the lower one is not (the middle joint does not need it: rig-c/pose#336).
157
+ */
158
+ export function limbJointDepths(joints: Joints, depth: FrameDepth, limb: Limb): { mid: number; end: number | null } | null {
159
+ const [upper, lower] = LIMBS[limb];
160
+ const du = depth[upper];
161
+ const dl = depth[lower];
162
+ const lu = drawn(joints, upper);
163
+ const ll = drawn(joints, lower);
164
+ if (du === undefined || lu === null) return null;
165
+ const mid = segmentDepth(du, lu);
166
+ return { mid, end: dl === undefined || ll === null ? null : mid + segmentDepth(dl, ll) };
167
+ }
168
+
169
+ /**
170
+ * rig-c/pose#336: the frames of a derived depth on which a shin leans the way the trunk faces — the sign table before
171
+ * #336, where a walk's foreshortened shin read forward. The table since leans it back (−facing) in both gaits, and a run's
172
+ * never changed, so only a record a release before #336 cleaned with `--depth walk` carries one. A record with no
173
+ * foreshortened shin reads the same under both tables and is not listed.
174
+ */
175
+ export function forwardShinFrames(track: readonly Joints[], frames: ReadonlyArray<{ depth?: FrameDepth }>): number[] {
176
+ const out: number[] = [];
177
+ frames.forEach((f, i) => {
178
+ const face = facingOf(track[i] ?? {});
179
+ if (face === 0) return;
180
+ if ((['r_shin', 'l_shin'] as const).some((s) => f.depth?.[s]?.source === 'derived' && f.depth[s]!.sign === face)) out.push(i);
181
+ });
182
+ return out;
183
+ }
184
+
185
+ /** The DEPTH_RECLEAN refusal (#336): the file, the gait it records, the frames (the first five), and what to run. */
186
+ export function depthRecleanText(file: string, gait: DepthGait | null, frames: readonly number[]): string {
187
+ const shown = frames.slice(0, 5).join(', ') + (frames.length > 5 ? `, … +${frames.length - 5} more` : '');
188
+ const g = gait ?? 'walk';
189
+ return `${file} carries derived depth (clean --depth ${gait ?? '?'}) whose foreshortened shin leans forward, the way the trunk faces, on ${frames.length} frame(s) (${shown}): the sign table before rig-c/pose#336, where a walk's lifted foot read forward. --draw-order depth reads a walk at the ankle and would draw that foot in front of the supporting leg. Clean it again with this version (rig-pose clean --depth ${g}), which leans a foreshortened shin back in both gaits; a sequence cleaned with --depth run by an earlier release is read as it is (its signs did not change), and measured depth is not checked`;
190
+ }
191
+
146
192
  /** The limbs' front-to-back order on one frame, from its depth and its joints' drawn lengths. */
147
193
  export function layersOf(joints: Joints, depth: FrameDepth): FrameLayers {
148
194
  const z: Partial<Record<Limb, number>> = {};
149
195
  for (const limb of LIMB_NAMES) {
150
- const [upper, lower] = LIMBS[limb];
151
- const du = depth[upper];
152
- const dl = depth[lower];
153
- const lu = drawn(joints, upper);
154
- const ll = drawn(joints, lower);
155
- if (du === undefined || dl === undefined || lu === null || ll === null) continue;
156
- const mid = segmentDepth(du, lu);
157
- const end = mid + segmentDepth(dl, ll);
158
- z[limb] = Math.round(((mid + end) / 2) * 100) / 100;
196
+ const j = limbJointDepths(joints, depth, limb);
197
+ if (j === null || j.end === null) continue;
198
+ z[limb] = Math.round(((j.mid + j.end) / 2) * 100) / 100;
159
199
  }
160
200
  const placed = LIMB_NAMES.filter((l) => z[l] !== undefined).sort((a, b) => z[b]! - z[a]! || LIMB_NAMES.indexOf(a) - LIMB_NAMES.indexOf(b));
161
201
  const order: Limb[][] = [];
package/src/draworder.ts CHANGED
@@ -36,19 +36,38 @@
36
36
  * ordered, holds the order before; before the first decided frame, the setup
37
37
  * order — or, on a looped sequence, the order the clip ends in, held across
38
38
  * the seam. A key is written where the order changes, and none while it is the
39
- * setup order from the start.
39
+ * setup order from the start. Its time is half a frame before the frame it
40
+ * shows on (0 on frame 0), the rule the switches' attachment keys follow
41
+ * (`keyAtFrame` in `switch.ts`): a key exactly on a frame time is a tie the
42
+ * render breaks a frame late about half the time (rig-c/pose#325).
43
+ * - **By the joint the gait names** (rig-c/pose#336, the owner's two
44
+ * statements). On a sequence cleaned with `--depth walk` the pair is ordered
45
+ * by each leg's ankle depth (the hip at 0, plus the thigh's, plus the shin's:
46
+ * `limbJointDepths`' end) — in a walk the lifted foot is behind the supporting
47
+ * leg; with `--depth run` by the knee depth (the thigh's: its middle) — in a
48
+ * run the lifted leg is in front, its knee protruding. The limb mean cannot
49
+ * say either: a lifted leg's thigh forward and shin back cancel in it, and a
50
+ * walk's flat thigh leaves it at most half a shin (35 px on the fixtures),
51
+ * under the limb tie. The tie is scaled to the joint, what a flat chain can
52
+ * hold there: √(1 − 0.95²) × thigh at the knee, × (thigh + shin) at the
53
+ * ankle, the smaller leg's. Measured depth names no gait and keeps the limb
54
+ * mean and its tie (#304).
40
55
  *
41
56
  * Pure: no clock, no randomness, no disk.
42
57
  */
43
58
  import type { Body18 } from './joints.ts';
44
- import { FLAT_BAND, LIMBS, type FrameLayers, type Limb } from './depth.ts';
59
+ import { FLAT_BAND, LIMBS, limbJointDepths, type FrameLayers, type Limb } from './depth.ts';
45
60
  import { knownSetsOf, type JointMap, type Rest, type RigSets } from './map.ts';
46
- import { DEPTH_SEGMENTS, type DepthSegment, type FrameDepth, type Joint } from './pose.ts';
61
+ import { DEPTH_SEGMENTS, type DepthGait, type DepthSegment, type FrameDepth, type Joint } from './pose.ts';
62
+ import { keyAtFrame } from './switch.ts';
47
63
 
48
64
  /** The limb pairs `--draw-order depth` keys. */
49
65
  export const DRAW_ORDER_PAIRS: ReadonlyArray<readonly [Limb, Limb]> = [['r_leg', 'l_leg']];
50
66
  /** What one segment inside the flat band can hide out of the picture, per unit of its rest length: √(1 − 0.95²). */
51
67
  export const TIE_PER_LENGTH = Math.sqrt(1 - FLAT_BAND[0] * FLAT_BAND[0]);
68
+ /** The joint a gait's legs are ordered by (rig-c/pose#336): a walk's lifted foot is behind, a run's lifted knee in front. */
69
+ export const ORDER_JOINT: Record<DepthGait, LegJoint> = { walk: 'ankle', run: 'knee' };
70
+ export type LegJoint = 'knee' | 'ankle';
52
71
  /** The DRAWORDER line's cap, characters (the span list is cut to it; the report carries every span). */
53
72
  export const DRAW_ORDER_LINE_MAX = 260;
54
73
 
@@ -56,7 +75,7 @@ export interface DrawOrderOffset {
56
75
  slot: string;
57
76
  offset: number;
58
77
  }
59
- /** One key as written into the animation (`t` the frame's time). */
78
+ /** One key as written into the animation (`t` half a frame before the frame it shows on, 0 on frame 0: rig-c/pose#325). */
60
79
  export interface DrawOrderKey {
61
80
  t: number;
62
81
  offsets?: DrawOrderOffset[];
@@ -71,6 +90,8 @@ export interface DrawOrderPair {
71
90
  keyed: boolean;
72
91
  /** #281's tie for the pair, px: depths closer than this hold the order before (null where a rest length is unread: no tie, equal depths only). */
73
92
  tie: number | null;
93
+ /** #336: the joint the pair is ordered by, from the gait clean recorded (the tie then the joint's); absent, the limb mean (#304). */
94
+ by?: LegJoint;
74
95
  /** Why the pair is not keyed. */
75
96
  why?: string;
76
97
  /** The slots of anything else drawn between the pair's slots, in setup order (#324: the line counts them first and cuts this list). */
@@ -80,7 +101,7 @@ export interface DrawOrderPair {
80
101
  export interface DrawOrderGap {
81
102
  a: string;
82
103
  b: string;
83
- /** |depth a − depth b| over the frames both limbs read, px: the median and the largest. */
104
+ /** |depth a − depth b| over the frames both limbs read, px: the median and the largest (at the pair's joint, when it has one). */
84
105
  median: number | null;
85
106
  max: number | null;
86
107
  /** The same as a fraction of the leg (upper + lower rest length of the limb the tie is read from); null where a rest length is unread. */
@@ -103,7 +124,7 @@ export interface DrawOrderSpan {
103
124
  }
104
125
  export interface DrawOrderReading {
105
126
  pairs: DrawOrderPair[];
106
- /** The `drawOrder` timeline, with the frame each key sits on. */
127
+ /** The `drawOrder` timeline as written, with the frame each key first shows on. */
107
128
  keys: Array<DrawOrderKey & { frame: number }>;
108
129
  /** Every span of one decided order, setup or keyed, in frame order per pair. */
109
130
  spans: DrawOrderSpan[];
@@ -225,12 +246,15 @@ function restOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | unde
225
246
 
226
247
  /**
227
248
  * #281's tie for two limbs, px: √(1 − 0.95²) × (upper + lower ÷ 2), the smaller limb's; null where a rest length is unread.
249
+ * By a joint (#336): √(1 − 0.95²) × upper at the knee, × (upper + lower) at the ankle — what a flat chain can hold there.
228
250
  * `leg` is that limb's upper + lower rest length (#324's unit for the gap).
229
251
  */
230
- function tieOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, limbs: readonly [Limb, Limb]): { tie: number | null; leg: number | null } {
252
+ function tieOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, limbs: readonly [Limb, Limb], by: LegJoint | undefined): { tie: number | null; leg: number | null } {
231
253
  const rests = limbs.map((l) => {
232
254
  const [u, w] = LIMBS[l].map((s) => restOf(track, depth, s));
233
- return u === null || u === undefined || w === null || w === undefined ? null : { hidden: u + w / 2, leg: u + w };
255
+ // The knee's tie needs the upper segment alone (#336: a leg whose ankle is never placed still orders at the knee).
256
+ if (by === 'knee') return u === null || u === undefined ? null : { hidden: u, leg: w === null || w === undefined ? null : u + w };
257
+ return u === null || u === undefined || w === null || w === undefined ? null : { hidden: by === 'ankle' ? u + w : u + w / 2, leg: u + w };
234
258
  });
235
259
  if (rests[0] == null || rests[1] == null) return { tie: null, leg: null };
236
260
  const r = rests[1].hidden < rests[0].hidden ? rests[1] : rests[0];
@@ -240,13 +264,13 @@ function tieOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undef
240
264
  const round = (x: number, k: number): number => Math.round(x * 10 ** k) / 10 ** k;
241
265
 
242
266
  /** One pair's leg depth gap against its tie over the frames (the rule's own test: unequal and at least the tie). */
243
- function gapOf(pair: DrawOrderPair, layers: readonly FrameLayers[], leg: number | null): DrawOrderGap {
267
+ function gapOf(pair: DrawOrderPair, zs: ReadonlyArray<Partial<Record<Limb, number>>>, leg: number | null): DrawOrderGap {
244
268
  const tie = pair.tie ?? 0;
245
269
  const gaps: number[] = [];
246
270
  let passed = 0;
247
- for (const l of layers) {
248
- const za = l.depth[pair.limbs[0]];
249
- const zb = l.depth[pair.limbs[1]];
271
+ for (const z of zs) {
272
+ const za = z[pair.limbs[0]];
273
+ const zb = z[pair.limbs[1]];
250
274
  if (za === undefined || zb === undefined) continue;
251
275
  gaps.push(Math.abs(za - zb));
252
276
  if (za !== zb && Math.abs(za - zb) >= tie) passed++;
@@ -262,25 +286,42 @@ function gapOf(pair: DrawOrderPair, layers: readonly FrameLayers[], leg: number
262
286
  /**
263
287
  * The draw-order keys from the frames' layers. `track` and `depth` are the sequence's
264
288
  * joints and depth per frame (the rest lengths the tie reads); `order` is the rig's
265
- * setup draw order (the compiled model's slots); `times` the frames' times.
289
+ * setup draw order (the compiled model's slots); `times` the frames' times. `gait` is
290
+ * the gait clean recorded for its derived depth: a walk orders by the ankle, a run by
291
+ * the knee (#336); none (measured depth) by the limb mean (#304).
266
292
  */
267
- export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, times: readonly number[], map: JointMap, rest: Rest, order: ReadonlyArray<{ name: string; bone: string }>, sets?: RigSets, loop = false): DrawOrderReading {
293
+ export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, times: readonly number[], map: JointMap, rest: Rest, order: ReadonlyArray<{ name: string; bone: string }>, sets?: RigSets, loop = false, gait: DepthGait | null = null): DrawOrderReading {
294
+ const by = gait === null ? undefined : ORDER_JOINT[gait];
268
295
  const resolved = DRAW_ORDER_PAIRS.map((p) => resolvePair(p, map, rest, order, sets));
269
296
  const legs = resolved.map((r) => {
270
- const t = tieOf(track, depth, r.pair.limbs);
297
+ const t = tieOf(track, depth, r.pair.limbs, by);
271
298
  r.pair.tie = t.tie;
299
+ if (by !== undefined) r.pair.by = by;
272
300
  return t.leg;
273
301
  });
302
+ // #336: per frame, each limb's depth at the gait's joint (rounded as the layers are); without a gait, the layers' limb means.
303
+ const zs: ReadonlyArray<Partial<Record<Limb, number>>> =
304
+ by === undefined
305
+ ? layers.map((l) => l.depth)
306
+ : track.map((j, i) => {
307
+ const z: Partial<Record<Limb, number>> = {};
308
+ for (const limb of new Set(DRAW_ORDER_PAIRS.flat())) {
309
+ const d = depth[i] === undefined ? null : limbJointDepths(j, depth[i]!, limb);
310
+ const v = d === null ? null : by === 'knee' ? d.mid : d.end;
311
+ if (v !== null) z[limb] = Math.round(v * 100) / 100;
312
+ }
313
+ return z;
314
+ });
274
315
  const names = order.map((s) => s.name);
275
316
  // Per pair, per frame: who is behind ('a' | 'b'), or null for the setup order before any decision.
276
317
  const states = resolved.map((r) => {
277
318
  const tie = r.pair.tie ?? 0;
278
319
  const pass = (start: 'a' | 'b' | null): Array<'a' | 'b' | null> => {
279
320
  let state = start;
280
- return layers.map((l) => {
321
+ return zs.map((z) => {
281
322
  if (!r.pair.keyed) return null;
282
- const za = l.depth[r.pair.limbs[0]];
283
- const zb = l.depth[r.pair.limbs[1]];
323
+ const za = z[r.pair.limbs[0]];
324
+ const zb = z[r.pair.limbs[1]];
284
325
  if (za !== undefined && zb !== undefined && za !== zb && Math.abs(za - zb) >= tie) state = za < zb ? 'a' : 'b';
285
326
  return state;
286
327
  });
@@ -298,12 +339,14 @@ export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readon
298
339
  .sort((x, y) => names.indexOf(x.slot) - names.indexOf(y.slot));
299
340
  const keys: DrawOrderReading['keys'] = [];
300
341
  let last = '[]';
301
- for (let f = 0; f < layers.length; f++) {
342
+ for (let f = 0; f < zs.length; f++) {
302
343
  const offs = offsetsAt(f);
303
344
  const text = JSON.stringify(offs);
304
345
  if (text === last) continue;
305
346
  last = text;
306
- keys.push(offs.length === 0 ? { frame: f, t: times[f]! } : { frame: f, t: times[f]!, offsets: offs });
347
+ // Half a frame before the frame it shows on, as the switches' keys (`keyAtFrame`, rig-c/pose#325).
348
+ const t = keyAtFrame(times, f);
349
+ keys.push(offs.length === 0 ? { frame: f, t } : { frame: f, t, offsets: offs });
307
350
  }
308
351
  const spans: DrawOrderSpan[] = [];
309
352
  resolved.forEach((r, k) => {
@@ -314,7 +357,7 @@ export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readon
314
357
  else spans.push({ behind: s === 'a' ? r.pair.a : r.pair.b, front: s === 'a' ? r.pair.b : r.pair.a, frames: [f, f], keyed: (s === 'a' ? r.aBehind : r.bBehind).length > 0 });
315
358
  });
316
359
  });
317
- return { pairs: resolved.map((r) => r.pair), keys, spans, gaps: resolved.map((r, k) => gapOf(r.pair, layers, legs[k]!)) };
360
+ return { pairs: resolved.map((r) => r.pair), keys, spans, gaps: resolved.map((r, k) => gapOf(r.pair, zs, legs[k]!)) };
318
361
  }
319
362
 
320
363
  /** The animation's `drawOrder` value: the keys without their frames. */
@@ -330,7 +373,9 @@ const span = (f: [number, number]): string => (f[0] === f[1] ? `${f[0]}` : `${f[
330
373
  */
331
374
  export function drawOrderLines(d: DrawOrderReading, prefix = 'DRAWORDER '): string[] {
332
375
  const keyed = d.spans.filter((s) => s.keyed);
333
- const head = `${prefix}from depth, ${d.keys.length} key(s) over ${keyed.length} span(s)`;
376
+ // #336: a keyed pair ordered by a joint names it and its joint-scaled tie.
377
+ const joint = d.pairs.find((p) => p.by !== undefined && p.keyed);
378
+ const head = `${prefix}from depth${joint === undefined ? '' : ` by the ${joint.by} (${joint.by === 'ankle' ? 'walk' : 'run'}; tie ${joint.tie === null ? 'none' : `${joint.tie} px`})`}, ${d.keys.length} key(s) over ${keyed.length} span(s)`;
334
379
  const groups = new Map<string, string[]>();
335
380
  for (const s of keyed) {
336
381
  const g = `${s.behind} behind ${s.front}`;
@@ -366,12 +411,18 @@ export function drawOrderLines(d: DrawOrderReading, prefix = 'DRAWORDER '): stri
366
411
  /** px with its fraction of the leg: "12.3 px (0.084 leg)". */
367
412
  const pxLeg = (px: number | null, legs: number | null): string => (px === null ? 'unread' : `${px} px${legs === null ? '' : ` (${legs} leg)`}`);
368
413
 
414
+ /** " at the knee" / " at the ankle" for a pair ordered by a joint (#336), else nothing. */
415
+ const gapAt = (d: DrawOrderReading, g: DrawOrderGap): string => {
416
+ const j = d.pairs.find((p) => p.a === g.a && p.b === g.b)?.by;
417
+ return j === undefined ? '' : ` at the ${j}`;
418
+ };
419
+
369
420
  /**
370
421
  * The DRAWGAP lines (rig-c/pose#324): one per pair, keyed or not — the leg depth gap's median and
371
422
  * max against the tie, and how many frames passed it. Cut to `DRAW_ORDER_LINE_MAX` characters.
372
423
  */
373
424
  export function drawGapLines(d: DrawOrderReading, prefix = 'DRAWGAP '): string[] {
374
425
  return (d.gaps ?? []).map((g) =>
375
- `${prefix}${g.a} / ${g.b}: leg depth gap median ${pxLeg(g.median, g.medianLegs)}, max ${pxLeg(g.max, g.maxLegs)}, against the tie of ${g.tie === null ? 'none (a rest length is unread; equal depths only)' : pxLeg(g.tie, g.tieLegs)}; ${g.passed} of ${g.frames} frame(s) passed it`.slice(0, DRAW_ORDER_LINE_MAX),
426
+ `${prefix}${g.a} / ${g.b}: leg depth gap${gapAt(d, g)} median ${pxLeg(g.median, g.medianLegs)}, max ${pxLeg(g.max, g.maxLegs)}, against the tie of ${g.tie === null ? 'none (a rest length is unread; equal depths only)' : pxLeg(g.tie, g.tieLegs)}; ${g.passed} of ${g.frames} frame(s) passed it`.slice(0, DRAW_ORDER_LINE_MAX),
376
427
  );
377
428
  }
package/src/legs.ts CHANGED
@@ -58,6 +58,7 @@
58
58
  * frames (`closeLoop`), so the loop carries no net travel.
59
59
  */
60
60
  import type { Chain, JointMap, Rest } from './map.ts';
61
+ import type { ContactKeysReading } from './contacts.ts';
61
62
 
62
63
  type Pt = [number, number];
63
64
  /** A world 2×2: columns (a, c) — the x axis — and (b, d) — the y axis. */
@@ -522,6 +523,12 @@ export interface Feet {
522
523
  * `supportsOf` on every reading.
523
524
  */
524
525
  signal?: SupportSignal;
526
+ /**
527
+ * rig-c/pose#303: what `--contact-keys` did — the keys, the frames they cover,
528
+ * the support without them and the runs they changed. Set by `contactKeysOf`,
529
+ * applied last; absent without the flag.
530
+ */
531
+ contactKeys?: ContactKeysReading;
525
532
  }
526
533
 
527
534
  /** A run of frames whose support the lower-ankle rule (rig-c/pose#113) reads differently from the reading before it. */
package/src/pose.ts CHANGED
@@ -74,7 +74,7 @@ export interface SegmentDepth {
74
74
  source: DepthSource;
75
75
  }
76
76
  export type FrameDepth = Partial<Record<DepthSegment, SegmentDepth>>;
77
- /** The gait `clean --depth` was told the clip is: it decides the sign of a foreshortened shin. */
77
+ /** The gait `clean --depth` was told the clip is (rig-c/pose#336): the signs are the same for both; it decides the joint `--draw-order depth` orders the legs by. */
78
78
  export const DEPTH_GAITS = ['walk', 'run'] as const;
79
79
  export type DepthGait = (typeof DEPTH_GAITS)[number];
80
80
  /**
package/src/retarget.ts CHANGED
@@ -80,8 +80,9 @@ import type { Joint, Travel } from './pose.ts';
80
80
  import { TRAVEL_IN_PLACE } from './pose.ts';
81
81
  import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SetRuns, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
- import { clampArt, coveredChains, isRaised, measureArt, overflow, phaseGroupProblems, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
83
+ import { clampArt, coveredChains, isRaised, keyAtFrame, measureArt, overflow, phaseGroupProblems, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
84
  import type { BlendHold, Closure, Feet, Floor, LegScale, LegSegmentRatio, PathPose, Posing, Side, Stance } from './legs.ts';
85
+ import { contactKeysOf, type ContactKeys } from './contacts.ts';
85
86
  import { figureScaleOf, freeAnkleShiftOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, shiftOver, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
86
87
  import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
87
88
  import { readBody, readFace, readFaceAngles, selectYawSets } from './face.ts';
@@ -181,6 +182,12 @@ export interface RetargetOptions extends SwitchOptions {
181
182
  * the hip line on the frames it does not; absent, from the hip line.
182
183
  */
183
184
  pelvis?: { source: string; tilt: ReadonlyArray<number | null> };
185
+ /**
186
+ * rig-c/pose#303: the hand contact keys (`--contact-keys`), read against the
187
+ * sequence. Applied last over the supports read (`contactKeysOf`): on every
188
+ * frame they cover they decide the support. Absent, nothing changes.
189
+ */
190
+ contactKeys?: ContactKeys;
184
191
  }
185
192
 
186
193
  /**
@@ -1020,6 +1027,11 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1020
1027
  else if (plantOn) p.add('LEGS_MAP', `--plant-feet reads both hips and both ankles from the pose; the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`);
1021
1028
  }
1022
1029
  }
1030
+ // rig-c/pose#303: the contact keys decide last, over every reading above; with no support read they have nothing to decide, so they are refused.
1031
+ if (opts.contactKeys !== undefined) {
1032
+ if (feet !== null) feet = contactKeysOf(feet, opts.contactKeys);
1033
+ else p.add('CONTACT_KEYS', `--contact-keys sets which foot supports, and no support is read: ${legs !== null && 'problems' in legs ? legs.problems.join('; ') : `the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`}`);
1034
+ }
1023
1035
  const heldPt = (name: string): Pt[] | null => {
1024
1036
  const known = series.get(name);
1025
1037
  if (known !== undefined) return known;
@@ -3048,7 +3060,6 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3048
3060
  if (!slotOrder.includes(sl)) slotOrder.push(sl);
3049
3061
  }
3050
3062
  }
3051
- const yawExactAt = new Map<string, Set<number>>();
3052
3063
  for (const s of activeYawSets) {
3053
3064
  const def = known.get(s.set);
3054
3065
  if (def === undefined) continue;
@@ -3058,10 +3069,6 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3058
3069
  const row = sw.shown.get(sl)!;
3059
3070
  for (const [lo, hi] of s.spans) {
3060
3071
  for (let i = lo; i <= hi; i++) row[i] = v;
3061
- const exact = yawExactAt.get(sl) ?? new Set<number>();
3062
- exact.add(lo);
3063
- if (hi + 1 < n) exact.add(hi + 1);
3064
- yawExactAt.set(sl, exact);
3065
3072
  }
3066
3073
  }
3067
3074
  }
@@ -3071,11 +3078,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3071
3078
  if (row === undefined) continue;
3072
3079
  let prev = slots.get(sl)?.setup ?? null;
3073
3080
  const keys: AttachmentKey[] = [];
3074
- const exact = yawExactAt.get(sl);
3075
3081
  row.forEach((v, i) => {
3076
3082
  if (v === prev) return;
3077
- const t = i === 0 ? 0 : exact?.has(i) === true ? keyTimes[i]! : r6((keyTimes[i - 1]! + keyTimes[i]!) / 2);
3078
- keys.push({ t, v });
3083
+ // Half a frame before the frame it shows on, as every discrete key pose writes (`keyAtFrame`, rig-c/pose#334):
3084
+ // written on their frames' exact times, 7 of the 10 yaw swaps of the 24 fps head turn in test/retarget.test.ts
3085
+ // showed a frame late under the render's clock.
3086
+ keys.push({ t: keyAtFrame(keyTimes, i), v });
3079
3087
  prev = v;
3080
3088
  });
3081
3089
  if (keys.length > 0) rebuilt.push({ slot: sl, property: 'attachment', keys });
package/src/run.ts CHANGED
@@ -10,7 +10,7 @@ import { tmpdir } from 'node:os';
10
10
  import { dirname, join, relative, resolve } from 'node:path';
11
11
  import { knownSetsOf, readMap, resolveMap, type JointMap, type Rest } from './map.ts';
12
12
  import { cleanLine, cleanSummaryLine, inferredSource } from './clean.ts';
13
- import { depthLine, layersOf, type FrameLayers } from './depth.ts';
13
+ import { depthLine, depthRecleanText, forwardShinFrames, layersOf, type FrameLayers } from './depth.ts';
14
14
  import { drawGapLines, drawOrderFromDepth, drawOrderKeys, drawOrderLines, type DrawOrderReading } from './draworder.ts';
15
15
  import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
16
16
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
@@ -27,6 +27,7 @@ import { reportRenderDomain, writeReport, type ReportInput } from './report.ts';
27
27
  import { PROVENANCE_FILE, provenanceText } from './provenance.ts';
28
28
  import { madeLine, planOutDirs } from './outdir.ts';
29
29
  import { bodyLine, faceLine } from './face.ts';
30
+ import { contactKeysLines, readContactKeys } from './contacts.ts';
30
31
 
31
32
  export interface RetargetArgs extends RetargetOptions {
32
33
  rig: string;
@@ -81,6 +82,8 @@ export interface RetargetArgs extends RetargetOptions {
81
82
  output?: 'default' | 'verbose' | 'summary';
82
83
  /** `--draw-order depth` (rig-c/pose#304): key the animation's draw order from the sequence's depth. Absent: no draw-order key, the output as before. */
83
84
  drawOrder?: 'depth';
85
+ /** `--contact-keys` (rig-c/pose#303): the hand contact keys' file, `rig-pose-contact-keys/1`. Absent: the support as read, the output as before. */
86
+ contactKeysFile?: string;
84
87
  }
85
88
 
86
89
  export interface RunResult {
@@ -104,7 +107,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
104
107
  throw new Refusal(problems);
105
108
  };
106
109
  const args = new Problems();
107
- for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge]] as const) {
110
+ for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge], ['--contact-keys', a.contactKeysFile]] as const) {
108
111
  if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
109
112
  }
110
113
  // rig-c/pose#239: the report is written unless asked elsewhere — beside the motion, as <out>.report.json.
@@ -141,6 +144,15 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
141
144
  // #304: the draw order is read off the sequence's depth; asked for on a sequence without it, refused by name.
142
145
  if (a.drawOrder === 'depth' && !seq.frames.some((x) => x.depth !== undefined)) refuse([{ code: 'DRAWORDER_NO_DEPTH', text: `--draw-order depth keys the draw order from the sequence's depth, and ${a.pose} carries none (rig-pose-sequence/${seq.cleaned!.params.depth === undefined ? '1 or 1.1, no "depth" on any frame' : '1.2 with no frame naming a segment'}); clean it with --depth walk|run, or retarget without --draw-order` }]);
143
146
  const frames = personTrack(seq, a.person, a.pose);
147
+ // #336: a derived depth whose shins lean the way the trunk faces was cleaned with the table before #336 (a walk's lifted
148
+ // foot forward); the draw order now reads a walk at the ankle, and would draw that foot in front. Refused by name.
149
+ if (a.drawOrder === 'depth') {
150
+ const old = forwardShinFrames(frames, seq.frames);
151
+ if (old.length > 0) refuse([{ code: 'DEPTH_RECLEAN', text: depthRecleanText(a.pose, seq.cleaned!.params.depth?.gait ?? null, old) }]);
152
+ }
153
+ // rig-c/pose#303: the contact keys, read against the sequence's frames before any gate runs.
154
+ const contactKeysSnapshot = a.contactKeysFile === undefined ? null : readFileSync(a.contactKeysFile);
155
+ const contactKeys = contactKeysSnapshot === null ? null : readContactKeys(contactKeysSnapshot.toString('utf8'), a.contactKeysFile!, seq.frames.length);
144
156
  const times = seq.frames.map((f) => f.t);
145
157
 
146
158
  const scratch = mkdtempSync(join(tmpdir(), 'rig-pose-'));
@@ -156,7 +168,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
156
168
  const slots = slotsOf(doc0);
157
169
  resolveMap(map, rest, a.map, slots, input.sets);
158
170
 
159
- const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}), ...(seq.pelvisSource !== undefined ? { pelvis: { source: seq.pelvisSource, tilt: seq.frames.map((f) => f.pelvisTilt ?? null) } } : {}) }, slots, input.sets);
171
+ const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}), ...(seq.pelvisSource !== undefined ? { pelvis: { source: seq.pelvisSource, tilt: seq.frames.map((f) => f.pelvisTilt ?? null) } } : {}), ...(contactKeys !== null ? { contactKeys } : {}) }, slots, input.sets);
160
172
  if (fragment.length > 0 && ((r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
161
173
  const known = knownSetsOf(map, input.sets);
162
174
  for (const s of [...(r.face?.sets ?? []), ...(r.body?.sets ?? [])]) {
@@ -227,8 +239,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
227
239
  // rig-c/pose#281: the limbs' front-to-back order from the sequence's depth, only when it carries depth.
228
240
  const layers: FrameLayers[] | null = seq.frames.some((x) => x.depth !== undefined) ? frames.map((j, i) => layersOf(j, seq.frames[i]!.depth ?? {})) : null;
229
241
  if (layers !== null) say(` ${depthLine(layers, seq.frames, seq.cleaned!.params.depth?.gait ?? null)}`);
230
- // rig-c/pose#304: with --draw-order depth, the legs' draw order keyed from those layers.
231
- const drawOrder: DrawOrderReading | null = a.drawOrder === 'depth' && layers !== null ? drawOrderFromDepth(layers, frames, seq.frames.map((x) => x.depth), r.times, map, rest, doc0.slots, input.sets, seq.cleaned!.params.loop) : null;
242
+ // rig-c/pose#304: with --draw-order depth, the legs' draw order keyed from the depth (#336: at the joint the recorded gait names).
243
+ const drawOrder: DrawOrderReading | null = a.drawOrder === 'depth' && layers !== null ? drawOrderFromDepth(layers, frames, seq.frames.map((x) => x.depth), r.times, map, rest, doc0.slots, input.sets, seq.cleaned!.params.loop, seq.cleaned!.params.depth?.gait ?? null) : null;
232
244
  if (drawOrder !== null) for (const l of [...drawOrderLines(drawOrder), ...drawGapLines(drawOrder)]) say(` ${l}`);
233
245
  if (r.pivot !== null) say(` PIVOT bone "${r.pivot.bone}"'s origin is ${r.pivot.distance.toFixed(2)} units (${r.pivot.hipWidths.toFixed(3)} hip widths) from its thigh roots' midpoint (${r.pivot.roots.join(' + ')}): dx ${r.pivot.offset[0].toFixed(2)}, dy ${r.pivot.offset[1].toFixed(2)}, at setup — the pivot every hip turn swings the legs about`);
234
246
  if (r.feet !== null) {
@@ -237,7 +249,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
237
249
  say(` FEET ${r.planted ? 'planted' : 'read, not planted (no --plant-feet)'}: leg length (median hip → ankle) r ${f.legLength.r.toFixed(1)}px, l ${f.legLength.l.toFixed(1)}px; ${f.supports.length} support span(s), ${f.changes.filter((c) => c.from !== null).length} change(s) of support, ${f.jumps.length} jump(s), ${f.steps.length} step(s)`);
238
250
  for (const sg of r.segments) say(` FEET ${segmentLine(sg)}`);
239
251
  say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
240
- if (f.signal !== undefined) for (const l of signalLines(f.signal, f.supports)) say(` FEET ${l}`);
252
+ // rig-c/pose#303: with contact keys, their stride lines stand in for #280's (the keys decide the frames they cover).
253
+ const signal = f.signal === undefined ? [] : signalLines(f.signal, f.supports);
254
+ for (const l of f.contactKeys === undefined ? signal : [...signal.filter((x) => !x.startsWith('stride ')), ...contactKeysLines(f)]) say(` FEET ${l}`);
241
255
  say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
242
256
  if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
243
257
  if (f.tracked !== undefined) say(` FEET tracked (the lower foot supports on the floor, following its scaled ankle track until still, rig-c/pose#119): ${f.tracked.map((x) => `${x.side} ${span(x.frames)}`).join(', ')}`);
@@ -423,7 +437,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
423
437
 
424
438
  assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
425
439
  // Every retarget option as resolved: the report's `options`, and with the name the build record's `flags` (#270).
426
- const 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', ...(a.reduceChains === undefined ? {} : { reduceChains: a.reduceChains }), reduceHip: a.reduceHip ?? 'off', ...(a.reduceKeepPlanted === true ? { reduceKeepPlanted: true } : {}), ...(a.drawOrder !== undefined ? { drawOrder: a.drawOrder } : {}) };
440
+ const 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', ...(a.reduceChains === undefined ? {} : { reduceChains: a.reduceChains }), reduceHip: a.reduceHip ?? 'off', ...(a.reduceKeepPlanted === true ? { reduceKeepPlanted: true } : {}), ...(a.drawOrder !== undefined ? { drawOrder: a.drawOrder } : {}), ...(a.contactKeysFile !== undefined ? { contactKeys: a.contactKeysFile } : {}) };
427
441
  const reportText = writeReport({
428
442
  spec: 'rig-pose-report/1',
429
443
  rigc,
@@ -485,8 +499,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
485
499
  pelvis: r.pelvis,
486
500
  // Only when the sequence carries depth (#281): a sequence without it writes the report it always has.
487
501
  ...(layers !== null ? { layers: layers.map((l, frame) => ({ frame, order: l.order, depth: l.depth })) } : {}),
488
- // Only with --draw-order depth (#304).
489
- ...(drawOrder !== null ? { drawOrder: { source: 'depth', pairs: drawOrder.pairs, keys: drawOrder.keys, spans: drawOrder.spans, gaps: drawOrder.gaps ?? [] } } : {}),
502
+ // Only with --draw-order depth (#304). A key's `t` here is its frame's time, the report's clock (#214); the
503
+ // motion's key is written half a frame before it (#325).
504
+ ...(drawOrder !== null ? { drawOrder: { source: 'depth', pairs: drawOrder.pairs, keys: drawOrder.keys.map((k) => ({ ...k, t: r.times[k.frame]! })), spans: drawOrder.spans, gaps: drawOrder.gaps ?? [] } } : {}),
505
+ // Only with --contact-keys (#303).
506
+ ...(r.feet?.contactKeys !== undefined && contactKeysSnapshot !== null ? { contactKeys: { path: a.contactKeysFile, sha256: createHash('sha256').update(contactKeysSnapshot).digest('hex'), keys: r.feet.contactKeys.keys.length, from: r.feet.contactKeys.from, changed: r.feet.contactKeys.changed, kept: r.feet.contactKeys.kept, unreached: r.feet.contactKeys.unreached } } : {}),
490
507
  stance: r.stance === null ? null : { tolerance: r.stance.tolerance, release: r.stance.release, minSeconds: r.stance.minSeconds, bounds: r.stance.bounds, ...(r.stance.range === undefined ? {} : { range: r.stance.range }), runs: r.stance.runs, over: r.stance.over },
491
508
  feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, straight: r.legStraight, rigLeg: r.rigLeg, segments: r.segments, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), ...(r.feet.tracked === undefined ? {} : { tracked: r.feet.tracked }), floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, ...(r.blendHold.range === undefined ? {} : { range: r.blendHold.range }), runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading, closure: r.closure },
492
509
  shear,
package/src/switch.ts CHANGED
@@ -476,8 +476,15 @@ function moveBoundaries(vals: ReadonlyArray<AlternateSet | null>, pos: readonly
476
476
  return moves;
477
477
  }
478
478
 
479
- /** The key time for a change that shows on frame i: half a frame before it, 0 on the first frame. */
480
- const changeAt = (times: readonly number[], i: number): number => (i === 0 ? 0 : r6((times[i - 1]! + times[i]!) / 2));
479
+ /**
480
+ * The key time for a stepped change that shows on frame i: half a frame before it, 0 on the first frame. A key
481
+ * exactly on a frame time is a tie the render breaks either way (its track time is a running sum of 1/fps, key
482
+ * times are held as float32, and a key applies when its time ≤ the track time), so about half such keys land a
483
+ * frame late (rig-c/pose#325); the midpoint sits half a frame from both ties. Every discrete key pose writes goes
484
+ * through it: the attachment and offset keys here, the draw-order keys in `draworder.ts`, and the face and body yaw
485
+ * sets' attachment keys in `retarget.ts` (rig-c/pose#334).
486
+ */
487
+ export const keyAtFrame = (times: readonly number[], i: number): number => (i === 0 ? 0 : r6((times[i - 1]! + times[i]!) / 2));
481
488
 
482
489
  function keyHandOffset(a: AlternateSet, map: JointMap, cn: string): [number, number] | null {
483
490
  const c = map.chains.find((x) => x.name === cn);
@@ -1116,7 +1123,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
1116
1123
  const keys: AttachmentKey[] = [];
1117
1124
  row.forEach((v, i) => {
1118
1125
  if (v === prev) return;
1119
- keys.push({ t: changeAt(times, i), v });
1126
+ keys.push({ t: keyAtFrame(times, i), v });
1120
1127
  prev = v;
1121
1128
  });
1122
1129
  if (keys.length > 0) slotTracks.push({ slot: s, property: 'attachment', keys });
@@ -1129,7 +1136,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
1129
1136
  row.forEach((v, i) => {
1130
1137
  const prev = row[i - 1];
1131
1138
  if (i === 0 || (prev![0] === v[0] && prev![1] === v[1])) return;
1132
- keys.push({ t: changeAt(times, i), v: [r3(v[0]), r3(v[1])] });
1139
+ keys.push({ t: keyAtFrame(times, i), v: [r3(v[0]), r3(v[1])] });
1133
1140
  });
1134
1141
  // Every key but the last holds until the next (a stepped curve on the last has nothing to ease to).
1135
1142
  for (let k = 0; k + 1 < keys.length; k++) keys[k]!.ease = 'stepped';