@rig-c/pose 0.11.0 → 0.12.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
@@ -10,7 +10,7 @@ Part of the rig-c toolchain: [core](https://github.com/rig-c/core) compiles and
10
10
 
11
11
  ## Try it
12
12
 
13
- [`examples/wave`](examples/wave) runs with nothing else: a bones-only stick rig (no art), a 3-second wave made from joint angles (`make-pose.ts` writes it), and the map that joins them.
13
+ [`examples/wave`](https://github.com/rig-c/pose/tree/main/examples/wave) is in the repository, not the npm package: clone the repository, or copy its four files. It runs with nothing else: a bones-only stick rig (no art), a 3-second wave made from joint angles (`make-pose.ts` writes it), and the map that joins them.
14
14
 
15
15
  ```sh
16
16
  rig-pose retarget --rig examples/wave/rig --map examples/wave/map.json --pose examples/wave/pose.json --out wave.motion.json --build wave-build
@@ -18,6 +18,10 @@ rig-pose retarget --rig examples/wave/rig --map examples/wave/map.json --pose ex
18
18
 
19
19
  It prints the gate's verdict and where every mapped bone landed, then writes `wave.motion.json` and the gated build. `rigc preview --candidate wave-build` plays it in the Spine Web Player. A rig with no art shows only its bones.
20
20
 
21
+ ## What the rig needs
22
+
23
+ The map names **chains**: each limb as bones that descend from one another, parent to child. That means a thigh with its shin below it, and an upper arm with its forearm below it, and likewise for the trunk and the head. A rig whose arm is one bone, or that has no leg bones, cannot take a pose: the map is refused by name (`MAP_*`). [@rig-c/parts](https://github.com/rig-c/parts) builds such a rig from a painting's keypoints with `propose --keypoints`. A rig drawn by hand needs the same chains.
24
+
21
25
  ## What it does
22
26
 
23
27
  ```sh
@@ -53,12 +57,23 @@ and what the run cannot decide for you it lists:
53
57
  - `LEG-SCALE` — with `--leg-scale` ([below](#the-legs-foreshortening-and-planted-feet)): per leg segment, its straight length, the spread of its picture length over it, and the frames clamped at each limit.
54
58
  - `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.
55
59
  - `FEET` — when the map has both legs: 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.
60
+ - `STANCE` — with `--stance` ([below](#the-legs-foreshortening-and-planted-feet)): one line per run of frames planted both (with the stretch keyed, its largest k) or lifted (the pose's height kept), with the free side, the frames and the tolerance; and one per run whose stretch fell past its bounds (`past the bounds`), with the k the floor needed and how far the foot was left from it.
56
61
  - `SHEAR` — with `--leg-scale`: every scaled bone and every bone under one, how far its art is sheared and how long its world axes are against setup. A reading.
57
62
  - `IDLE`, `IDLE-DROPPED`, `IDLE-KEPT-OUT`, `MERGE` — the rig's idle against the new animation, and what `--carry-idle` and `--merge` put back ([below](#the-idle-what-the-retarget-drops)).
58
63
  - `REDUCE` — with `--reduce` ([below](#few-keys---reduce)): per bone, its keys before and after and its largest error, with the frame; then the total.
59
64
  - `TURN` — a line across the body (hips, shoulders, eyes) shorter in the picture than 0.5 of its median length, or reversed, for at least 0.15 s: the body or the head turned. A reading of the pose, made whether or not the map uses the line; it changes no key. The demand for a turned-body drawing, as `END-ON` is for a foreshortened limb.
60
65
 
61
- `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale` and `plantFeet` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `floor`, `supports`, `jumps`, `changes`, and the posed `reading`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), `reduce` (the tolerances and each row of `REDUCE`, [below](#few-keys---reduce)), and the demand lists:
66
+ `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale`, `plantFeet` and `stance` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `floor`, `supports`, `jumps`, `changes`, and the posed `reading`), `stance` (`tolerance`, `bounds`, `runs`: `{ state, side, frames, maxHeight, k }`, `over`: `{ side, frames, needed, left }`; `null` without `--stance`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), `reduce` (the tolerances and each row of `REDUCE`, [below](#few-keys---reduce)), and the demand lists. The fields a run is graded by:
67
+
68
+ | field | each entry |
69
+ | --- | --- |
70
+ | `holds` | `{ point, from, to, source, why }`: a joint held over frames `from`–`to` at frame `source`'s value |
71
+ | `bends` | `{ chain, joint, frame, t, angle, side, rest }`: a hinge found bent the wrong way (`angle` in degrees, Spine's sense; `side` ±1, the side it may bend) |
72
+ | `endOn` | `{ chain, bone, joints, frames, seconds, minFraction }`: a limb pointing at the camera for ≥ 0.15 s |
73
+ | `landings` | `{ bone, worst, frame, kind, tolerance }`: where rig-c's poser put each bone, against the target. `kind` is `angle` (degrees), `origin` or `foot` (world units), or `scale` (a fraction of the setup length). A run writes only when every `worst` ≤ `tolerance` |
74
+ | `folds` | `{ slot, attachment, times }`: every mesh `rigc render --folds` saw fold, at those seconds; `null` when it did not read |
75
+
76
+ The demand lists:
62
77
 
63
78
  - `switches: [{ set, chains, slots, hides, offsets, from, to, frames: [first, last], distance: { max, median }, artTurn: { <bone>: { max, median } }, moved: { on, off } }]` — one per span shown; `from` is the first frame's time, `to` the time the base art comes back (the animation's end when the span runs to it), `distance` in degrees over the frames shown, `artTurn` |wrap(target − drawn)| in degrees per bone the set has a `drawn` angle for. `moved.on` is the boundary the span starts on (null from the first frame), `moved.off` the one the base art or the next set comes back on (null to the end), each `{ frame: [selected, keyed], frames, seconds, window: [first, last], speed: { old, new } }`: signed frames and seconds, the frames it could move to, and the speed in degrees per frame on the old and the new frame. A boundary between two sets is the same entry in both.
64
79
  - `uncovered: [{ slot, attachment, chains, times }]` — every entry of `folds` on the times its slot still shows that mesh, with the map's chains that move it (the deepest: the arm, not the trunk it hangs from). `rigc render --folds` reads only what the pose shows, so a mesh swapped out or hidden by a switch is not read on those frames at all; `uncovered` is the fold times less the switched ones.
@@ -107,6 +122,7 @@ rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inh
107
122
  - 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** — releases the plant: the hip follows the pose's travel from where it was, and re-plants on landing (blended the same way). The body has to lift: an ankle's picture y drifts with depth, and under one floor for the whole sequence HQ's dancer 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.
108
123
  - A **step** releases the plant too: while the supporting ankle moves more than **0.03** leg lengths a frame, the other foot takes support if it is planted (not itself released or moving), and otherwise the hip follows the pose's travel. The released ankle may support again once it has moved less than **0.01** leg lengths a frame for **2 frames**. On the dancer the plant held the supporting ankle through a real step (frames 176–181, 579 → 718 px) and threw the hip sideways.
109
124
  - `FEET` lists the supports, the jumps and the steps (each with the side, its frames, who took support and where it re-planted); the report carries them under `feet` (`supports`, `jumps`, `steps`, `changes`).
125
+ - **`--stance <leg lengths>`** (only with `--plant-feet`, which it is refused without; off by default) plants **both** feet when the free one is near the floor ([#60](https://github.com/rig-c/pose/issues/60)). On a frame with a supporting side (so outside every jump), the free ankle's height above the supporting one is read in the picture, in the free side's leg length — the leg length `FEET` uses. Within the tolerance the frame is **planted both**: every bone of the free leg's chain has its `scalex` key multiplied by k = (root y − floor y) / (root y − ankle y) on the posed rig, the root being the chain's first bone's origin and the floor the supporting ankle, so the foot reaches the floor. A free ankle below the supporting one is planted too: its k is under 1, since a foot under the floor is the same fault as one floating above it. k is held within **[0.92, 1.08]**, and a frame past those bounds is listed `past the bounds`, held at the bound and not forced. Above the tolerance the foot is **lifted** and keeps the pose's height. The stretched scales are the scale targets, so the landing check holds them. Measured with `--stance 0.07` and HQ's flags (`--smooth 7 --flip 90 --short 0.35 --bends clamp --leg-scale 0.8,1.05 --plant-feet --carry-idle`): on HQ's dancer, frames 94–175 outside the jumps, the free ankle's height above the supporting one on the posed build went from median 18.3 and p90 25.4 units to 0.0 and 0.0. On a second sequence (Toca, 492 frames at 30 fps) the 29 frames labelled by eye as both feet flat, outside the jumps, read 0.0 and 0.0. Of the 6 labelled one foot lifted, 5 stay above the tolerance; the sixth (frame 360) was 0.042 leg lengths up in the picture, within the tolerance, and so planted; without `--stance` it stood 6.5 units up, also under the tolerance (22.6 units there). That is 83 %, under the 90 % the card asked.
110
126
  - The map needs one chain ending at each ankle whose last bone is aimed `"x"` (the ankle is that bone's tip), and a `translate` the legs hang from (`LEGS_MAP`). A `range`, or a set's `turn` or `offsets`, on a bone between the hip and an ankle is refused (`LEGS_RANGE`): the plant is derived before the range clamp, and a clamp after it would move the planted foot.
111
127
 
112
128
  Both are checked on the gated build: every scale lands, each ankle lands where the keys put it, and the hip's origin where the plant put it. The figures were fixed in #47 before the dancer was measured; the defaults stay off until they pass.
@@ -194,7 +210,7 @@ Which bones carry which joints. You write it for your rig:
194
210
  - A **point** of the rig is a bone's origin (`name`), the end of its x axis (`name:tip`), or the midpoint of two of those (`"a+b"`: `"sleeve_r0+sleeve_l0"` is the shoulders' midpoint). An aim's points must be the bone itself or descend from it.
195
211
  - `reach: true`, on a one-bone chain from the translated joint (a trunk from `mid_hip`): the bone is aimed from where its origin is posed — the hip moved and turned — at where the pose puts the segment's end relative to mid_hip, scaled. Without it, a tilted pelvis carries the chest's origin sideways and the trunk, parallel to the pose's, misses the shoulders. Its aim is one point, from the bone's own origin: `{ "name": "trunk", "bones": ["chest"], "joints": ["mid_hip", "mid_shoulder"], "aim": ["sleeve_r0+sleeve_l0"], "reach": true }`. The chain must descend from the translated bone.
196
212
  - `bend`: every joint inside a chain is a hinge. `"rest"` (default) takes the side it may bend from the rig's rest pose and refuses a rest that is straight (under 0.5°); `"ccw"`/`"cw"` state it; `"free"` skips the check.
197
- ⚠️ **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 (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 `"free"`; legs on `"rest"`, with `--bends clamp` straightening the noise.
213
+ ⚠️ **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 (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 a second dance with knee bends (Toca Toca, 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.
198
214
  - `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
199
215
  - `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.
200
216
 
@@ -329,7 +345,7 @@ Exit codes: `0` compared, whether it found anything or not, since a finding is n
329
345
 
330
346
  [Bun](https://bun.sh) ≥ 1.2 on `PATH` — rig-pose, like rig-c, runs its TypeScript sources directly. `rig-c` comes as a dependency. With `@esotericsoftware/spine-core` installed beside it, rig-c's gate runs the spine-core round trip; without it, rig-c's own gate, and the gate's report says which.
331
347
 
332
- ## What 0.2 fixes
348
+ ## Why alternate drawings (0.2)
333
349
 
334
350
  A flat painting turned by its bones matches each limb's direction, not its depth. On a real 13 s front-facing dance, retargeted onto a @rig-c/parts rig, five moments were found where it cannot follow. They were found by measuring the pose against the rig and checked against the video:
335
351
 
@@ -341,13 +357,15 @@ A flat painting turned by its bones matches each limb's direction, not its depth
341
357
  | S4 | 7.7–10.9 | hands crossed in front | which forearm is in front |
342
358
  | S5 | 11.1–13.0 | an arm thrust at the camera, then a V sign | foreshortening (0.63) and the shape of the hand; the arm mesh folds |
343
359
 
344
- The answer is a second drawing of the arm for each of those poses, shown in place of the bent mesh while the pose lasts. rig-pose chooses when to show it, keys the switch in the motion, and lists every fold no drawing covers ([#6](https://github.com/rig-c/pose/issues/6), [above](#alternate-drawings-alternates)).
360
+ The answer, since 0.2, is a second drawing of the arm for each of those poses, shown in place of the bent mesh while the pose lasts. rig-pose chooses when to show it, keys the switch in the motion, and lists every fold no drawing covers ([#6](https://github.com/rig-c/pose/issues/6), [above](#alternate-drawings-alternates)).
345
361
 
346
- ## What 0.1.0 does not do
362
+ ## What it does not do
347
363
 
348
- - **Extract poses from video.** The input is joint positions; how you get them (a pose model, a dataset, a hand) is yours.
349
- - **Turn the body.** 2D only: a segment pointing at the camera foreshortens, and the rig cannot. The direction is matched; the length is not.
350
- - **Plant feet** beyond what the pose gives (until `--plant-feet`, [#47](https://github.com/rig-c/pose/issues/47)), **hands and fingers** (no bones for them yet, rig-c/parts#171), mobile.
364
+ - **Extract poses from video.** The input is joint positions. How you get them is up to you: a pose model, a dataset, or by hand.
365
+ - **Turn the body.** It works in 2D only. A segment pointing at the camera foreshortens, and the rig cannot: the direction is matched, the length is not. With `--leg-scale`, leg segments are the exception.
366
+ - **Read a moving camera.** A reference whose camera zooms or pans moves the planted floor with it under `--plant-feet` ([#52](https://github.com/rig-c/pose/issues/52)).
367
+ - **Front and behind, blinks, mouth shapes.** The detector's scores and face points do not carry them ([#33](https://github.com/rig-c/pose/issues/33), [#46](https://github.com/rig-c/pose/issues/46)).
368
+ - **Hands and fingers.** There are no bones for them yet (rig-c/parts#171).
351
369
 
352
370
  ## Licence
353
371
 
package/cli.ts CHANGED
@@ -64,6 +64,12 @@ retarget
64
64
  travel and re-plants on landing; a supporting ankle moving > 0.03 leg lengths a frame
65
65
  releases its plant to the other foot, or to the pose's travel, until it is still
66
66
  (< 0.01 for 2 frames) (FEET lines)
67
+ --stance <leg lengths> with --plant-feet: on a frame outside the jumps, a free ankle within this many
68
+ leg lengths (FEET's) of the supporting one in the picture is planted too — its leg's
69
+ scalex keys stretched by k = (root y − floor y) / (root y − ankle y) until the foot
70
+ reaches the floor, raised or lowered, k within [0.92, 1.08] (past it, reported, not forced); above
71
+ it the foot is lifted and keeps the pose's height (STANCE lines; default: off; 0.07 is
72
+ the card's, rig-c/pose#60)
67
73
  --idle <name> the rig's animation read as its idle (default: idle). Every idle track the new
68
74
  animation does not key, on any channel, is listed as IDLE-DROPPED (a blink, a
69
75
  breath); one it keys is IDLE-KEPT-OUT, naming the track that keys it, and so is a
@@ -200,7 +206,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
200
206
  }
201
207
 
202
208
  function retargetArgs(argv: string[]): RetargetArgs {
203
- const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip'], ['coco', 'plant-feet', 'carry-idle']);
209
+ const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip', 'stance'], ['coco', 'plant-feet', 'carry-idle']);
204
210
  const bends = f.get('bends') ?? 'refuse';
205
211
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
206
212
  const switchAt = f.get('switch-at') ?? 'first';
@@ -225,6 +231,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
225
231
  short: shortOf(f),
226
232
  legScale: legScaleOf(f),
227
233
  plantFeet: f.has('plant-feet'),
234
+ stance: stanceOf(f),
228
235
  bends,
229
236
  bendTolerance: num(f, 'bend-tolerance', 0, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
230
237
  switchTolerance: num(f, 'switch-tolerance', 25, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
@@ -241,6 +248,14 @@ function retargetArgs(argv: string[]): RetargetArgs {
241
248
  };
242
249
  }
243
250
 
251
+ /** --stance (rig-c/pose#60): off unless given, and only with --plant-feet. */
252
+ function stanceOf(f: Map<string, string>): number | 'off' {
253
+ if (!f.has('stance') || f.get('stance') === 'off') return 'off';
254
+ const v = num(f, 'stance', 0, (n) => n > 0 && n < 1, 'off, or leg lengths in (0, 1)');
255
+ if (!f.has('plant-feet')) throw new Usage(`--stance is "${f.get('stance')}" without --plant-feet; it plants the free foot beside the supporting one that --plant-feet holds`);
256
+ return v;
257
+ }
258
+
244
259
  /** --reduce and --reduce-hip (rig-c/pose#32): off unless given; the hip only with the bones. */
245
260
  function reduceOf(f: Map<string, string>): { reduce?: number; reduceHip?: number } {
246
261
  if (!f.has('reduce')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.11.0",
3
+ "version": "0.12.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/legs.ts CHANGED
@@ -486,3 +486,107 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
486
486
  }
487
487
  return hip;
488
488
  }
489
+
490
+ // --- stance (rig-c/pose#60) --------------------------------------------------
491
+
492
+ /** A free leg is scaled within these factors to reach the floor (raised from below or lowered from above); past them the frame is reported, not forced. */
493
+ export const STANCE_BOUNDS: readonly [number, number] = [0.92, 1.08];
494
+
495
+ /** A run of frames with one free side in one state. */
496
+ export interface StanceRun {
497
+ /** `both`: the free ankle within the tolerance of the floor, so both feet are planted; `lifted`: above it, the pose's height kept. */
498
+ state: 'both' | 'lifted';
499
+ /** The side that is not supporting. */
500
+ side: Side;
501
+ frames: [number, number];
502
+ /** The free ankle's largest height above the supporting one in the picture, in that side's leg lengths. */
503
+ maxHeight: number;
504
+ /** `both`: the smallest and largest stretch keyed (bounded); `lifted`: null. */
505
+ k: { min: number; max: number } | null;
506
+ }
507
+
508
+ /** A run of planted-both frames whose stretch needed a factor past the bounds (or could not reach: `needed` null). */
509
+ export interface StanceOver {
510
+ side: Side;
511
+ frames: [number, number];
512
+ /** The stretch the floor needed furthest from 1, or null where the free ankle is not below its leg's root. */
513
+ needed: number | null;
514
+ /** The free ankle's largest distance left from the floor after the bound, world units. */
515
+ left: number;
516
+ }
517
+
518
+ export interface Stance {
519
+ /** The tolerance, in leg lengths (FEET's: the median picture hip → ankle of the free side). */
520
+ tolerance: number;
521
+ bounds: readonly [number, number];
522
+ runs: StanceRun[];
523
+ over: StanceOver[];
524
+ /** Per frame: the free side and the stretch keyed on it, or null (lifted, a jump, no support). */
525
+ stretch: Array<{ side: Side; k: number } | null>;
526
+ }
527
+
528
+ /** World positions (y up) on one frame: each leg chain's first bone's origin and each ankle. */
529
+ export interface StancePosed {
530
+ root: Record<Side, Pt>;
531
+ ankle: Record<Side, Pt>;
532
+ }
533
+
534
+ /**
535
+ * Both feet planted when the free ankle is near the floor (rig-c/pose#60). On a
536
+ * frame with a supporting side (so outside every jump), the free ankle's height
537
+ * above the supporting one is read in the picture (`ankle`, px, y down) in the
538
+ * free side's leg length — the unit of every FEET threshold. Within `tolerance`,
539
+ * the frame is planted both: the free leg is to be stretched along its chain by
540
+ * k = (root y − floor y) / (root y − ankle y) on the posed rig (`posed`, world,
541
+ * y up), the floor being the supporting ankle's posed y; k is held within
542
+ * `STANCE_BOUNDS` (a free ankle below the floor shortens its leg, one above
543
+ * lengthens it), and a frame past them is listed in `over`. Above the tolerance
544
+ * the foot is lifted and keeps the pose's height.
545
+ */
546
+ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number) => StancePosed): Stance {
547
+ const n = feet.support.length;
548
+ const stretch: Stance['stretch'] = new Array(n).fill(null);
549
+ type Row = { state: 'both' | 'lifted'; side: Side; height: number; k: number | null; needed: number | null; over: boolean; left: number };
550
+ const rows: Array<Row | null> = new Array<Row | null>(n).fill(null);
551
+ for (let i = 0; i < n; i++) {
552
+ const s = feet.support[i] ?? null;
553
+ if (s === null) continue;
554
+ const f: Side = s === 'r' ? 'l' : 'r';
555
+ const height = (ankle[s][i]![1] - ankle[f][i]![1]) / feet.legLength[f];
556
+ if (height > tolerance) {
557
+ rows[i] = { state: 'lifted', side: f, height, k: null, needed: null, over: false, left: 0 };
558
+ continue;
559
+ }
560
+ const p = posed(i);
561
+ const floor = p.ankle[s][1];
562
+ const root = p.root[f][1];
563
+ const a = p.ankle[f][1];
564
+ const ok = root - a > 1e-9 && root - floor > 1e-9;
565
+ const needed = ok ? (root - floor) / (root - a) : null;
566
+ const k = needed === null ? 1 : Math.min(Math.max(needed, STANCE_BOUNDS[0]), STANCE_BOUNDS[1]);
567
+ const over = needed === null || needed < STANCE_BOUNDS[0] || needed > STANCE_BOUNDS[1];
568
+ const left = Math.abs(root - k * (root - a) - floor);
569
+ rows[i] = { state: 'both', side: f, height, k, needed, over, left };
570
+ if (k !== 1) stretch[i] = { side: f, k };
571
+ }
572
+ const runs: StanceRun[] = [];
573
+ const over: StanceOver[] = [];
574
+ for (let i = 0; i < n; i++) {
575
+ const r = rows[i];
576
+ if (r === null) continue;
577
+ const last = runs[runs.length - 1];
578
+ if (last !== undefined && last.frames[1] === i - 1 && last.state === r.state && last.side === r.side) {
579
+ last.frames[1] = i;
580
+ last.maxHeight = Math.max(last.maxHeight, r.height);
581
+ if (last.k !== null && r.k !== null) last.k = { min: Math.min(last.k.min, r.k), max: Math.max(last.k.max, r.k) };
582
+ } else runs.push({ state: r.state, side: r.side, frames: [i, i], maxHeight: r.height, k: r.k === null ? null : { min: r.k, max: r.k } });
583
+ if (!r.over) continue;
584
+ const o = over[over.length - 1];
585
+ if (o !== undefined && o.frames[1] === i - 1 && o.side === r.side) {
586
+ o.frames[1] = i;
587
+ o.needed = o.needed === null || r.needed === null ? null : Math.abs(r.needed - 1) > Math.abs(o.needed - 1) ? r.needed : o.needed;
588
+ o.left = Math.max(o.left, r.left);
589
+ } else over.push({ side: r.side, frames: [i, i], needed: r.needed, left: r.left });
590
+ }
591
+ return { tolerance, bounds: STANCE_BOUNDS, runs, over, stretch };
592
+ }
package/src/retarget.ts CHANGED
@@ -69,8 +69,8 @@ import type { Joint } from './pose.ts';
69
69
  import { Problems, Refusal } from './problems.ts';
70
70
  import type { ArtRange, SlotTrack, Switch, SwitchOptions } from './switch.ts';
71
71
  import { clampArt, coveredChains, measureArt, overflow, RANGE_SLACK, runsOf, selectSwitches } from './switch.ts';
72
- import type { Feet, LegScale, PathPose, Posing, Side } from './legs.ts';
73
- import { isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, supportsOf, tipOf } from './legs.ts';
72
+ import type { Feet, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
73
+ import { isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, stanceOf, supportsOf, tipOf } from './legs.ts';
74
74
 
75
75
  const DEG = 180 / Math.PI;
76
76
 
@@ -91,6 +91,8 @@ export interface RetargetOptions extends SwitchOptions {
91
91
  legScale?: [number, number] | 'off';
92
92
  /** The hip derived from the supporting foot (`legs.ts`); false (the default) moves it by the pose's travel. */
93
93
  plantFeet?: boolean;
94
+ /** With plantFeet: a free ankle within this many leg lengths of the floor plants both feet, its leg stretched to reach it (`legs.ts` stanceOf, #60); 'off' (the default) plants one. */
95
+ stance?: number | 'off';
94
96
  }
95
97
 
96
98
  export interface Hold {
@@ -267,6 +269,8 @@ export interface Retarget {
267
269
  feet: Feet | null;
268
270
  /** Whether the hip was derived from the supporting foot. */
269
271
  planted: boolean;
272
+ /** With --stance: the frames planted both and lifted, and the free leg's stretch (#60). */
273
+ stance: Stance | null;
270
274
  /** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
271
275
  ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
272
276
  /** The translated bone's origin against its thigh roots' midpoint, at setup. */
@@ -638,6 +642,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
638
642
  const plantOn = opts.plantFeet === true;
639
643
  const legScale: LegScale[] = [];
640
644
  const scales = new Map<string, number[]>();
645
+ let stance: Stance | null = null;
641
646
  let feet: Feet | null = null;
642
647
  let legs: ReturnType<typeof legsOf> | null = null;
643
648
  let legPath: string[] | null = null;
@@ -692,6 +697,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
692
697
  }
693
698
  if (legScale.length === 0 && !p.any) p.add('LEGS_MAP', '--leg-scale scales the leg segments (two of one side\'s hip, knee and ankle), and the map aims none');
694
699
  }
700
+ if (opts.stance !== undefined && opts.stance !== 'off' && !plantOn) p.add('STANCE', `--stance ${opts.stance} plants the free foot beside the supporting one that --plant-feet holds, and --plant-feet is not given`);
695
701
  p.throwIfAny();
696
702
  /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
697
703
  const poseLegs = (i: number): PathPose => {
@@ -714,6 +720,31 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
714
720
  const tb = rest.get(t!.bone)!;
715
721
  const hip = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY]);
716
722
  for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
723
+ // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
724
+ // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
725
+ if (opts.stance !== undefined && opts.stance !== 'off') {
726
+ const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
727
+ const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
728
+ const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
729
+ stance = stanceOf(feet!, ankleSeries, opts.stance, (i) => {
730
+ const lp = poseLegs(i);
731
+ return {
732
+ root: { r: world(i, lp.origin.get(L.r.chain.bones[0]!)!), l: world(i, lp.origin.get(L.l.chain.bones[0]!)!) },
733
+ ankle: { r: world(i, tipOf(rest, lp, L.r.ankleBone)), l: world(i, tipOf(rest, lp, L.l.ankleBone)) },
734
+ };
735
+ });
736
+ for (const side of SIDES) {
737
+ if (!stance.stretch.some((x) => x?.side === side)) continue;
738
+ for (const b of L[side].chain.bones) {
739
+ const own = scales.get(b);
740
+ const s = own === undefined ? new Array<number>(n).fill(1) : [...own];
741
+ stance.stretch.forEach((x, i) => {
742
+ if (x?.side === side) s[i] = s[i]! * x.k;
743
+ });
744
+ scales.set(b, s);
745
+ }
746
+ }
747
+ }
717
748
  }
718
749
  if (t !== null && scale !== null) {
719
750
  const bone = rest.get(t.bone)!;
@@ -884,7 +915,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
884
915
  animation: { duration: keyTimes[n - 1]!, tracks },
885
916
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
886
917
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
887
- legScale, scales, feet, planted: plantOn, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs),
918
+ legScale, scales, feet, planted: plantOn, stance, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs),
888
919
  };
889
920
  }
890
921
 
package/src/run.ts CHANGED
@@ -17,7 +17,7 @@ import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch
17
17
  import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
18
18
  import { legsOf } from './legs.ts';
19
19
  import { reduceKeys, type ReduceBone, type Reduced } from './reduce.ts';
20
- import type { LegScale } from './legs.ts';
20
+ import type { LegScale, Stance } from './legs.ts';
21
21
 
22
22
  export interface RetargetArgs extends RetargetOptions {
23
23
  rig: string;
@@ -104,7 +104,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
104
104
  resolveMap(map, rest, a.map, slots);
105
105
 
106
106
  const r = retarget(rest, map, frames, times, a, slots);
107
- say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
107
+ say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
108
108
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
109
109
  if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
110
110
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
@@ -133,6 +133,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
133
133
  say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
134
134
  say(` FEET steps (the supporting ankle > 0.03 leg lengths a frame; re-planted after 2 frames < 0.01): ${f.steps.map((x) => `${x.side} ${span(x.frames)} → ${x.to === null ? 'the pose\'s travel' : `${x.to} supports`}, ${x.replant === null ? 'never re-planted' : `re-planted at ${x.replant}`}`).join('; ') || 'none'}`);
135
135
  }
136
+ if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
136
137
 
137
138
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
138
139
  const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
@@ -263,7 +264,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
263
264
  animation: a.name,
264
265
  frames: seq.frames.length,
265
266
  duration: r.animation.duration,
266
- options: { minScore: a.minScore, smooth: a.smooth, 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, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
267
+ options: { minScore: a.minScore, smooth: a.smooth, 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', idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
267
268
  scale: r.scale,
268
269
  holds: r.holds,
269
270
  short: r.short,
@@ -281,6 +282,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
281
282
  artRange: r.artRange,
282
283
  legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
283
284
  pivot: r.pivot,
285
+ stance: r.stance === null ? null : { tolerance: r.stance.tolerance, bounds: r.stance.bounds, runs: r.stance.runs, over: r.stance.over },
284
286
  feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, reading: feetReading },
285
287
  shear,
286
288
  idleDropped: idle === null ? null : idle.dropped,
@@ -395,6 +397,20 @@ function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { r
395
397
  return { r: { root: l.legs.r.chain.bones[0]!, ankle: l.legs.r.ankleBone }, l: { root: l.legs.l.chain.bones[0]!, ankle: l.legs.l.ankleBone } };
396
398
  }
397
399
 
400
+ /**
401
+ * The STANCE lines (#60): one per run of planted-both or lifted frames, with
402
+ * the tolerance and the stretch keyed; one per run a stretch was capped.
403
+ */
404
+ export function stanceLines(st: Stance, times: readonly number[]): string[] {
405
+ const span = (x: [number, number]) => (x[0] === x[1] ? `frame ${x[0]} (t=${times[x[0]]!.toFixed(3)}s)` : `frames ${x[0]}–${x[1]} (t=${times[x[0]]!.toFixed(3)}–${times[x[1]]!.toFixed(3)}s)`);
406
+ const tol = `the tolerance ${st.tolerance} leg lengths`;
407
+ const out = st.runs.map((r) => (r.state === 'both'
408
+ ? `planted both: free foot ${r.side} ${span(r.frames)} — its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture, within ${tol}; its leg stretched by k ${r.k!.min === r.k!.max ? r.k!.max.toFixed(4) : `${r.k!.min.toFixed(4)} … ${r.k!.max.toFixed(4)}`} (largest ${r.k!.max.toFixed(4)})`
409
+ : `lifted: free foot ${r.side} ${span(r.frames)} — its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture, over ${tol}; the pose's height kept`));
410
+ for (const o of st.over) out.push(`past the bounds: free foot ${o.side} ${span(o.frames)} — ${o.needed === null ? 'its ankle is not below its leg\'s root, so no stretch reaches the floor' : `the floor needed k ${o.needed.toFixed(4)}, past [${st.bounds[0]}, ${st.bounds[1]}]; held at the bound`}, the foot left up to ${o.left.toFixed(2)} units from the floor`);
411
+ return out;
412
+ }
413
+
398
414
  /** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
399
415
  function legScaleLine(l: LegScale, times: readonly number[]): string {
400
416
  const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);