@rig-c/pose 0.8.1 → 0.9.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
@@ -31,7 +31,7 @@ Nothing is written until every check passes:
31
31
  | Check | Fails with |
32
32
  | --- | --- |
33
33
  | rig-c's gate is green with the rig as it is, and again with the animation in (`rigc build`) | `RIG_GATE_BASE`, `RIG_GATE`, then rigc's own `FAIL` lines |
34
- | every mapped bone **lands** on the angle the pose asks for, on every frame — the gated build is posed by rig-c's poser and measured (0.01° / 0.01 units) | `POSE_LANDS`: the bone, the worst error, the frame |
34
+ | every mapped bone **lands** on the angle the pose asks for, on every frame — the gated build is posed by rig-c's poser and measured (0.01° / 0.01 units); with `--leg-scale`, every scaled bone's x axis on its asked scale (0.0001 of its setup length), and with either leg flag each ankle where the keys put it (0.01 units + 0.0001 × its setup distance from the hip) | `POSE_LANDS`: the bone, the worst error, the frame |
35
35
  | elbows and knees bend only the way they can: at every joint inside a chain, the inner angle stays on the side of the rest pose's | `POSE_BEND_ONE_WAY`: the chain, the joint, the frame, the angle |
36
36
  | every point the map needs is good on at least one frame | `POSE_POINT_NEVER_GOOD` |
37
37
  | the map resolves against the rig: bones exist, chains descend, aims descend | `MAP_*`, naming both sides |
@@ -50,9 +50,13 @@ and what the run cannot decide for you it lists:
50
50
  - `UNCOVERED` — with `alternates` in the map: every fold on a frame where no switch took the folding mesh off screen. This is the list of poses still to paint.
51
51
  - `RANGE` — a chain's `range` ([below](#a-joints-range-range)): a bone whose rotate key the pose asks past its range, outside every span of a set depicting its chain, is clamped at the limit; one line per run, with the frames, the seconds and the largest overflow. The demand for an alternate.
52
52
  - `ART-RANGE` — inside a set's span: a bone with a `drawn` angle in the set, turned from it past its limits — the set's `turn` for that bone (held at the limit) or, without one, its chain's `range` (the bone follows the pose). Listed as the pose asked it either way: the demand for another drawing of that set.
53
+ - `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
+ - `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
+ - `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.
56
+ - `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.
53
57
  - `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.
54
58
 
55
- `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance`, `switchMin` and `switchAt` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, and the demand lists:
59
+ `--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`, and the demand lists:
56
60
 
57
61
  - `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.
58
62
  - `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.
@@ -65,7 +69,9 @@ Runs in `turns`, `range` and `artRange` merge across a gap of one frame, as `end
65
69
 
66
70
  `--smooth <n>` smooths each joint's positions, after its holds, with a Savitzky–Golay filter: at every frame, the least-squares cubic over the *n* frames around it (clipped at the ends), read at that frame. It is centred, so it moves no motion in time, and a cubic keeps the peaks a moving average flattens. `1` (the default) is none; otherwise *n* is odd and at least 5, since a cubic through 3 points passes through all three.
67
71
 
68
- **Use `--smooth 7` at 24 fps.** It was measured on a 13 s dance on a @rig-c/parts rig, read back from the gated build: the motion's timing did not move (speed cross-correlation lag 0 on the wrists, ankles and hip), the frame-to-frame second difference dropped 2.5× on the wrists, 1.7–2.1× on the ankles and 1.5× on the hip, and the hip's 95th-percentile speed fell to 0.85×. The criterion set beforehand, a 2× drop on every point, was not met on the hip. That criterion was flawed: the hip's second difference is mostly real motion, while the filter cuts white noise 4.6×. A measure that isolates noise is a later card, and the default stays off until it passes.
72
+ **`--smooth 7` flattens the pelvis's rebound.** Measured on HQ's dancer with `--leg-scale 0.8,1.05 --plant-feet` ([#47](https://github.com/rig-c/pose/issues/47)): the rebound — the hip's peak upward acceleration at the bottom of each bounce, rig ÷ reference, median over a window, bar ≥ 0.7 — reads 0.40–0.52 with `--smooth 7` in every configuration measured (0.461 and 0.501 in the two windows, where `--smooth 1` reads 0.648 and 0.942). So the pelvis is read at `--smooth 1`, and `--smooth 7` is not advised for it. What follows is the earlier advice, for the limbs.
73
+
74
+ **`--smooth 7` at 24 fps, for the limbs.** It was measured on a 13 s dance on a @rig-c/parts rig, read back from the gated build: the motion's timing did not move (speed cross-correlation lag 0 on the wrists, ankles and hip), the frame-to-frame second difference dropped 2.5× on the wrists, 1.7–2.1× on the ankles and 1.5× on the hip, and the hip's 95th-percentile speed fell to 0.85×. The criterion set beforehand, a 2× drop on every point, was not met on the hip. That criterion was flawed: the hip's second difference is mostly real motion, while the filter cuts white noise 4.6×. A measure that isolates noise is a later card, and the default stays off until it passes.
69
75
 
70
76
  **The flip guard.** A pose model can put a fast sweep of a limb into one frame — under motion blur it takes the elbow or the wrist for another point — and the rig then whips the limb round between two keys. Position smoothing cannot remove a step like that. So after holds and smoothing, and before the hinge check, every segment the map aims (not a `reach`, which is aimed later from posed positions) is read frame to frame: a turn greater than `--flip <deg>` from frame k−1 to frame k is spread. For the smallest h ≥ 1 that keeps every one-frame turn at or under the threshold, frames k−h … k+h−1 are replaced by the straight line from frame k−h−1 to frame k+h, the turn's own way round. It is centred, so the step happens when the pose put it, over 2h frames, and both ends keep the pose's angles. A window that would cross the first or last frame stops there and the line says so; two windows that overlap become one. `--flip off` keeps every turn. The report lists each spread under `flips`.
71
77
 
@@ -84,6 +90,25 @@ Both have a meaningless direction; only the first is a pose the rig cannot draw.
84
90
 
85
91
  Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. On the second sequence the three spreads the flip guard made out of end-on segments are SHORT holds now, not FLIPs: right upper arm 399–400, right thigh 368–369, right shin 368–371. Of the flip guard's other spreads only one is left, the left forearm at 322–323; every other one fell on frames under the cutoff and is a SHORT hold instead. No guarded bone of the gated build turns more than 81.4° in one frame (85.1° with `--flip 90` alone), and every bone lands. `endOn` there lists the right upper arm (395–401) and the right shin (365–370). It does not list the right thigh, which is under 0.35 on one frame only (369, 0.16). The plan before the run expected the thigh too, and that expectation was wrong. On the dance `endOn` lists exactly S2's left upper arm (30–37); the three FLIPs are #15's, the largest one-frame turn is 78.2°, and every bone lands. A first implementation also held the lines across the body. It listed the eye line (head in profile) and the hip line (body side-on) as end-on, and holding the hip line narrowed the rig's hip tilt range to 39.3° against the pose's 125.2°. The cutoff was never measured on those lines, so they are excluded; with them out the rig reads 125.2° again. The default stays off: a default needs its own criterion and a third sequence.
86
92
 
93
+ ### The legs: foreshortening and planted feet
94
+
95
+ A knee bending toward the camera is seen from the front only as a shorter leg, and a rig with rigid legs cannot lower its hip without lifting its feet ([#47](https://github.com/rig-c/pose/issues/47)). Two flags, both off by default:
96
+
97
+ **`--leg-scale <min,max>`** scales each mapped leg segment (two of one side's hip, knee and ankle) along its own x axis — a `scalex` key — by its picture length on the frame over its **straight length**, the 95th percentile of its picture length over the sequence, clamped to [min, max]. The card's range is `0.8,1.05`. A segment the map aims other than `"x"` is refused (`LEGS_MAP`). `LEG-SCALE` lists each segment's frames clamped at either limit.
98
+
99
+ rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inherit mode takes its parent's whole world matrix. So under a thigh scaled by s, a shin at angle φ in the thigh's axes points at atan2(sin φ, s·cos φ), and is itself scaled by |(s·cos φ, sin φ)|. The rotate key `turn − turn above` assumes unit scale. A mapped bone under a scaled bone is therefore keyed through its parent's posed matrix: a `rotate` key k and a `scalex` key s' make its local matrix R(k) · L · diag(s', 1), L its setup local matrix, and k and s' are solved so its world x axis lands on its target at the scale asked. On HQ's dancer the unit-scale formula missed the shins by up to 2.4°, their length by 0.16 of setup and the ankles by 38 units; solved, every bone lands. The child's art is still sheared by its parent's scale, and a bone under it (a foot) is squashed along the shin: `SHEAR` reads both.
100
+
101
+ **`--plant-feet`** places the hip so the supporting foot stays where it took support, through the posed (and scaled) legs, instead of moving it by the pose's mid_hip travel:
102
+
103
+ - 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); a leg length is each side's median picture length hip → ankle, and the switch compares against their mean.
104
+ - The new support is held where it is on the frame it takes over, and the hip blends from the old derivation to the new over **3 frames**.
105
+ - 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.
106
+ - 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.
107
+ - `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`).
108
+ - 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.
109
+
110
+ 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.
111
+
87
112
  Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
88
113
 
89
114
  ## The pose: `rig-pose-sequence/1`
@@ -284,7 +309,7 @@ The answer is a second drawing of the arm for each of those poses, shown in plac
284
309
 
285
310
  - **Extract poses from video.** The input is joint positions; how you get them (a pose model, a dataset, a hand) is yours.
286
311
  - **Turn the body.** 2D only: a segment pointing at the camera foreshortens, and the rig cannot. The direction is matched; the length is not.
287
- - **Plant feet** beyond what the pose gives, **hands and fingers** (no bones for them yet, rig-c/parts#171), mobile.
312
+ - **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.
288
313
 
289
314
  ## Licence
290
315
 
package/cli.ts CHANGED
@@ -51,6 +51,19 @@ retarget
51
51
  --bends refuse|clamp a hinge (elbow, knee: any joint inside a chain) bent the wrong way refuses
52
52
  the run (default), or is straightened on that frame and listed
53
53
  --bend-tolerance <d> degrees past straight a hinge may bend the wrong way before it counts (default: 0)
54
+ --leg-scale <min,max>|off
55
+ each leg segment (two of one side's hip, knee, ankle) is scaled along its own x axis
56
+ (a scalex key) by its picture length over its straight length — the 95th percentile of
57
+ its picture length — clamped to [min, max], listed as LEG-SCALE with the frames clamped;
58
+ a bone under a scaled one is keyed through its parent's scale (default: off; 0.8,1.05
59
+ is the card's range, rig-c/pose#47)
60
+ --plant-feet the hip is placed so the supporting foot stays where it took support: the lower ankle
61
+ in the picture, changing after the other has been 0.02 leg lengths lower for 0.1 s,
62
+ blended over 3 frames; a jump (both ankles and mid_hip 0.05 leg lengths above their
63
+ rolling floor — the 95th-percentile y over ±1 s — for ≥ 2 frames) follows the pose's
64
+ travel and re-plants on landing; a supporting ankle moving > 0.03 leg lengths a frame
65
+ releases its plant to the other foot, or to the pose's travel, until it is still
66
+ (< 0.01 for 2 frames) (FEET lines)
54
67
  --switch-tolerance <d> a set of alternate drawings (the map's "alternates") may show on a frame where
55
68
  every segment of its chains is within d degrees of its key skeleton (default: 25)
56
69
  --switch-min <s> a run of one set shorter than s seconds is dropped (default: 0.25)
@@ -155,8 +168,18 @@ function shortOf(f: Map<string, string>): number | 'off' {
155
168
  return num(f, 'short', 0.35, (v) => v > 0 && v < 1, 'off, or a fraction in (0, 1) of the segment\'s median picture length');
156
169
  }
157
170
 
171
+ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
172
+ const v = f.get('leg-scale');
173
+ if (v === undefined || v === 'off') return 'off';
174
+ const parts = v.split(',').map((x) => (x.trim() === '' ? NaN : Number(x)));
175
+ if (parts.length !== 2 || !parts.every((x) => Number.isFinite(x)) || !(parts[0]! > 0) || parts[0]! > 1 || parts[1]! < 1) {
176
+ throw new Usage(`--leg-scale is "${v}"; it must be off, or min,max with 0 < min ≤ 1 ≤ max (the scale of a leg segment against its straight length, e.g. 0.8,1.05)`);
177
+ }
178
+ return [parts[0]!, parts[1]!];
179
+ }
180
+
158
181
  function retargetArgs(argv: string[]): RetargetArgs {
159
- 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'], ['coco']);
182
+ 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'], ['coco', 'plant-feet']);
160
183
  const bends = f.get('bends') ?? 'refuse';
161
184
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
162
185
  const switchAt = f.get('switch-at') ?? 'first';
@@ -179,6 +202,8 @@ function retargetArgs(argv: string[]): RetargetArgs {
179
202
  smooth: smoothOf(f),
180
203
  flip: flipOf(f),
181
204
  short: shortOf(f),
205
+ legScale: legScaleOf(f),
206
+ plantFeet: f.has('plant-feet'),
182
207
  bends,
183
208
  bendTolerance: num(f, 'bend-tolerance', 0, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
184
209
  switchTolerance: num(f, 'switch-tolerance', 25, (n) => n >= 0 && n < 180, 'degrees in [0, 180)'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.8.1",
3
+ "version": "0.9.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 ADDED
@@ -0,0 +1,488 @@
1
+ /**
2
+ * The legs (rig-c/pose#47): foreshortening by length, and the hip derived from
3
+ * the supporting foot. Pure — no file, no clock, no process.
4
+ *
5
+ * **Leg scale.** A knee bending toward the camera is seen from the front only
6
+ * as a shorter leg. Each mapped leg segment (two of one side's hip, knee and
7
+ * ankle) is scaled along its own x axis (`scalex`): its picture length on the
8
+ * frame over its *straight length*, the 95th percentile of its picture length
9
+ * over the sequence, clamped to a declared range. The frames clamped are listed.
10
+ *
11
+ * **A scaled parent turns its child.** rig-c's `scalex` multiplies the bone's
12
+ * x axis, and a child with the `normal` inherit mode takes the parent's whole
13
+ * world matrix — so under a thigh scaled by s, a shin at angle φ in the thigh's
14
+ * axes points at atan2(sin φ, s·cos φ), not φ, and its own length is scaled by
15
+ * |(s·cos φ, sin φ)|. The rotate key `turn − turn above` assumes unit scale. A
16
+ * mapped bone below a scaled bone is therefore keyed through its parent's
17
+ * posed matrix (`posePath`): with rig-c's semantics, a `rotate` key k and a
18
+ * `scalex` key s' make the bone's local matrix R(k) · L · diag(s', 1), L its
19
+ * setup local matrix (from rig-c's setup world matrices), so k and s' are the
20
+ * ones that put its world x axis on the target angle at the asked length. Its
21
+ * art is still sheared by the parent's scale; that is measured, not hidden
22
+ * (`shear` in the report).
23
+ *
24
+ * **Planted feet.** The hip is no longer the pose's mid_hip travel scaled. On
25
+ * every frame one foot supports: the one whose reference ankle is lower in the
26
+ * picture, changing only when the other ankle has been ≥ `PLANT_SWITCH` leg
27
+ * lengths lower for ≥ `PLANT_SWITCH_SECONDS`. The supporting foot is held where
28
+ * it was on the frame it took support, and the hip is placed so it stays there,
29
+ * through the posed (scaled) legs. A change of support blends the two
30
+ * derivations over `PLANT_BLEND` frames. A jump — both ankles and mid_hip more
31
+ * than `JUMP_RISE` leg lengths above their rolling floor (the 95th-percentile
32
+ * picture y over ± `FLOOR_SECONDS`), for ≥ `JUMP_FRAMES` frames — releases the
33
+ * plant: the hip follows the pose's travel from where it was, and re-plants on
34
+ * landing. A step does too: while the supporting ankle moves more than
35
+ * `STEP_SPEED` leg lengths a frame its plant is released, the other foot takes
36
+ * support if it is planted (not released, not moving) and otherwise the hip
37
+ * follows the pose; the released ankle may support again once it has moved
38
+ * less than `STILL_SPEED` for `STILL_FRAMES` frames (rig-c/pose#47, round 2).
39
+ * The floor is rolling because an ankle's picture y drifts with depth: under
40
+ * one floor for the sequence, HQ's dancer standing still read as jumping.
41
+ */
42
+ import type { Chain, JointMap, Rest } from './map.ts';
43
+
44
+ type Pt = [number, number];
45
+ /** A world 2×2: columns (a, c) — the x axis — and (b, d) — the y axis. */
46
+ export type M2 = [number, number, number, number];
47
+
48
+ const DEG = 180 / Math.PI;
49
+
50
+ /** A change of support needs the other ankle this many leg lengths lower … */
51
+ export const PLANT_SWITCH = 0.02;
52
+ /** … for at least this long, in seconds (first frame's time to the end of the last, as END-ON times a run). */
53
+ export const PLANT_SWITCH_SECONDS = 0.1;
54
+ /** A change of support (and a landing) blends the hip over this many frames. */
55
+ export const PLANT_BLEND = 3;
56
+ /** Both ankles and mid_hip more than this many leg lengths above their rolling floors: a jump. */
57
+ export const JUMP_RISE = 0.05;
58
+ /** A rolling floor: this percentile of the picture y around the frame. */
59
+ export const FLOOR_PERCENTILE = 0.95;
60
+ /** A segment's straight length: this percentile of its picture length over the sequence. */
61
+ export const STRAIGHT_PERCENTILE = 0.95;
62
+
63
+ export type Side = 'r' | 'l';
64
+ export const SIDES: readonly Side[] = ['r', 'l'];
65
+
66
+ /** A segment of one side's hip, knee and ankle. */
67
+ export function isLegSegment(a: string, b: string): boolean {
68
+ const side = (x: string) => (x.startsWith('r_') ? 'r' : x.startsWith('l_') ? 'l' : null);
69
+ if (a === b || side(a) === null || side(a) !== side(b)) return false;
70
+ const LEG = ['hip', 'knee', 'ankle'];
71
+ return LEG.includes(a.slice(2)) && LEG.includes(b.slice(2));
72
+ }
73
+
74
+ /** Linear-interpolated percentile (numpy's default) of the finite values. */
75
+ export function percentile(xs: readonly number[], q: number): number {
76
+ const s = xs.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
77
+ if (s.length === 0) return NaN;
78
+ const pos = q * (s.length - 1);
79
+ const lo = Math.floor(pos);
80
+ const hi = Math.min(s.length - 1, lo + 1);
81
+ return s[lo]! + (s[hi]! - s[lo]!) * (pos - lo);
82
+ }
83
+
84
+ export interface Leg {
85
+ side: Side;
86
+ chain: Chain;
87
+ /** The chain's last bone: its x axis ends at the ankle (`<bone>:tip`). */
88
+ ankleBone: string;
89
+ }
90
+
91
+ /**
92
+ * Each side's leg: the (non-reach) chain whose last joint is that side's
93
+ * ankle. Every refusal names what it found.
94
+ */
95
+ export function legsOf(map: JointMap, why: string): { legs: Record<Side, Leg> } | { problems: string[] } {
96
+ const problems: string[] = [];
97
+ const legs: Partial<Record<Side, Leg>> = {};
98
+ for (const side of SIDES) {
99
+ const ankle = `${side}_ankle`;
100
+ const found = map.chains.filter((c) => !c.reach && c.joints[c.joints.length - 1] === ankle);
101
+ if (found.length !== 1) {
102
+ problems.push(`${why} needs one chain ending at "${ankle}" (its last bone's x axis ends at the ankle); the map has ${found.length === 0 ? 'none' : found.map((c) => `"${c.name}"`).join(', ')}`);
103
+ continue;
104
+ }
105
+ const chain = found[0]!;
106
+ const k = chain.bones.length - 1;
107
+ if (chain.aim[k] !== 'x') problems.push(`${why}: chain "${chain.name}" ends at "${ankle}" with bone "${chain.bones[k]}" aimed ${JSON.stringify(chain.aim[k])}; the ankle is read as that bone's tip, so it must be aimed along its own x axis ("x")`);
108
+ legs[side] = { side, chain, ankleBone: chain.bones[k]! };
109
+ }
110
+ return problems.length > 0 ? { problems } : { legs: legs as Record<Side, Leg> };
111
+ }
112
+
113
+ // --- 2×2 -------------------------------------------------------------------
114
+
115
+ const mul = (p: M2, q: M2): M2 => [p[0] * q[0] + p[1] * q[2], p[0] * q[1] + p[1] * q[3], p[2] * q[0] + p[3] * q[2], p[2] * q[1] + p[3] * q[3]];
116
+ const inv = (m: M2): M2 => {
117
+ const det = m[0] * m[3] - m[1] * m[2];
118
+ return [m[3] / det, -m[1] / det, -m[2] / det, m[0] / det];
119
+ };
120
+ const apply = (m: M2, v: Pt): Pt => [m[0] * v[0] + m[1] * v[1], m[2] * v[0] + m[3] * v[1]];
121
+ const rot = (deg: number): M2 => {
122
+ const r = deg / DEG;
123
+ return [Math.cos(r), -Math.sin(r), Math.sin(r), Math.cos(r)];
124
+ };
125
+ const scaleX = (s: number): M2 => [s, 0, 0, 1];
126
+ export const restMatrix = (rest: Rest, bone: string): M2 => {
127
+ const b = rest.get(bone)!;
128
+ return [b.a, b.b, b.c, b.d];
129
+ };
130
+
131
+ /**
132
+ * How one mapped bone is posed on a frame: its world turn from setup (the
133
+ * target less the rest angle), its target (the world angle of its aim), whether
134
+ * its aim is its own x axis, and the scale asked of its x axis (1: none).
135
+ */
136
+ export interface Posing {
137
+ turn: number;
138
+ target: number;
139
+ aimX: boolean;
140
+ }
141
+
142
+ export interface PathPose {
143
+ /** Each bone's posed origin, relative to the first bone's origin. */
144
+ origin: Map<string, Pt>;
145
+ /** Each bone's posed world 2×2. */
146
+ m: Map<string, M2>;
147
+ /** For a mapped bone under a scaled bone: the rotate key (degrees, wrapped) and the scalex key that land it. */
148
+ solved: Map<string, { rotate: number; scale: number }>;
149
+ }
150
+
151
+ /**
152
+ * Pose a path of bones, parents first, the first the path's top (its origin is
153
+ * (0, 0)). A mapped bone with no scaled bone above it turns rigidly from setup
154
+ * (R(turn) · setup world · diag(scale, 1)); a mapped bone under a scaled bone
155
+ * is solved through its parent's posed matrix (the header); an unmapped bone
156
+ * keeps its setup local matrix. Every setup matrix and origin is rig-c's.
157
+ */
158
+ export function posePath(rest: Rest, path: readonly string[], posing: (b: string) => Posing | null, scaleOf: (b: string) => number | null): PathPose | string {
159
+ const origin = new Map<string, Pt>();
160
+ const m = new Map<string, M2>();
161
+ const solved = new Map<string, { rotate: number; scale: number }>();
162
+ const scaledAbove = new Set<string>();
163
+ for (const b of path) {
164
+ const rb = rest.get(b)!;
165
+ const W0 = restMatrix(rest, b);
166
+ const pz = posing(b);
167
+ const s = scaleOf(b) ?? 1;
168
+ const p = rb.parent;
169
+ const top = origin.size === 0;
170
+ if (top || p === null || !m.has(p)) {
171
+ if (!top) return `bone "${b}" is on the path but its parent "${p}" is not`;
172
+ origin.set(b, [0, 0]);
173
+ m.set(b, mul(mul(rot(pz?.turn ?? 0), W0), scaleX(s)));
174
+ if (scaleOf(b) !== null) scaledAbove.add(b);
175
+ continue;
176
+ }
177
+ const Wp0 = restMatrix(rest, p);
178
+ const Wp = m.get(p)!;
179
+ const rp = rest.get(p)!;
180
+ const local = apply(inv(Wp0), [rb.worldX - rp.worldX, rb.worldY - rp.worldY]);
181
+ const po = origin.get(p)!;
182
+ const o = apply(Wp, local);
183
+ origin.set(b, [po[0] + o[0], po[1] + o[1]]);
184
+ const L0 = mul(inv(Wp0), W0);
185
+ const under = scaledAbove.has(p);
186
+ if (pz === null) m.set(b, mul(Wp, L0));
187
+ else if (!under) m.set(b, mul(mul(rot(pz.turn), W0), scaleX(s)));
188
+ else {
189
+ if (!pz.aimX) return `bone "${b}" is mapped under a scaled bone and aimed other than along its x axis; only an "x" aim is solved through a scaled parent`;
190
+ const len0 = Math.hypot(W0[0], W0[2]);
191
+ const X: Pt = [s * len0 * Math.cos(pz.target / DEG), s * len0 * Math.sin(pz.target / DEG)];
192
+ const v = apply(inv(Wp), X);
193
+ const l0: Pt = [L0[0], L0[2]];
194
+ const k = (Math.atan2(v[1], v[0]) - Math.atan2(l0[1], l0[0])) * DEG;
195
+ const sk = Math.hypot(v[0], v[1]) / Math.hypot(l0[0], l0[1]);
196
+ m.set(b, mul(mul(mul(Wp, rot(k)), L0), scaleX(sk)));
197
+ solved.set(b, { rotate: k, scale: sk });
198
+ }
199
+ if (under || scaleOf(b) !== null) scaledAbove.add(b);
200
+ }
201
+ return { origin, m, solved };
202
+ }
203
+
204
+ /** Where `bone`'s x axis ends on a posed path: its origin plus its world x column times its length. */
205
+ export function tipOf(rest: Rest, pose: PathPose, bone: string): Pt {
206
+ const o = pose.origin.get(bone)!;
207
+ const w = pose.m.get(bone)!;
208
+ const len = rest.get(bone)!.length;
209
+ return [o[0] + w[0] * len, o[1] + w[2] * len];
210
+ }
211
+
212
+ /** The bones from `top` down to each of `ends` (inclusive), parents first; null when an end does not descend from top. */
213
+ export function pathFrom(rest: Rest, top: string, ends: readonly string[]): string[] | null {
214
+ const set = new Set<string>([top]);
215
+ for (const e of ends) {
216
+ const up: string[] = [];
217
+ let b: string | null = e;
218
+ while (b !== null && b !== top) {
219
+ up.push(b);
220
+ b = rest.get(b)!.parent;
221
+ }
222
+ if (b === null) return null;
223
+ for (const x of up) set.add(x);
224
+ }
225
+ const depth = (b: string): number => {
226
+ let d = 0;
227
+ for (let a = rest.get(b)!.parent; a !== null; a = rest.get(a)!.parent) d++;
228
+ return d;
229
+ };
230
+ return [...set].sort((x, y) => depth(x) - depth(y) || (x < y ? -1 : x > y ? 1 : 0));
231
+ }
232
+
233
+ /** The root of `b`'s ancestry. */
234
+ export function rootOf(rest: Rest, b: string): string {
235
+ let x = b;
236
+ for (let p = rest.get(x)!.parent; p !== null; p = rest.get(x)!.parent) x = p;
237
+ return x;
238
+ }
239
+
240
+ // --- leg scale -------------------------------------------------------------
241
+
242
+ export interface LegScale {
243
+ chain: string;
244
+ bone: string;
245
+ joints: [string, string];
246
+ /** The straight length: the 95th percentile of the segment's picture length, px. */
247
+ straight: number;
248
+ /** The picture length over the straight length, before the clamp: lowest, median, highest. */
249
+ ratio: { min: number; median: number; max: number };
250
+ /** The range the scale is clamped to. */
251
+ limits: [number, number];
252
+ /** Runs of frames clamped at the low and at the high limit. */
253
+ low: Array<[number, number]>;
254
+ high: Array<[number, number]>;
255
+ /** The scale keyed on each frame (clamped). */
256
+ scale: number[];
257
+ }
258
+
259
+ /** One segment's scale per frame: picture length ÷ its 95th percentile, clamped; and the runs clamped. */
260
+ export function legScaleOf(lengths: readonly number[], limits: [number, number]): Omit<LegScale, 'chain' | 'bone' | 'joints'> {
261
+ const straight = percentile(lengths, STRAIGHT_PERCENTILE);
262
+ const ratio = lengths.map((l) => l / straight);
263
+ const scale = ratio.map((r) => Math.min(Math.max(r, limits[0]), limits[1]));
264
+ const sorted = [...ratio].sort((a, b) => a - b);
265
+ const mid = sorted.length >> 1;
266
+ const median = sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
267
+ return {
268
+ straight,
269
+ ratio: { min: sorted[0]!, median, max: sorted[sorted.length - 1]! },
270
+ limits,
271
+ low: runs(ratio.map((r) => r < limits[0])),
272
+ high: runs(ratio.map((r) => r > limits[1])),
273
+ scale,
274
+ };
275
+ }
276
+
277
+ function runs(flags: readonly boolean[]): Array<[number, number]> {
278
+ const out: Array<[number, number]> = [];
279
+ for (let i = 0; i < flags.length; i++) {
280
+ if (!flags[i]) continue;
281
+ let j = i;
282
+ while (j + 1 < flags.length && flags[j + 1]) j++;
283
+ out.push([i, j]);
284
+ i = j;
285
+ }
286
+ return out;
287
+ }
288
+
289
+ // --- supports, jumps and steps ---------------------------------------------
290
+
291
+ /** The points a jump reads, each above its rolling floor by more than `JUMP_RISE` leg lengths. */
292
+ export const JUMP_POINTS = ['r_ankle', 'l_ankle', 'mid_hip'] as const;
293
+ /** A jump lasts at least this many frames. */
294
+ export const JUMP_FRAMES = 2;
295
+ /** A rolling floor: `FLOOR_PERCENTILE` of the picture y over ± this many seconds around the frame. */
296
+ export const FLOOR_SECONDS = 1.0;
297
+ /** A supporting ankle moving more than this many leg lengths a frame is stepping: its plant is released. */
298
+ export const STEP_SPEED = 0.03;
299
+ /** A released ankle re-plants once it moves less than this many leg lengths a frame … */
300
+ export const STILL_SPEED = 0.01;
301
+ /** … for this many frames. */
302
+ export const STILL_FRAMES = 2;
303
+
304
+ export interface Step {
305
+ side: Side;
306
+ /** First frame the ankle moved past `STEP_SPEED`, and the last frame before it re-planted (or the last frame). */
307
+ frames: [number, number];
308
+ /** The frame it re-planted on (still for `STILL_FRAMES`), or null if it never did. */
309
+ replant: number | null;
310
+ /** The side that took support when it released, or null: the hip followed the pose's travel. */
311
+ to: Side | null;
312
+ }
313
+
314
+ export interface Feet {
315
+ /** Each side's median picture length hip → ankle, px; the unit of every threshold below (mid_hip: their mean). */
316
+ legLength: Record<Side, number>;
317
+ /** Per frame: the side that supports, or null (a jump, or a step with no other foot planted). */
318
+ support: Array<Side | null>;
319
+ /** Runs of one supporting side: [first, last] frames and the side. */
320
+ supports: Array<{ side: Side; frames: [number, number] }>;
321
+ /** Runs of jump frames. */
322
+ jumps: Array<[number, number]>;
323
+ /** Every release of a supporting foot by a step. */
324
+ steps: Step[];
325
+ /** The frames a change of support (or a landing, or a re-plant) was keyed on: the blend starts there. */
326
+ changes: Array<{ frame: number; from: Side | null; to: Side }>;
327
+ }
328
+
329
+ /** Each frame's rolling floor of a picture-y series: its `FLOOR_PERCENTILE` over the frames within ± `FLOOR_SECONDS`. */
330
+ export function rollingFloor(ys: readonly number[], times: readonly number[]): number[] {
331
+ return ys.map((_, i) => {
332
+ const w: number[] = [];
333
+ for (let j = 0; j < ys.length; j++) if (Math.abs(times[j]! - times[i]!) <= FLOOR_SECONDS + 1e-9) w.push(ys[j]!);
334
+ return percentile(w, FLOOR_PERCENTILE);
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Which foot supports on each frame, from the reference ankles and hips (picture
340
+ * px, y down) — the header's rules. `times` are the key times; `secondsOf`
341
+ * times a run of frames as the rest of the retarget does.
342
+ */
343
+ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, times: readonly number[], secondsOf: (run: [number, number]) => number): Feet {
344
+ const n = ankle.r.length;
345
+ const legLength = {} as Record<Side, number>;
346
+ for (const s of SIDES) legLength[s] = percentile(ankle[s].map((a, i) => Math.hypot(a[0] - hip[s][i]![0], a[1] - hip[s][i]![1])), 0.5);
347
+ const mean = (legLength.r + legLength.l) / 2;
348
+ // A jump: both ankles and mid_hip above their rolling floors, for ≥ JUMP_FRAMES frames.
349
+ const midY = hip.r.map((h, i) => (h[1] + hip.l[i]![1]) / 2);
350
+ const rises: Array<{ ys: number[]; unit: number }> = [
351
+ { ys: ankle.r.map((a) => a[1]), unit: legLength.r },
352
+ { ys: ankle.l.map((a) => a[1]), unit: legLength.l },
353
+ { ys: midY, unit: mean },
354
+ ];
355
+ const up = rises.map(({ ys, unit }) => {
356
+ const floor = rollingFloor(ys, times);
357
+ return ys.map((y, i) => floor[i]! - y > JUMP_RISE * unit);
358
+ });
359
+ const lifted = Array.from({ length: n }, (_, i) => up.every((u) => u[i]));
360
+ const jumps = runs(lifted).filter(([a, b]) => b - a + 1 >= JUMP_FRAMES);
361
+ const jump = new Array<boolean>(n).fill(false);
362
+ for (const [a, b] of jumps) for (let i = a; i <= b; i++) jump[i] = true;
363
+ // Ankle speeds, leg lengths a frame (0 on the first frame).
364
+ const speed = {} as Record<Side, number[]>;
365
+ for (const s of SIDES) speed[s] = ankle[s].map((a, i) => (i === 0 ? 0 : Math.hypot(a[0] - ankle[s][i - 1]![0], a[1] - ankle[s][i - 1]![1]) / legLength[s]));
366
+ const moving = (s: Side, i: number) => speed[s][i]! > STEP_SPEED;
367
+ const still = (s: Side, i: number) => {
368
+ for (let k = i - STILL_FRAMES + 1; k <= i; k++) if (k >= 0 && !(speed[s][k]! < STILL_SPEED)) return false;
369
+ return true;
370
+ };
371
+ const support: Array<Side | null> = new Array<Side | null>(n).fill(null);
372
+ const changes: Feet['changes'] = [];
373
+ const steps: Step[] = [];
374
+ const open: Partial<Record<Side, Step>> = {};
375
+ let cur: Side | null = null;
376
+ let lowerFrom = -1;
377
+ const lower = (cands: Side[], i: number): Side => (cands.length === 2 ? (ankle.l[i]![1] > ankle.r[i]![1] ? 'l' : 'r') : cands[0]!);
378
+ for (let i = 0; i < n; i++) {
379
+ // A released ankle re-plants once it has been still for STILL_FRAMES frames.
380
+ for (const s of SIDES) {
381
+ const st = open[s];
382
+ if (st !== undefined && still(s, i)) {
383
+ st.replant = i;
384
+ st.frames[1] = i - 1;
385
+ delete open[s];
386
+ }
387
+ }
388
+ if (jump[i]) {
389
+ cur = null;
390
+ lowerFrom = -1;
391
+ continue;
392
+ }
393
+ const free = (s: Side) => open[s] === undefined && !moving(s, i);
394
+ if (cur !== null && moving(cur, i)) {
395
+ const other: Side = cur === 'r' ? 'l' : 'r';
396
+ const to: Side | null = free(other) ? other : null;
397
+ const st: Step = { side: cur, frames: [i, n - 1], replant: null, to };
398
+ steps.push(st);
399
+ open[cur] = st;
400
+ if (to !== null) changes.push({ frame: i, from: cur, to });
401
+ cur = to;
402
+ lowerFrom = -1;
403
+ } else if (cur === null) {
404
+ const cands = SIDES.filter(free);
405
+ if (cands.length > 0) {
406
+ cur = lower(cands, i);
407
+ changes.push({ frame: i, from: null, to: cur });
408
+ lowerFrom = -1;
409
+ }
410
+ } else {
411
+ const other: Side = cur === 'r' ? 'l' : 'r';
412
+ if (free(other) && ankle[other][i]![1] - ankle[cur][i]![1] >= PLANT_SWITCH * mean) {
413
+ if (lowerFrom < 0) lowerFrom = i;
414
+ if (secondsOf([lowerFrom, i]) >= PLANT_SWITCH_SECONDS - 1e-9) {
415
+ changes.push({ frame: i, from: cur, to: other });
416
+ cur = other;
417
+ lowerFrom = -1;
418
+ }
419
+ } else lowerFrom = -1;
420
+ }
421
+ support[i] = cur;
422
+ }
423
+ const supports: Feet['supports'] = [];
424
+ for (let i = 0; i < n; i++) {
425
+ const s = support[i];
426
+ if (s === null || s === undefined) continue;
427
+ const last = supports[supports.length - 1];
428
+ if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
429
+ else supports.push({ side: s, frames: [i, i] });
430
+ }
431
+ return { legLength, support, supports, jumps, steps, changes };
432
+ }
433
+
434
+ /**
435
+ * The hip's world origin on every frame, from the supporting foot (header).
436
+ * `offset(i, side)` is the ankle's posed position relative to the hip's origin
437
+ * on frame i; `travel(i)` the pose's own travel of the hip from frame 0, world.
438
+ * `start` is the hip's origin on frame 0 (its setup origin).
439
+ */
440
+ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: number) => Pt, start: Pt): Pt[] {
441
+ const n = feet.support.length;
442
+ const hip: Pt[] = [];
443
+ /** How the hip is derived on a frame: held over a foot's anchor, or carried by the pose's travel from a frame's hip. */
444
+ type Mode = { kind: 'plant'; side: Side; anchor: Pt } | { kind: 'travel'; from: number; at: Pt };
445
+ const of = (mode: Mode, i: number): Pt => {
446
+ if (mode.kind === 'plant') {
447
+ const o = offset(i, mode.side);
448
+ return [mode.anchor[0] - o[0], mode.anchor[1] - o[1]];
449
+ }
450
+ const a = travel(i);
451
+ const b = travel(mode.from);
452
+ return [mode.at[0] + a[0] - b[0], mode.at[1] + a[1] - b[1]];
453
+ };
454
+ let mode: Mode | null = null;
455
+ let from: Mode | null = null;
456
+ let blendAt = -1;
457
+ for (let i = 0; i < n; i++) {
458
+ const s = feet.support[i] ?? null;
459
+ if (i === 0) {
460
+ if (s === null) mode = { kind: 'travel', from: 0, at: start };
461
+ else {
462
+ const o = offset(0, s);
463
+ mode = { kind: 'plant', side: s, anchor: [start[0] + o[0], start[1] + o[1]] };
464
+ }
465
+ } else if (s === null && mode!.kind === 'plant') {
466
+ mode = { kind: 'travel', from: i - 1, at: hip[i - 1]! };
467
+ from = null;
468
+ blendAt = -1;
469
+ } else if (s !== null && (mode!.kind === 'travel' || (mode as { side: Side }).side !== s)) {
470
+ // The new support is held where it is on this frame, under the hip the old derivation gives here.
471
+ const here = of(mode!, i);
472
+ const o = offset(i, s);
473
+ from = mode;
474
+ mode = { kind: 'plant', side: s, anchor: [here[0] + o[0], here[1] + o[1]] };
475
+ blendAt = i;
476
+ }
477
+ const to = of(mode!, i);
478
+ if (from !== null && i - blendAt < PLANT_BLEND) {
479
+ const w = (i - blendAt + 1) / (PLANT_BLEND + 1);
480
+ const f = of(from, i);
481
+ hip.push([f[0] + (to[0] - f[0]) * w, f[1] + (to[1] - f[1]) * w]);
482
+ } else {
483
+ from = null;
484
+ hip.push(to);
485
+ }
486
+ }
487
+ return hip;
488
+ }
package/src/retarget.ts CHANGED
@@ -64,11 +64,13 @@ import { screenToSpineDegrees } from 'rig-c/transform';
64
64
  import type { Body18 } from './joints.ts';
65
65
  import { isBody18, VIRTUAL } from './joints.ts';
66
66
  import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
67
- import { aimAngle, rigPoint } from './map.ts';
67
+ import { aimAngle, descends, rigPoint } from './map.ts';
68
68
  import type { Joint } from './pose.ts';
69
- import { Problems } from './problems.ts';
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
74
 
73
75
  const DEG = 180 / Math.PI;
74
76
 
@@ -85,6 +87,10 @@ export interface RetargetOptions extends SwitchOptions {
85
87
  flip: number | 'off';
86
88
  /** A segment shorter in the picture than this fraction of its median length has its angle held there (`holdShort`); 'off' reads every frame. */
87
89
  short: number | 'off';
90
+ /** Leg segments scaled along their x axis by picture length over straight length, clamped to [min, max] (`legs.ts`); 'off' (the default) scales nothing. */
91
+ legScale?: [number, number] | 'off';
92
+ /** The hip derived from the supporting foot (`legs.ts`); false (the default) moves it by the pose's travel. */
93
+ plantFeet?: boolean;
88
94
  }
89
95
 
90
96
  export interface Hold {
@@ -170,7 +176,7 @@ export interface MotionKey {
170
176
  }
171
177
  export interface MotionTrack {
172
178
  bone: string;
173
- property: 'rotate' | 'translate';
179
+ property: 'rotate' | 'translate' | 'scalex';
174
180
  keys: MotionKey[];
175
181
  }
176
182
  export interface Animation {
@@ -251,6 +257,29 @@ export interface Retarget {
251
257
  range: RangeDemand[];
252
258
  /** Every run a bone inside a span turned from its drawn angle past its range. */
253
259
  artRange: ArtRange[];
260
+ /** With --leg-scale: each leg segment's scale, straight length and clamped runs. */
261
+ legScale: LegScale[];
262
+ /** Per scaled bone, the scale its x axis must reach on each frame (relative to setup); the scalex key of a bone under a scaled bone differs from it. */
263
+ scales: Map<string, number[]>;
264
+ /** The supports and jumps read off the reference ankles, when the map has both legs (whether or not --plant-feet used them). */
265
+ feet: Feet | null;
266
+ /** Whether the hip was derived from the supporting foot. */
267
+ planted: boolean;
268
+ /** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
269
+ ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
270
+ /** The translated bone's origin against its thigh roots' midpoint, at setup. */
271
+ pivot: Pivot | null;
272
+ }
273
+
274
+ /** The hip's pivot against the thigh roots, at setup (rig-c/pose#47). */
275
+ export interface Pivot {
276
+ bone: string;
277
+ roots: [string, string];
278
+ /** Hip origin − the roots' midpoint, world units. */
279
+ offset: Pt;
280
+ distance: number;
281
+ /** The distance in hip widths (the roots' distance apart). */
282
+ hipWidths: number;
254
283
  }
255
284
 
256
285
  type Pt = [number, number];
@@ -358,6 +387,24 @@ const r3 = (v: number) => {
358
387
  const r = Math.round(v * 1000) / 1000;
359
388
  return Object.is(r, -0) ? 0 : r;
360
389
  };
390
+ /** A scale key: to 1e-6, so its rounding moves an ankle 500 units out by at most 0.00025. */
391
+ const r6 = (v: number) => {
392
+ const r = Math.round(v * 1e6) / 1e6;
393
+ return Object.is(r, -0) ? 0 : r;
394
+ };
395
+
396
+ /** The translated bone's origin against the midpoint of the two legs' first bones, at setup. */
397
+ function pivotOf(rest: Rest, bone: string | null, legs: ReturnType<typeof legsOf> | null): Pivot | null {
398
+ if (bone === null || legs === null || !('legs' in legs)) return null;
399
+ const roots: [string, string] = [legs.legs.r.chain.bones[0]!, legs.legs.l.chain.bones[0]!];
400
+ const a = rest.get(roots[0])!;
401
+ const b = rest.get(roots[1])!;
402
+ const h = rest.get(bone)!;
403
+ const offset: Pt = [h.worldX - (a.worldX + b.worldX) / 2, h.worldY - (a.worldY + b.worldY) / 2];
404
+ const width = Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
405
+ const distance = Math.hypot(offset[0], offset[1]);
406
+ return { bone, roots, offset, distance, hipWidths: width > 1e-9 ? distance / width : NaN };
407
+ }
361
408
 
362
409
  /**
363
410
  * Retarget. Refuses (every reason at once) when a point the map needs is never
@@ -493,30 +540,17 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
493
540
  const s = rigLen / poseLen;
494
541
  scale = { factor: s, pose: poseLen, rig: rigLen };
495
542
  const P = series.get(t.joint)!;
496
- const bone = rest.get(t.bone)!;
497
- const parent = bone.parent === null ? null : rest.get(bone.parent)!;
498
- const keys: MotionKey[] = [];
499
- const at: Pt[] = [];
500
543
  for (let i = 0; i < n; i++) {
501
544
  const wx = (P[i]![0] - P[0]![0]) * s;
502
545
  const wy = -(P[i]![1] - P[0]![1]) * s;
503
- let lx = wx;
504
- let ly = wy;
505
- if (parent !== null) {
506
- const det = parent.a * parent.d - parent.b * parent.c;
507
- lx = (parent.d * wx - parent.b * wy) / det;
508
- ly = (-parent.c * wx + parent.a * wy) / det;
509
- }
510
- keys.push({ t: keyTimes[i]!, v: [r3(lx), r3(ly)] });
511
- at.push([bone.worldX + wx, bone.worldY + wy]);
512
546
  travel.push([wx, wy]);
513
547
  }
514
- translateTrack = { bone: t.bone, property: 'translate', keys };
515
- origin = { bone: t.bone, at };
516
548
  }
517
549
  }
518
550
  }
519
551
  p.throwIfAny();
552
+ /** The pose's own travel of the hip, world, before --plant-feet replaces it. */
553
+ const poseTravel: Pt[] = travel.map((v) => [v[0], v[1]]);
520
554
 
521
555
  // Each mapped bone's target world angle per frame, reaches last.
522
556
  const targets = new Map<string, number[]>();
@@ -595,6 +629,111 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
595
629
  }
596
630
  p.throwIfAny();
597
631
 
632
+ // The legs (#47): each leg segment's scale, the supports and jumps, and with
633
+ // --plant-feet the hip derived from the supporting foot — before the reaches,
634
+ // which are aimed from where the hip is.
635
+ const legScaleOn = opts.legScale !== undefined && opts.legScale !== 'off';
636
+ const plantOn = opts.plantFeet === true;
637
+ const legScale: LegScale[] = [];
638
+ const scales = new Map<string, number[]>();
639
+ let feet: Feet | null = null;
640
+ let legs: ReturnType<typeof legsOf> | null = null;
641
+ let legPath: string[] | null = null;
642
+ let legTop: string | null = null;
643
+ const restTurn = (b: string, i: number): number => targets.get(b)![i]! - restOf.get(b)!;
644
+ const aimX = new Map<string, boolean>();
645
+ for (const c of map.chains) c.bones.forEach((b, k) => aimX.set(b, c.aim[k] === 'x'));
646
+ {
647
+ const why = plantOn ? '--plant-feet' : legScaleOn ? '--leg-scale' : 'reading the feet';
648
+ legs = legsOf(map, why);
649
+ if ('problems' in legs) {
650
+ if (plantOn || legScaleOn) for (const x of legs.problems) p.add('LEGS_MAP', x);
651
+ } else {
652
+ const L = legs.legs;
653
+ const top = t !== null && SIDES.every((s) => L[s].ankleBone === t.bone || descends(rest, L[s].ankleBone, t.bone)) ? t.bone : rootOf(rest, L.r.ankleBone);
654
+ if (plantOn && (t === null || top !== t.bone)) p.add('LEGS_MAP', t === null ? '--plant-feet places the hip, and the map has no "translate"' : `--plant-feet places the translated bone "${t.bone}", and the legs' last bones (${L.r.ankleBone}, ${L.l.ankleBone}) do not both descend from it`);
655
+ const path = pathFrom(rest, top, [L.r.ankleBone, L.l.ankleBone]);
656
+ if (path === null || rootOf(rest, L.l.ankleBone) !== rootOf(rest, L.r.ankleBone)) {
657
+ if (plantOn || legScaleOn) p.add('LEGS_MAP', `the legs' last bones ${L.r.ankleBone} and ${L.l.ankleBone} do not hang from one bone`);
658
+ } else {
659
+ legPath = path;
660
+ legTop = top;
661
+ }
662
+ const hipOf = (s: Side) => series.get(`${s}_hip`);
663
+ const ankleOf = (s: Side) => series.get(`${s}_ankle`);
664
+ const hr = hipOf('r'), hl = hipOf('l'), ar = ankleOf('r'), al = ankleOf('l');
665
+ if (hr !== undefined && hl !== undefined && ar !== undefined && al !== undefined) feet = supportsOf({ r: ar, l: al }, { r: hr, l: hl }, keyTimes, secondsOf);
666
+ 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`);
667
+ }
668
+ }
669
+ if (legScaleOn) {
670
+ const limits = opts.legScale as [number, number];
671
+ for (const c of map.chains) {
672
+ if (c.reach) continue;
673
+ c.bones.forEach((b, k) => {
674
+ const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
675
+ if (!isLegSegment(joints[0], joints[1])) return;
676
+ if (c.aim[k] !== 'x') {
677
+ p.add('LEGS_MAP', `--leg-scale scales chain "${c.name}" bone "${b}" (${joints[0]} → ${joints[1]}) along its own x axis, and the map aims it ${JSON.stringify(c.aim[k])}; a leg segment it scales must be aimed "x"`);
678
+ return;
679
+ }
680
+ const from = series.get(joints[0])!;
681
+ const to = series.get(joints[1])!;
682
+ const ls = legScaleOf(from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1])), limits);
683
+ if (!(ls.straight > 1e-6)) {
684
+ p.add('LEGS_MAP', `--leg-scale: chain "${c.name}" bone "${b}"'s segment ${joints[0]} → ${joints[1]} has a straight length (95th percentile) of ${ls.straight}px; there is nothing to scale against`);
685
+ return;
686
+ }
687
+ legScale.push({ chain: c.name, bone: b, joints, ...ls });
688
+ scales.set(b, ls.scale);
689
+ });
690
+ }
691
+ 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');
692
+ }
693
+ p.throwIfAny();
694
+ /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
695
+ const poseLegs = (i: number): PathPose => {
696
+ const r = posePath(rest, legPath!, (b): Posing | null => (targets.has(b) ? { turn: restTurn(b, i), target: targets.get(b)![i]!, aimX: aimX.get(b)! } : null), (b) => scales.get(b)?.[i] ?? null);
697
+ if (typeof r === 'string') throw new Refusal([{ code: 'LEGS_MAP', text: r }]);
698
+ return r;
699
+ };
700
+ if (plantOn) {
701
+ // The plant is derived from the targets before the range clamp, so nothing after may turn a bone on the legs' path.
702
+ const onPath = new Set(legPath!);
703
+ for (const c of map.chains) for (const b of Object.keys(c.range)) if (onPath.has(b)) p.add('LEGS_RANGE', `--plant-feet places the hip through chain "${c.name}" bone "${b}", which has a "range"; a clamp after the plant would move the planted foot — drop the range or --plant-feet`);
704
+ for (const a of map.alternates) {
705
+ for (const b of [...Object.keys(a.turn), ...Object.keys(a.offsets)]) if (onPath.has(b)) p.add('LEGS_RANGE', `--plant-feet places the hip through bone "${b}", which set "${a.name}" turns or offsets; that would move the planted foot — drop it from the set or --plant-feet`);
706
+ }
707
+ p.throwIfAny();
708
+ const offsets = Array.from({ length: n }, (_, i) => {
709
+ const lp = poseLegs(i);
710
+ return { r: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.r.ankleBone : ''), l: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.l.ankleBone : '') };
711
+ });
712
+ const tb = rest.get(t!.bone)!;
713
+ const hip = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY]);
714
+ for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
715
+ }
716
+ if (t !== null && scale !== null) {
717
+ const bone = rest.get(t.bone)!;
718
+ const parent = bone.parent === null ? null : rest.get(bone.parent)!;
719
+ const keys: MotionKey[] = [];
720
+ const at: Pt[] = [];
721
+ for (let i = 0; i < n; i++) {
722
+ const [wx, wy] = travel[i]!;
723
+ let lx = wx;
724
+ let ly = wy;
725
+ if (parent !== null) {
726
+ const det = parent.a * parent.d - parent.b * parent.c;
727
+ lx = (parent.d * wx - parent.b * wy) / det;
728
+ ly = (-parent.c * wx + parent.a * wy) / det;
729
+ }
730
+ keys.push({ t: keyTimes[i]!, v: [r3(lx), r3(ly)] });
731
+ at.push([bone.worldX + wx, bone.worldY + wy]);
732
+ }
733
+ translateTrack = { bone: t.bone, property: 'translate', keys };
734
+ origin = { bone: t.bone, at };
735
+ }
736
+
598
737
  // Reaches: from the bone's posed origin at the pose's end point, anchored at
599
738
  // the translated bone. A reach above another is aimed first.
600
739
  const depth = (b: string): number => {
@@ -685,6 +824,25 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
685
824
  const artRange = measureArt(sw.switches, map, targets, endOf, keyTimes, RANGE_GAP);
686
825
  clampArt(sw.switches, map, targets);
687
826
 
827
+ // The legs on their final targets: a bone under a scaled bone is keyed through
828
+ // its parent's posed matrix (legs.ts), and each ankle's world position is read.
829
+ const legPoses: PathPose[] | null = legPath !== null && (legScaleOn || plantOn) ? Array.from({ length: n }, (_, i) => poseLegs(i)) : null;
830
+ let ankles: Retarget['ankles'] = null;
831
+ if (legPoses !== null && legs !== null && 'legs' in legs) {
832
+ const L = legs.legs;
833
+ const topRest = rest.get(legTop!)!;
834
+ const at = { r: [] as Pt[], l: [] as Pt[] };
835
+ legPoses.forEach((lp, i) => {
836
+ const o: Pt = origin !== null && origin.bone === legTop ? origin.at[i]! : [topRest.worldX, topRest.worldY];
837
+ for (const sd of SIDES) {
838
+ const tip = tipOf(rest, lp, L[sd].ankleBone);
839
+ at[sd].push([o[0] + tip[0], o[1] + tip[1]]);
840
+ }
841
+ });
842
+ ankles = { bones: { r: L.r.ankleBone, l: L.l.ankleBone }, at };
843
+ }
844
+ const solvedKey = (b: string, i: number) => legPoses?.[i]!.solved.get(b);
845
+
688
846
  // Keys: the bone's turn less the turn of the nearest mapped bone above it.
689
847
  const tracks: Array<MotionTrack | SlotTrack> = [];
690
848
  for (const c of map.chains) {
@@ -699,6 +857,22 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
699
857
  keys.push({ t: keyTimes[i]!, v: [r3(v)] });
700
858
  }
701
859
  tracks.push({ bone: b, property: 'rotate', keys });
860
+ // A bone under a scaled bone: the rotate key that lands it through its parent's scale (legs.ts).
861
+ if (solvedKey(b, 0) !== undefined) {
862
+ let last = 0;
863
+ keys.forEach((k, i) => {
864
+ const raw = solvedKey(b, i)!.rotate;
865
+ last = i === 0 ? wrap(raw) : last + wrap(raw - last);
866
+ k.v = [r3(last)];
867
+ });
868
+ }
869
+ }
870
+ }
871
+ // Scale keys: a scaled leg segment's own scale, or the scale that lands a bone under one at its asked length.
872
+ for (const c of map.chains) {
873
+ for (const b of c.bones) {
874
+ if (!scales.has(b) && solvedKey(b, 0) === undefined) continue;
875
+ tracks.push({ bone: b, property: 'scalex', keys: Array.from({ length: n }, (_, i) => ({ t: keyTimes[i]!, v: [r6(solvedKey(b, i)?.scale ?? scales.get(b)?.[i] ?? 1)] })) });
702
876
  }
703
877
  }
704
878
  if (translateTrack !== null) tracks.push(translateTrack);
@@ -708,6 +882,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
708
882
  animation: { duration: keyTimes[n - 1]!, tracks },
709
883
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
710
884
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
885
+ legScale, scales, feet, planted: plantOn, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs),
711
886
  };
712
887
  }
713
888
 
package/src/run.ts CHANGED
@@ -13,7 +13,9 @@ import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
13
13
  import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
14
14
  import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
15
15
  import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
16
- import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
16
+ import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
17
+ import { legsOf } from './legs.ts';
18
+ import type { LegScale } from './legs.ts';
17
19
 
18
20
  export interface RetargetArgs extends RetargetOptions {
19
21
  rig: string;
@@ -85,7 +87,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
85
87
  resolveMap(map, rest, a.map, slots);
86
88
 
87
89
  const r = retarget(rest, map, frames, times, a, slots);
88
- 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}°)`);
90
+ 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' : ''}`);
89
91
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
90
92
  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)})`);
91
93
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
@@ -104,6 +106,16 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
104
106
  }
105
107
  for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
106
108
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
109
+ for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
110
+ 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`);
111
+ if (r.feet !== null) {
112
+ const f = r.feet;
113
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
114
+ 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)`);
115
+ say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
116
+ say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
117
+ 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'}`);
118
+ }
107
119
 
108
120
  const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
109
121
  const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
@@ -117,16 +129,30 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
117
129
  const landings = measureLanding(doc, a.name, map, r, posed);
118
130
  const p = new Problems();
119
131
  for (const l of landings) {
120
- const unit = l.kind === 'angle' ? '°' : ' units';
121
- const tol = l.kind === 'angle' ? ANGLE_TOLERANCE : ORIGIN_TOLERANCE;
122
- const line = `bone "${l.bone}" ${l.kind === 'angle' ? 'aim' : 'origin'} — worst |reached − asked| ${l.worst.toPrecision(3)}${unit} at frame ${l.frame} (t=${r.times[l.frame]!.toFixed(3)}s); the tolerance is ${tol}${unit}`;
132
+ const unit = l.kind === 'angle' ? '°' : l.kind === 'scale' ? ' of its setup length' : ' units';
133
+ const what = l.kind === 'angle' ? 'aim' : l.kind === 'scale' ? 'scale' : l.kind === 'foot' ? 'ankle' : 'origin';
134
+ const tol = l.kind === 'foot' ? Number(l.tolerance.toPrecision(3)) : l.tolerance;
135
+ const line = `bone "${l.bone}" ${what} — worst |reached − asked| ${l.worst.toPrecision(3)}${unit} at frame ${l.frame} (t=${r.times[l.frame]!.toFixed(3)}s); the tolerance is ${tol}${unit}`;
123
136
  if (landed(l)) say(` LAND ${line}`);
124
137
  else p.add('POSE_LANDS', `${line} — the rig does not turn the way the key formula assumes (a constraint, a scaled or sheared parent, a non-normal inherit mode)`);
125
138
  }
126
139
  p.throwIfAny();
127
140
 
128
- const posture = measurePosture(posed, new Map(doc.bones.map((b) => [b.name, b.length ?? 0])), map, r, frames, a.minScore);
141
+ const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
142
+ const posture = measurePosture(posed, lengths, map, r, frames, a.minScore);
129
143
  if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
144
+ const legBones = legBonesOf(map);
145
+ const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
146
+ if (feetReading !== null) {
147
+ const f = feetReading;
148
+ say(` FEET the posed build, outside jumps: the lowest ankle's vertical range ${f.lowest.range.toFixed(2)} units = ${f.lowest.legs.toFixed(4)} leg lengths (rig leg r ${f.legLength.r.toFixed(1)}, l ${f.legLength.l.toFixed(1)} units) over ${f.lowest.frames} frames`);
149
+ const worst = (k: 'verticalLegs' | 'horizontalLegs') => f.plants.reduce<(typeof f.plants)[number] | null>((m, x) => (m === null || x[k] > m[k] ? x : m), null);
150
+ const wv = worst('verticalLegs');
151
+ const wh = worst('horizontalLegs');
152
+ if (wv !== null && wh !== null) say(` FEET the supporting ankle over each support span: worst vertical range ${wv.vertical.toFixed(2)} units = ${wv.verticalLegs.toFixed(4)} leg lengths (${wv.side} ${wv.frames[0]}–${wv.frames[1]}), worst horizontal drift ${wh.horizontal.toFixed(2)} units = ${wh.horizontalLegs.toFixed(4)} leg lengths (${wh.side} ${wh.frames[0]}–${wh.frames[1]})`);
153
+ }
154
+ const shear = r.scales.size === 0 ? [] : measureShear(doc, posed, r.scales.keys());
155
+ for (const x of shear) say(` SHEAR bone "${x.bone}": art sheared up to ${x.shear.toFixed(2)}° (frame ${x.frame}); world x axis ${x.x[0].toFixed(3)} … ${x.x[1].toFixed(3)}, y axis ${x.y[0].toFixed(3)} … ${x.y[1].toFixed(3)} of setup — a scaled bone, or one under it; a reading`);
130
156
 
131
157
  const dts = r.times.slice(1).map((t, i) => t - r.times[i]!).sort((x, y) => x - y);
132
158
  const fps = dts.length === 0 ? 12 : Math.max(1, Math.min(60, Math.round(1 / dts[dts.length >> 1]!)));
@@ -160,7 +186,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
160
186
  animation: a.name,
161
187
  frames: seq.frames.length,
162
188
  duration: r.animation.duration,
163
- 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 },
189
+ 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 },
164
190
  scale: r.scale,
165
191
  holds: r.holds,
166
192
  short: r.short,
@@ -176,6 +202,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
176
202
  turns: r.turns,
177
203
  range: r.range,
178
204
  artRange: r.artRange,
205
+ legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
206
+ pivot: r.pivot,
207
+ 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 },
208
+ shear,
179
209
  }, null, 2)}\n`);
180
210
  }
181
211
  say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
@@ -235,3 +265,18 @@ function windowLines(map: JointMap, switches: ReadonlyArray<{ set: string; frame
235
265
  }
236
266
  return out;
237
267
  }
268
+
269
+ /** Each leg's first bone (its root, the thigh) and last bone (whose tip is the ankle), when the map has both. */
270
+ function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { root: string; ankle: string } } | null {
271
+ const l = legsOf(map, 'reading the feet');
272
+ if ('problems' in l) return null;
273
+ 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 } };
274
+ }
275
+
276
+ /** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
277
+ function legScaleLine(l: LegScale, times: readonly number[]): string {
278
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
279
+ const count = (rs: Array<[number, number]>) => rs.reduce((k, x) => k + x[1] - x[0] + 1, 0);
280
+ const clamp = (rs: Array<[number, number]>, at: number, how: string) => (rs.length === 0 ? `none ${how} ${at}` : `${count(rs)} frame(s) ${how} ${at}, clamped (frames ${rs.map(span).join(', ')})`);
281
+ return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (95th percentile of ${times.length} frames); picture length ÷ straight ${l.ratio.min.toFixed(3)} … ${l.ratio.max.toFixed(3)} (median ${l.ratio.median.toFixed(3)}); scalex keyed in [${l.limits[0]}, ${l.limits[1]}]: ${clamp(l.low, l.limits[0], 'under')}; ${clamp(l.high, l.limits[1], 'over')}`;
282
+ }
package/src/verify.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * a guess about which rigs are safe; it is posed, and the distance between the
9
9
  * aim each bone reached and the aim the pose asked for is the verdict.
10
10
  */
11
- import { poseRawAnimation, type CompiledDocument, type RawBone } from 'rig-c/core';
11
+ import { poseRawAnimation, poseRawSetup, type CompiledDocument, type RawBone } from 'rig-c/core';
12
12
  import type { Body18 } from './joints.ts';
13
13
  import { aimAngle, pointAt, type JointMap } from './map.ts';
14
14
  import type { Joint } from './pose.ts';
@@ -17,10 +17,13 @@ import { pictureAngle, pointOn, wrap } from './retarget.ts';
17
17
 
18
18
  export interface Landing {
19
19
  bone: string;
20
- /** The largest |reached − asked| over the frames, degrees (rotate) or world units (translate). */
20
+ /** The largest |reached − asked| over the frames: degrees (an aim), world units (an origin, an ankle), or a fraction of the setup length (a scale). */
21
21
  worst: number;
22
22
  frame: number;
23
- kind: 'angle' | 'origin';
23
+ /** `scale`: the bone's world x axis against its setup length times the leg scale asked (#47); `foot`: the ankle (the leg's last bone's tip) against where the keys put it. */
24
+ kind: 'angle' | 'origin' | 'scale' | 'foot';
25
+ /** The tolerance it is held to. */
26
+ tolerance: number;
24
27
  }
25
28
 
26
29
  /**
@@ -32,6 +35,20 @@ export interface Landing {
32
35
  */
33
36
  export const ANGLE_TOLERANCE = 0.01;
34
37
  export const ORIGIN_TOLERANCE = 0.01;
38
+ /**
39
+ * A scale key is rounded to 1e-6 and a bone under a scaled bone takes its
40
+ * parent's rounding too; rig-c stores float32 (≈ 6e-8 relative). 1e-4 sits
41
+ * two orders above that.
42
+ */
43
+ export const SCALE_TOLERANCE = 1e-4;
44
+ /**
45
+ * An ankle sits at the end of a lever as long as the leg: each keyed rotation
46
+ * on the way (the hip, the thigh, the shin; rounded to 0.001°, ±8.7e-6 rad)
47
+ * moves it by up to 8.7e-6 × its setup distance from the hip's origin, so three
48
+ * move it ≤ 2.6e-5 × that. The tolerance is the origin's plus 1e-4 × that
49
+ * distance, about four times the sum (0.06 units on a 520-unit leg).
50
+ */
51
+ export const FOOT_TOLERANCE_PER_UNIT = 1e-4;
35
52
 
36
53
  /** Every bone, world, on every key frame of the animation, by rig-c's poser. */
37
54
  export function poseFrames(doc: CompiledDocument, animation: string, times: readonly number[]): Array<Map<string, RawBone>> {
@@ -58,8 +75,24 @@ export function measureLanding(doc: CompiledDocument, animation: string, map: Jo
58
75
  frame = i;
59
76
  }
60
77
  });
61
- out.push({ bone: b, worst, frame, kind: 'angle' });
78
+ out.push({ bone: b, worst, frame, kind: 'angle', tolerance: ANGLE_TOLERANCE });
79
+ });
80
+ }
81
+ const setup = new Map(poseRawSetup(doc).bones.map((b) => [b.name, b]));
82
+ for (const [b, asked] of r.scales) {
83
+ const s0 = setup.get(b)!;
84
+ const len0 = Math.hypot(s0.a, s0.c);
85
+ let worst = -1;
86
+ let frame = 0;
87
+ posed.forEach((bones, i) => {
88
+ const o = bones.get(b)!;
89
+ const e = Math.abs(Math.hypot(o.a, o.c) / len0 - asked[i]!);
90
+ if (e > worst) {
91
+ worst = e;
92
+ frame = i;
93
+ }
62
94
  });
95
+ out.push({ bone: b, worst, frame, kind: 'scale', tolerance: SCALE_TOLERANCE });
63
96
  }
64
97
  if (r.origin !== null) {
65
98
  let worst = -1;
@@ -73,13 +106,132 @@ export function measureLanding(doc: CompiledDocument, animation: string, map: Jo
73
106
  frame = i;
74
107
  }
75
108
  });
76
- out.push({ bone: r.origin.bone, worst, frame, kind: 'origin' });
109
+ out.push({ bone: r.origin.bone, worst, frame, kind: 'origin', tolerance: ORIGIN_TOLERANCE });
110
+ }
111
+ if (r.ankles !== null) {
112
+ const top = r.origin?.bone;
113
+ for (const side of ['r', 'l'] as const) {
114
+ const bone = r.ankles.bones[side];
115
+ const s0 = setup.get(bone)!;
116
+ const tip0: [number, number] = [s0.worldX + s0.a * (lengths.get(bone) ?? 0), s0.worldY + s0.c * (lengths.get(bone) ?? 0)];
117
+ const h0 = top === undefined ? null : setup.get(top)!;
118
+ const lever = h0 === null ? Math.hypot(tip0[0], tip0[1]) : Math.hypot(tip0[0] - h0.worldX, tip0[1] - h0.worldY);
119
+ let worst = -1;
120
+ let frame = 0;
121
+ posed.forEach((bones, i) => {
122
+ const got = pointAt(bones, lengths, `${bone}:tip`)!;
123
+ const want = r.ankles!.at[side][i]!;
124
+ const e = Math.hypot(got[0] - want[0], got[1] - want[1]);
125
+ if (e > worst) {
126
+ worst = e;
127
+ frame = i;
128
+ }
129
+ });
130
+ out.push({ bone: `${bone}:tip`, worst, frame, kind: 'foot', tolerance: ORIGIN_TOLERANCE + FOOT_TOLERANCE_PER_UNIT * lever });
131
+ }
77
132
  }
78
133
  return out;
79
134
  }
80
135
 
81
136
  export function landed(l: Landing): boolean {
82
- return l.worst <= (l.kind === 'angle' ? ANGLE_TOLERANCE : ORIGIN_TOLERANCE);
137
+ return l.worst <= l.tolerance;
138
+ }
139
+
140
+ /**
141
+ * How a scaled bone, or a bone under one, has its art distorted: the angle
142
+ * between its world x and y axes less 90° (shear), and each world axis's length
143
+ * against setup (a foot under a scaled shin is squashed along the shin). A
144
+ * reading, not a gate: rig-c's `scalex` on a bone reaches every bone below it.
145
+ */
146
+ export interface Shear {
147
+ bone: string;
148
+ /** Worst |angle(x, y) − 90°|, degrees, and its frame. */
149
+ shear: number;
150
+ frame: number;
151
+ /** The world x and y axes' lengths over setup's: lowest and highest over the frames. */
152
+ x: [number, number];
153
+ y: [number, number];
154
+ }
155
+
156
+ export function measureShear(doc: CompiledDocument, posed: Array<Map<string, RawBone>>, scaled: Iterable<string>): Shear[] {
157
+ const setup = new Map(poseRawSetup(doc).bones.map((b) => [b.name, b]));
158
+ const under = new Set<string>(scaled);
159
+ for (const b of doc.bones) if (b.parent !== undefined && b.parent !== null && under.has(b.parent)) under.add(b.name);
160
+ const out: Shear[] = [];
161
+ for (const b of doc.bones) {
162
+ if (!under.has(b.name)) continue;
163
+ const s0 = setup.get(b.name)!;
164
+ const x0 = Math.hypot(s0.a, s0.c);
165
+ const y0 = Math.hypot(s0.b, s0.d);
166
+ let shear = -1;
167
+ let frame = 0;
168
+ const xs: number[] = [];
169
+ const ys: number[] = [];
170
+ posed.forEach((bones, i) => {
171
+ const o = bones.get(b.name)!;
172
+ const e = Math.abs(wrap((Math.atan2(o.d, o.b) - Math.atan2(o.c, o.a)) * (180 / Math.PI) - 90));
173
+ if (e > shear) {
174
+ shear = e;
175
+ frame = i;
176
+ }
177
+ xs.push(Math.hypot(o.a, o.c) / x0);
178
+ ys.push(Math.hypot(o.b, o.d) / y0);
179
+ });
180
+ const rx = rangeOf(xs);
181
+ const ry = rangeOf(ys);
182
+ out.push({ bone: b.name, shear, frame, x: [rx.min, rx.max], y: [ry.min, ry.max] });
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /**
188
+ * The feet on the posed build (#43, #47), outside the jumps read off the
189
+ * reference: the lowest ankle's vertical range, and per support span, the
190
+ * supporting ankle's vertical range and horizontal drift — in world units and
191
+ * in that side's leg length (the median, over the frames, of its thigh's origin
192
+ * to its ankle).
193
+ */
194
+ export interface FeetReading {
195
+ legLength: { r: number; l: number };
196
+ /** The lowest ankle's world y range over the frames outside jumps. */
197
+ lowest: { range: number; legs: number; frames: number };
198
+ /** Per support span: the supporting ankle's ranges outside jumps. */
199
+ plants: Array<{ side: 'r' | 'l'; frames: [number, number]; vertical: number; horizontal: number; verticalLegs: number; horizontalLegs: number }>;
200
+ }
201
+
202
+ export function measureFeet(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, r: Retarget, legs: { r: { root: string; ankle: string }; l: { root: string; ankle: string } }): FeetReading | null {
203
+ if (r.feet === null) return null;
204
+ const tip = (bones: Map<string, RawBone>, side: 'r' | 'l') => pointAt(bones, lengths, `${legs[side].ankle}:tip`)!;
205
+ const legLength = { r: 0, l: 0 };
206
+ for (const side of ['r', 'l'] as const) {
207
+ legLength[side] = medianOf(posed.map((bones) => {
208
+ const a = tip(bones, side);
209
+ const o = bones.get(legs[side].root)!;
210
+ return Math.hypot(a[0] - o.worldX, a[1] - o.worldY);
211
+ }));
212
+ }
213
+ const jump = new Set<number>();
214
+ for (const [a, b] of r.feet.jumps) for (let i = a; i <= b; i++) jump.add(i);
215
+ const ys: number[] = [];
216
+ posed.forEach((bones, i) => {
217
+ if (jump.has(i)) return;
218
+ ys.push(Math.min(tip(bones, 'r')[1], tip(bones, 'l')[1]));
219
+ });
220
+ const lr = rangeOf(ys);
221
+ const mean = (legLength.r + legLength.l) / 2;
222
+ const plants = r.feet.supports.map((s) => {
223
+ const xs: number[] = [];
224
+ const yy: number[] = [];
225
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) {
226
+ const a = tip(posed[i]!, s.side);
227
+ xs.push(a[0]);
228
+ yy.push(a[1]);
229
+ }
230
+ const v = rangeOf(yy).range;
231
+ const h = rangeOf(xs).range;
232
+ return { side: s.side, frames: s.frames, vertical: v, horizontal: h, verticalLegs: v / legLength[s.side], horizontalLegs: h / legLength[s.side] };
233
+ });
234
+ return { legLength, lowest: { range: lr.range, legs: lr.range / mean, frames: ys.length }, plants };
83
235
  }
84
236
 
85
237
  export interface Range {