@rig-c/pose 0.20.5 → 0.22.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
@@ -143,6 +143,7 @@ and what the run cannot decide for you it lists:
143
143
  - `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.
144
144
  - `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)).
145
145
  - `REDUCE` — with `--reduce` ([below](#few-keys---reduce)): per bone, its keys before and after and its largest error, with the frame; then the total.
146
+ - `FACE` — with `face` in the map ([#138](https://github.com/rig-c/pose/issues/138)): per-frame yaw and pitch read from `nose` against the eye midpoint (`r_eye + l_eye`) and, when present, the ear midpoint (`r_ear + l_ear`), de-rolled by the eye line, then compressed into each configured axis's `[min, max]` range (`scale` and correlation `r`), and written as `face` in `--report`.
146
147
  - `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.
147
148
 
148
149
  `--report <file>` writes all of it as JSON (`rig-pose-report/1`): `spec`, `rigc` (`{ version, entry }`: the rig-c that gated it), `rig`, `animation`, `frames`, `duration`, `cleaned` (the sequence's `cleaned` record, [above](#clean-first-rig-pose-clean)), `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale`, `plantFeet`, `stance`, `floor` and `raisedByHead` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate` (rig-c's gate summary, a string), `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `supports`, `jumps`, `steps`, `changes`, `lowered`, `floor`, `shifts`, `blend`, `closure`, and the posed `reading`; a jump is an inclusive `[first, last]` pair of frames; `lowered` holds `{ frames, was, now, cause? }` runs (`cause`, where `now` is `null`: `released` or `higher`), present only with `--plant-feet` on a sequence not declared in place ([#113](https://github.com/rig-c/pose/issues/113)); `floor` is the world y every support span was planted on and `shifts` holds one `{ side, frames, shift }` per support span in world units, `null` and empty without `--plant-feet`; `blend` is `{ bounds, runs: [{ side, frames, k: { min, max } }], over: [{ side, frames, needed, left }] }`, the supporting leg stretched on the blend frames, `null` without `--plant-feet`; `closure` is `{ drift, frames }`, the hip's x drift closed over an in-place loop, `null` unless the sequence is `"in-place"` and `--plant-feet` is given), `stance` (`tolerance`, `release`, `minSeconds`, `bounds`, `runs`: `{ state, side, frames, maxHeight, maxDepth?, k, absorbed? }` (`state` is `both`, `lifted` or `below`; `maxDepth`, on a `below` run, is the free ankle's largest distance below the supporting one in leg lengths), `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:
@@ -195,7 +196,7 @@ Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. O
195
196
 
196
197
  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:
197
198
 
198
- **`--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 larger of the two sides' 95th-percentile picture lengths for that segment over the sequence ([#148](https://github.com/rig-c/pose/issues/148)), 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.
199
+ **`--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 larger of the two sides' 95th-percentile picture lengths for that segment over the sequence ([#148](https://github.com/rig-c/pose/issues/148); on a non-`in-place` sequence longer than 25 frames, each frame's segment length is normalised by a continuous rate-limited figure-scale signal `figureScaleOf` over `nose → lower ankle` standing height — frame-to-frame step ≤ 1 %, frozen spans ≥ 5 frames held at their median — so camera zoom neither shrinks earlier phrases nor lags behind frozen endings, and `--camera <file>` writes that track as `rig-pose-camera/1` without changing `motion.json`, [#159](https://github.com/rig-c/pose/issues/159), [#161](https://github.com/rig-c/pose/issues/161), [#163](https://github.com/rig-c/pose/issues/163)), 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.
199
200
 
200
201
  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 φ)|. A child (or its intermediate control bone) with `noScale` or `noScaleOrReflection` ([#153](https://github.com/rig-c/pose/issues/153)) still has its direction carried through the parent's scaled matrix, so its angle is skewed the same way, while rig-c normalises that direction to unit length so the parent's scale is not inherited. 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' put its world x axis on its target at the scale asked (under `noScale`, s' is the bone's own asked scale s directly). 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. A `normal` 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.
201
202
 
@@ -312,6 +313,7 @@ Which bones carry which joints. You write it for your rig:
312
313
  ⚠️ **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.
313
314
  - `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
314
315
  - `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.
316
+ - `face` (optional, [#138](https://github.com/rig-c/pose/issues/138), [#164](https://github.com/rig-c/pose/issues/164)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] } }` (`min ≤ 0 ≤ max`, `min < max`). Reads per-frame yaw and pitch from `nose` against `r_eye + l_eye` (and `r_ear + l_ear` when present), maps 1:1 inside `0.8 × range` (`[-12, 12]°` on `[-15, 15]°`) and compresses only the overshoot above the knee into the remaining 20% of the range so a single outlier frame does not crush the rest of the clip, and keys the named dial bone's `rotate` track (`FACE`).
315
317
 
316
318
  ### A joint's range: `range`
317
319
 
@@ -357,7 +359,7 @@ The bound exists because speed alone rewarded showing art far from its pose. Unb
357
359
 
358
360
  Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's old and new frame (the same under `first`), the frames and seconds it moved, the speed on both and its window, and the report carries the same under `moved`.
359
361
 
360
- **What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
362
+ **What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. Between two sets that both draw an arm behind the torso, a frame that belongs to neither set stays behind (the nearer set's layer); the default front slot is for a frame no set can claim **and** whose arm is read in front — not a fallback ([#136](https://github.com/rig-c/pose/issues/136), [#139](https://github.com/rig-c/pose/issues/139), [#162](https://github.com/rig-c/pose/issues/162)). A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
361
363
 
362
364
  **TURN.** The cutoff was measured on the three lines across the body before it was fixed ([#6](https://github.com/rig-c/pose/issues/6)): on the 13 s front-facing dance their picture length never drops under 0.81 of its median and none reverses; on a second sequence the hip line falls to 0.17 at frames 465–491 (side-on) and the eye line to 0.22 at 26–58 (head in profile), and 3 frames reverse. 0.5 lies between. A line is reversed when it points more than 90° from the subject's right joint to the left (front-facing, it points at 0°). The lines are read after holds, held as the map's points are even where the map does not use them; a line whose points are good on no frame is named as not measured.
363
365
 
package/cli.ts CHANGED
@@ -165,6 +165,9 @@ retarget
165
165
  --report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds,
166
166
  switches, uncovered, turns, range, artRange, raised, byHip,
167
167
  idleDropped, idleKeptOut, merged, reduce) as JSON
168
+ --camera <file> write the sequence's camera track (rig-pose-camera/1: continuous figure scale s,
169
+ raw standing height ratio, pan tx/ty, and composite videoS) as a sidecar JSON file
170
+ without changing motion.json (rig-c/pose#161)
168
171
 
169
172
  It writes nothing until rigc's gate is green with the animation in, every mapped bone lands on
170
173
  the angle the pose asks for (posed by rig-c's own poser), and no hinge bends the wrong way.
@@ -262,7 +265,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
262
265
  }
263
266
 
264
267
  function retargetArgs(argv: string[]): RetargetArgs {
265
- 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', 'floor'], ['coco', 'plant-feet', 'carry-idle', 'raised-by-head']);
268
+ const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report', 'camera', 'leg-scale', 'idle', 'merge', 'reduce', 'reduce-hip', 'stance', 'floor'], ['coco', 'plant-feet', 'carry-idle', 'raised-by-head']);
266
269
  const bends = f.get('bends') ?? 'refuse';
267
270
  if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
268
271
  const switchAt = f.get('switch-at') ?? 'first';
@@ -299,6 +302,7 @@ function retargetArgs(argv: string[]): RetargetArgs {
299
302
  build: f.get('build'),
300
303
  render: f.get('render'),
301
304
  report: f.get('report'),
305
+ camera: f.get('camera'),
302
306
  idle: f.get('idle'),
303
307
  carryIdle: f.has('carry-idle'),
304
308
  merge: f.get('merge'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.20.5",
3
+ "version": "0.22.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/face.ts ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Face direction (`rig-c/pose#138`): read yaw and pitch per frame from the five
3
+ * body-18 face points (`nose` against the eye midpoint `r_eye + l_eye` and,
4
+ * when present, the ear midpoint `r_ear + l_ear`, de-rolled by the eye line),
5
+ * and map each configured axis into its declared range by compression (never
6
+ * clipping, so motion changes at the extremes survive).
7
+ */
8
+ import type { FaceAxis, FaceMap } from './map.ts';
9
+
10
+ type Pt = [number, number];
11
+
12
+ const DEG = 180 / Math.PI;
13
+
14
+ /**
15
+ * Fraction of each side's limit below which `mapFaceAxis` is 1:1 identity
16
+ * (`rig-c/pose#164`); above `FACE_KNEE * limit` (`12°` for `15°`, `8°` for `10°`),
17
+ * a piecewise-linear knee compresses only the overshoot `[k, peak]` into `[k, limit]`.
18
+ */
19
+ export const FACE_KNEE = 0.8;
20
+
21
+ export interface FaceSpan {
22
+ min: number;
23
+ max: number;
24
+ range: number;
25
+ }
26
+
27
+ export interface FaceAxisReading {
28
+ bone: string;
29
+ limits: [number, number];
30
+ /** Knee thresholds `[kneeNeg, kneePos]` in degrees (`FACE_KNEE * limits`), inside which mapping is 1:1. */
31
+ knee: [number, number];
32
+ read: FaceSpan;
33
+ mapped: FaceSpan;
34
+ /** Peak-to-limit ratios `[kNeg, kPos]` in (0, 1] for negative and positive sides. */
35
+ scale: [number, number];
36
+ /** Spearman rank (monotone) correlation between the read angle and the mapped keys over the clip. */
37
+ corr: number;
38
+ /** Per-frame read angle in degrees, before range compression. */
39
+ raw: number[];
40
+ /** Per-frame mapped rotate key in degrees, rounded to 0.001°. */
41
+ keys: number[];
42
+ }
43
+
44
+ export interface FaceReading {
45
+ ears: boolean;
46
+ read: {
47
+ yaw: FaceSpan;
48
+ pitch: FaceSpan;
49
+ rawYaw: number[];
50
+ rawPitch: number[];
51
+ };
52
+ yaw?: FaceAxisReading;
53
+ pitch?: FaceAxisReading;
54
+ }
55
+
56
+ const r3 = (v: number): number => {
57
+ const r = Math.round(v * 1000) / 1000;
58
+ return Object.is(r, -0) ? 0 : r;
59
+ };
60
+
61
+ function median(xs: readonly number[]): number {
62
+ const s = [...xs].sort((a, b) => a - b);
63
+ const m = s.length >> 1;
64
+ return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
65
+ }
66
+
67
+ function spanOf(xs: readonly number[]): FaceSpan {
68
+ let min = Infinity;
69
+ let max = -Infinity;
70
+ for (const x of xs) {
71
+ if (x < min) min = x;
72
+ if (x > max) max = x;
73
+ }
74
+ return { min: r3(min), max: r3(max), range: r3(max - min) };
75
+ }
76
+
77
+ function ranksOf(xs: readonly number[]): number[] {
78
+ const idx = xs.map((_, i) => i).sort((a, b) => xs[a]! - xs[b]!);
79
+ const out = new Array<number>(xs.length).fill(0);
80
+ for (let i = 0; i < idx.length;) {
81
+ let j = i;
82
+ while (j + 1 < idx.length && xs[idx[j + 1]!]! === xs[idx[i]!]!) j++;
83
+ const rank = 0.5 * (i + j);
84
+ for (let k = i; k <= j; k++) out[idx[k]!] = rank;
85
+ i = j + 1;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ function corrOf(a: readonly number[], b: readonly number[]): number {
91
+ if (a.length < 2 || a.length !== b.length) return 1;
92
+ const ra = ranksOf(a);
93
+ const rb = ranksOf(b);
94
+ const ma = ra.reduce((x, y) => x + y, 0) / ra.length;
95
+ const mb = rb.reduce((x, y) => x + y, 0) / rb.length;
96
+ let num = 0;
97
+ let da = 0;
98
+ let db = 0;
99
+ for (let i = 0; i < ra.length; i++) {
100
+ const xa = ra[i]! - ma;
101
+ const xb = rb[i]! - mb;
102
+ num += xa * xb;
103
+ da += xa * xa;
104
+ db += xb * xb;
105
+ }
106
+ const den = Math.sqrt(da * db);
107
+ return den > 1e-9 ? r3(num / den) : 1;
108
+ }
109
+
110
+ /**
111
+ * Read per-frame yaw and pitch (degrees) from `nose`, `r_eye`, `l_eye`, and
112
+ * (when both are present) `r_ear` and `l_ear`.
113
+ *
114
+ * Sign convention (matching `rig-c` gallery `look` and the Part 1 range sweep):
115
+ * - `yaw > 0`: nose turned toward the subject's right (`r_eye`, viewer-left).
116
+ * - `pitch > 0`: nose tilted down (chin down / nod down).
117
+ */
118
+ export function readFaceAngles(
119
+ nose: readonly Pt[],
120
+ rEye: readonly Pt[],
121
+ lEye: readonly Pt[],
122
+ rEar: readonly Pt[] | null,
123
+ lEar: readonly Pt[] | null,
124
+ ): { ears: boolean; yaw: number[]; pitch: number[] } {
125
+ const n = nose.length;
126
+ const ears = rEar !== null && lEar !== null && rEar.length === n && lEar.length === n;
127
+ const xne: number[] = [];
128
+ const xna: number[] = [];
129
+ const yne: number[] = [];
130
+ const yna: number[] = [];
131
+
132
+ for (let i = 0; i < n; i++) {
133
+ const N = nose[i]!;
134
+ const Er = rEye[i]!;
135
+ const El = lEye[i]!;
136
+ const ex = El[0] - Er[0];
137
+ const ey = El[1] - Er[1];
138
+ const de = Math.hypot(ex, ey);
139
+ if (!(de > 1e-9)) {
140
+ xne.push(xne[i - 1] ?? 0);
141
+ yne.push(yne[i - 1] ?? 0);
142
+ if (ears) {
143
+ xna.push(xna[i - 1] ?? 0);
144
+ yna.push(yna[i - 1] ?? 0);
145
+ }
146
+ continue;
147
+ }
148
+ const ux = ex / de;
149
+ const uy = ey / de;
150
+ const vx = -uy;
151
+ const vy = ux;
152
+ const mex = 0.5 * (Er[0] + El[0]);
153
+ const mey = 0.5 * (Er[1] + El[1]);
154
+ xne.push(((mex - N[0]) * ux + (mey - N[1]) * uy) / de);
155
+ yne.push(((N[0] - mex) * vx + (N[1] - mey) * vy) / de);
156
+ if (ears) {
157
+ const Ar = rEar[i]!;
158
+ const Al = lEar[i]!;
159
+ const max = 0.5 * (Ar[0] + Al[0]);
160
+ const may = 0.5 * (Ar[1] + Al[1]);
161
+ const da = Math.max(de, (Al[0] - Ar[0]) * ux + (Al[1] - Ar[1]) * uy);
162
+ xna.push(((max - N[0]) * ux + (may - N[1]) * uy) / da);
163
+ yna.push(((N[0] - max) * vx + (N[1] - may) * vy) / de);
164
+ }
165
+ }
166
+
167
+ const yne0 = median(yne);
168
+ const yna0 = ears ? median(yna) : 0;
169
+ const yaw: number[] = [];
170
+ const pitch: number[] = [];
171
+ for (let i = 0; i < n; i++) {
172
+ const ty = ears ? (2 * xne[i]! + xna[i]!) / 2 : 2 * xne[i]!;
173
+ const tp = ears ? (yne[i]! - yne0 + (yna[i]! - yna0)) / 2 : yne[i]! - yne0;
174
+ yaw.push(Math.atan(ty) * DEG);
175
+ pitch.push(Math.atan(tp) * DEG);
176
+ }
177
+ return { ears, yaw, pitch };
178
+ }
179
+
180
+ function mapKneeSide(u: number, limit: number, peak: number): number {
181
+ if (!(limit > 1e-9)) return 0;
182
+ if (!(peak > limit)) return u;
183
+ const k = FACE_KNEE * limit;
184
+ if (u <= k) return u;
185
+ if (u >= peak) return limit;
186
+ return k + ((limit - k) * (u - k)) / (peak - k);
187
+ }
188
+
189
+ /**
190
+ * Compress a read angle series into `axis.range` (`[min, max]` with `min <= 0 <= max`):
191
+ * readings on a side whose peak stays inside the limit map 1:1 (`f(x) = x`); when a
192
+ * side's peak exceeds its limit `L`, readings up to the knee `k = FACE_KNEE * L`
193
+ * (`0.8 * L`, `rig-c/pose#164`) still map 1:1 and only the overshoot `[k, peak]` is
194
+ * compressed linearly into `[k, L]`.
195
+ */
196
+ export function mapFaceAxis(raw: readonly number[], axis: FaceAxis): FaceAxisReading {
197
+ const limits = axis.range;
198
+ let rawMin = Infinity;
199
+ let rawMax = -Infinity;
200
+ for (const x of raw) {
201
+ if (x < rawMin) rawMin = x;
202
+ if (x > rawMax) rawMax = x;
203
+ }
204
+ const limNeg = -limits[0];
205
+ const limPos = limits[1];
206
+ const peakNeg = -rawMin;
207
+ const peakPos = rawMax;
208
+ const kNeg = rawMin < limits[0] && rawMin < -1e-9 ? limits[0] / rawMin : 1;
209
+ const kPos = rawMax > limits[1] && rawMax > 1e-9 ? limits[1] / rawMax : 1;
210
+ const keys = raw.map((x) => r3(x < 0 ? -mapKneeSide(-x, limNeg, peakNeg) : mapKneeSide(x, limPos, peakPos)));
211
+ return {
212
+ bone: axis.bone,
213
+ limits: [limits[0], limits[1]],
214
+ knee: [r3(FACE_KNEE * limits[0]), r3(FACE_KNEE * limits[1])],
215
+ read: spanOf(raw),
216
+ mapped: spanOf(keys),
217
+ scale: [r3(kNeg), r3(kPos)],
218
+ corr: corrOf(raw, keys),
219
+ raw: [...raw],
220
+ keys,
221
+ };
222
+ }
223
+
224
+ export function readFace(
225
+ face: FaceMap,
226
+ nose: readonly Pt[],
227
+ rEye: readonly Pt[],
228
+ lEye: readonly Pt[],
229
+ rEar: readonly Pt[] | null,
230
+ lEar: readonly Pt[] | null,
231
+ ): FaceReading {
232
+ const { ears, yaw, pitch } = readFaceAngles(nose, rEye, lEye, rEar, lEar);
233
+ return {
234
+ ears,
235
+ read: {
236
+ yaw: spanOf(yaw),
237
+ pitch: spanOf(pitch),
238
+ rawYaw: yaw,
239
+ rawPitch: pitch,
240
+ },
241
+ ...(face.yaw === undefined ? {} : { yaw: mapFaceAxis(yaw, face.yaw) }),
242
+ ...(face.pitch === undefined ? {} : { pitch: mapFaceAxis(pitch, face.pitch) }),
243
+ };
244
+ }
245
+
246
+ const f1 = (x: number): string => x.toFixed(1);
247
+ const sg = (x: number): string => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
248
+
249
+ export function faceLine(f: FaceReading): string {
250
+ const joints = f.ears ? 'nose against r_eye + l_eye, r_ear + l_ear' : 'nose against r_eye + l_eye (ears absent)';
251
+ const readPart = `read yaw ${sg(f.read.yaw.min)}° … ${sg(f.read.yaw.max)}° (${f1(f.read.yaw.range)}°), pitch ${sg(f.read.pitch.min)}° … ${sg(f.read.pitch.max)}° (${f1(f.read.pitch.range)}°)`;
252
+ const axes: string[] = [];
253
+ for (const name of ['yaw', 'pitch'] as const) {
254
+ const ax = f[name];
255
+ if (ax === undefined) continue;
256
+ axes.push(`${name} → bone "${ax.bone}" [${ax.limits[0]}, ${ax.limits[1]}]° (knee [${ax.knee[0]}, ${ax.knee[1]}]°): ${sg(ax.mapped.min)}° … ${sg(ax.mapped.max)}° (scale ${ax.scale[0].toFixed(3)}/${ax.scale[1].toFixed(3)}, r=${ax.corr.toFixed(3)})`);
257
+ }
258
+ return `${joints}: ${readPart}; mapped ${axes.join(', ')}`;
259
+ }
package/src/legs.ts CHANGED
@@ -834,6 +834,82 @@ export const STANCE_BOUNDS: readonly [number, number] = [0.92, 1.08];
834
834
  /** Per side, per frame: the lower bound of the leg's stretch (#95). */
835
835
  export type StanceLower = Record<Side, readonly number[]>;
836
836
 
837
+ /** Maximum per-frame relative step of the continuous figure-scale curve (`rig-c/pose#163`). */
838
+ export const FIGURE_SCALE_STEP = 0.01;
839
+ /** Consecutive frames whose raw figure height stays within this many pixels over ≥ `FROZEN_MIN_FRAMES` frames hold their span median (`rig-c/pose#163`). */
840
+ export const FROZEN_STEP_PX = 0.5;
841
+ export const FROZEN_MIN_FRAMES = 5;
842
+
843
+ function medianOf(xs: readonly number[]): number {
844
+ const s = [...xs].sort((a, b) => a - b);
845
+ const m = s.length >> 1;
846
+ return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
847
+ }
848
+
849
+ /**
850
+ * Continuous per-frame figure scale `s[i]` (`s[0] = 1`, `rig-c/pose#163`,
851
+ * `rig-c/pose#161`): L2 projection of `log(raw)` onto the rate-limited corridor
852
+ * `|log(s[i+1] / s[i])| <= log(1 + FIGURE_SCALE_STEP)` (`≤ 1%` per frame),
853
+ * holding any frozen span (`≥ FROZEN_MIN_FRAMES` frames where consecutive raw
854
+ * heights move by `≤ FROZEN_STEP_PX`) constant at its span median.
855
+ */
856
+ export function figureScaleOf(raw: readonly number[], maxStep = FIGURE_SCALE_STEP): number[] {
857
+ const m = raw.length;
858
+ if (m === 0) return [];
859
+ const u = raw.map((v) => Math.log(Math.max(1e-6, v)));
860
+ const spans: Array<{ lo: number; hi: number; val: number }> = [];
861
+ for (let i = 0; i < m - 1;) {
862
+ if (Math.abs(raw[i + 1]! - raw[i]!) > FROZEN_STEP_PX) {
863
+ i++;
864
+ continue;
865
+ }
866
+ let j = i + 1;
867
+ while (j + 1 < m && Math.abs(raw[j + 1]! - raw[j]!) <= FROZEN_STEP_PX) j++;
868
+ if (j - i + 1 >= FROZEN_MIN_FRAMES) {
869
+ const med = Math.log(Math.max(1e-6, medianOf(raw.slice(i, j + 1))));
870
+ for (let k = i; k <= j; k++) u[k] = med;
871
+ spans.push({ lo: i, hi: j, val: med });
872
+ }
873
+ i = j + 1;
874
+ }
875
+ const r0 = Math.log(1 + maxStep);
876
+ const r = new Array<number>(Math.max(0, m - 1)).fill(r0);
877
+ for (const sp of spans) {
878
+ for (let i = sp.lo; i < sp.hi; i++) r[i] = 0;
879
+ }
880
+ for (let k = 0; k + 1 < spans.length; k++) {
881
+ const a = spans[k]!;
882
+ const b = spans[k + 1]!;
883
+ const gap = b.lo - a.hi;
884
+ if (gap > 0) {
885
+ const needed = Math.abs(b.val - a.val) / gap;
886
+ if (needed > r0) {
887
+ for (let i = a.hi; i < b.lo; i++) r[i] = needed * 1.0001;
888
+ }
889
+ }
890
+ }
891
+ const lam = new Array<number>(Math.max(0, m - 1)).fill(0);
892
+ const y = [...u];
893
+ for (let iter = 0; iter < 5000; iter++) {
894
+ let maxChange = 0;
895
+ for (let i = 0; i < m - 1; i++) {
896
+ const z = lam[i]! + 0.5 * (y[i + 1]! - y[i]!);
897
+ const thresh = 0.5 * r[i]!;
898
+ const next = z > thresh ? z - thresh : z < -thresh ? z + thresh : 0;
899
+ const d = next - lam[i]!;
900
+ if (Math.abs(d) > 1e-12) {
901
+ lam[i] = next;
902
+ y[i] = y[i]! + d;
903
+ y[i + 1] = y[i + 1]! - d;
904
+ if (Math.abs(d) > maxChange) maxChange = Math.abs(d);
905
+ }
906
+ }
907
+ if (maxChange < 1e-11) break;
908
+ }
909
+ const y0 = y[0]!;
910
+ return y.map((v) => Math.exp(v - y0));
911
+ }
912
+
837
913
  /**
838
914
  * The leg's own range (rig-c/pose#95). Per side, r(i) = the leg's picture
839
915
  * length hip → ankle on frame i ÷ its straight length (`STRAIGHT_PERCENTILE`
@@ -842,10 +918,18 @@ export type StanceLower = Record<Side, readonly number[]>;
842
918
  * fold as far as the picture's leg is folded on that frame, never less far
843
919
  * than the default. A leg with no straight length keeps the default.
844
920
  */
845
- export function stanceRangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>): StanceLower {
921
+ export function stanceRangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, inPlace = false, figureScale?: readonly number[]): StanceLower {
846
922
  const out = {} as Record<Side, number[]>;
923
+ const n = ankle.r.length;
924
+ const scale = !inPlace && n > 25
925
+ ? (figureScale ?? figureScaleOf(ankle.r.map((ar, i) => Math.max(
926
+ Math.hypot(ar[0] - hip.r[i]![0], ar[1] - hip.r[i]![1]),
927
+ Math.hypot(ankle.l[i]![0] - hip.l[i]![0], ankle.l[i]![1] - hip.l[i]![1]),
928
+ ))))
929
+ : null;
847
930
  for (const s of SIDES) {
848
- const len = ankle[s].map((a, i) => Math.hypot(a[0] - hip[s][i]![0], a[1] - hip[s][i]![1]));
931
+ const raw = ankle[s].map((a, i) => Math.hypot(a[0] - hip[s][i]![0], a[1] - hip[s][i]![1]));
932
+ const len = scale !== null ? raw.map((l, i) => (scale[i]! > 1e-6 ? l / scale[i]! : l)) : raw;
849
933
  const straight = percentile(len, STRAIGHT_PERCENTILE);
850
934
  out[s] = len.map((l) => (straight > 1e-9 && Number.isFinite(l) ? Math.min(STANCE_BOUNDS[0], l / straight) : STANCE_BOUNDS[0]));
851
935
  }
package/src/map.ts CHANGED
@@ -101,10 +101,19 @@ export interface AlternateSet {
101
101
  /** `place: "by-hip"` (rig-c/pose#93): the chains whose wrist `pose` puts within one hip width of the mid-hip, their wrists placed by the offset from the hip while the set shows. Absent or null: the pose's angles. */
102
102
  place?: { by: 'hip'; chains: string[] } | null;
103
103
  }
104
+ export interface FaceAxis {
105
+ bone: string;
106
+ range: [number, number];
107
+ }
108
+ export interface FaceMap {
109
+ yaw?: FaceAxis;
110
+ pitch?: FaceAxis;
111
+ }
104
112
  export interface JointMap {
105
113
  chains: Chain[];
106
114
  translate: Translate | null;
107
115
  alternates: AlternateSet[];
116
+ face?: FaceMap;
108
117
  }
109
118
 
110
119
  /** What a slot of the built rig shows at setup, and every attachment any skin gives it. */
@@ -135,7 +144,7 @@ export function readMap(text: string, where: string): JointMap {
135
144
  p.throwIfAny();
136
145
  throw new Error('unreachable');
137
146
  }
138
- refuseUnknownKeys(p, 'MAP_KEYS', where, doc, ['spec', 'note', 'chains', 'translate', 'alternates']);
147
+ refuseUnknownKeys(p, 'MAP_KEYS', where, doc, ['spec', 'note', 'chains', 'translate', 'alternates', 'face']);
139
148
  if (doc.spec !== MAP_SPEC) p.add('MAP_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${MAP_SPEC}"`);
140
149
  const chains: Chain[] = [];
141
150
  if (!Array.isArray(doc.chains) || doc.chains.length === 0) {
@@ -235,8 +244,42 @@ export function readMap(text: string, where: string): JointMap {
235
244
  }
236
245
  }
237
246
  const alternates = 'alternates' in doc ? readAlternates(p, where, doc.alternates, chains, translate) : [];
247
+ const face = 'face' in doc ? readFaceMap(p, where, doc.face) : undefined;
238
248
  p.throwIfAny();
239
- return { chains, translate, alternates };
249
+ return { chains, translate, alternates, ...(face === undefined ? {} : { face }) };
250
+ }
251
+
252
+ function readFaceMap(p: Problems, where: string, raw: unknown): FaceMap | undefined {
253
+ const fw = `${where} "face"`;
254
+ if (!isObject(raw)) {
255
+ p.add('MAP_FACE_SHAPE', `${fw} is ${typeName(raw)}; it is { yaw?: { bone, range: [min, max] }, pitch?: { bone, range: [min, max] } }`);
256
+ return undefined;
257
+ }
258
+ refuseUnknownKeys(p, 'MAP_KEYS', fw, raw, ['yaw', 'pitch']);
259
+ if (!('yaw' in raw) && !('pitch' in raw)) {
260
+ p.add('MAP_FACE_SHAPE', `${fw} names neither "yaw" nor "pitch"; it must name at least one axis`);
261
+ return undefined;
262
+ }
263
+ const out: FaceMap = {};
264
+ for (const axis of ['yaw', 'pitch'] as const) {
265
+ if (!(axis in raw)) continue;
266
+ const aw = `${fw} "${axis}"`;
267
+ const ax = raw[axis];
268
+ if (!isObject(ax)) {
269
+ p.add('MAP_FACE_SHAPE', `${aw} is ${typeName(ax)}; it is { bone, range: [min, max] }`);
270
+ continue;
271
+ }
272
+ refuseUnknownKeys(p, 'MAP_KEYS', aw, ax, ['bone', 'range']);
273
+ const bone = typeof ax.bone === 'string' && ax.bone !== '' ? ax.bone : null;
274
+ if (bone === null) p.add('MAP_FACE_BONE', `${aw} "bone" is ${typeName(ax.bone)}; it must name a bone`);
275
+ const v = ax.range;
276
+ if (!Array.isArray(v) || v.length !== 2 || !v.every((x) => typeof x === 'number' && Number.isFinite(x)) || (v[0] as number) > 0 || (v[1] as number) < 0 || (v[0] as number) >= (v[1] as number)) {
277
+ p.add('MAP_FACE_RANGE', `${aw} "range" is ${JSON.stringify(v)}; it must be [min, max], two finite numbers of degrees with min ≤ 0 ≤ max and min < max`);
278
+ } else if (bone !== null) {
279
+ out[axis] = { bone, range: [v[0] as number, v[1] as number] };
280
+ }
281
+ }
282
+ return out;
240
283
  }
241
284
 
242
285
  const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …], place?: "by-hip" }';
@@ -560,6 +603,22 @@ export function resolveMap(map: JointMap, rest: Rest, where: string, slots: RigS
560
603
  p.add('MAP_REACH', `chain "${c.name}" — bone "${b}" reaches from its posed origin, but it does not descend from the translated bone "${t.bone}"; its origin would not travel with the anchor`);
561
604
  }
562
605
  }
606
+ if (map.face !== undefined) {
607
+ for (const axis of ['yaw', 'pitch'] as const) {
608
+ const ax = map.face[axis];
609
+ if (ax === undefined) continue;
610
+ const label = `"face.${axis}"`;
611
+ if (!rest.has(ax.bone)) {
612
+ p.add('MAP_BONE_EXISTS', `${label} — bone "${ax.bone}" is not in the rig; its bones are ${listed}`);
613
+ continue;
614
+ }
615
+ const prev = owner.get(ax.bone);
616
+ if (prev !== undefined) p.add('MAP_BONE_ONCE', `bone "${ax.bone}" is in ${prev.startsWith('"') ? prev : `chain "${prev}"`} and in ${label}; a bone carries one rotate track`);
617
+ owner.set(ax.bone, label);
618
+ const o = rest.get(ax.bone)!;
619
+ if (Math.hypot(o.a, o.c) < 1e-9) p.add('MAP_AIM_LENGTH', `${label} — bone "${ax.bone}" has a collapsed x axis at setup (scale 0)`);
620
+ }
621
+ }
563
622
  resolveAlternates(p, map.alternates, slots);
564
623
  if (p.any) p.list.unshift({ code: 'MAP_RESOLVES', text: `${where} does not resolve against the rig` });
565
624
  p.throwIfAny();
package/src/retarget.ts CHANGED
@@ -82,7 +82,9 @@ import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
83
  import { clampArt, coveredChains, isRaised, measureArt, overflow, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
84
  import type { BlendHold, Closure, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
85
- import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
85
+ import { figureScaleOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
86
+ import type { FaceReading } from './face.ts';
87
+ import { readFace } from './face.ts';
86
88
 
87
89
  const DEG = 180 / Math.PI;
88
90
 
@@ -322,6 +324,28 @@ export interface Retarget {
322
324
  raised: Raised[];
323
325
  /** Each span of a `place: "by-hip"` set on each chain it places, its wrist placed against the hip (#93). */
324
326
  byHip: ByHip[];
327
+ /** With "face" in the map (#138): per-frame yaw/pitch reading and range-compressed mapping. */
328
+ face: FaceReading | null;
329
+ /** Continuous per-frame figure scale relative to frame 0 (`#163`, `#161`). */
330
+ figureScale: FigureScaleReading;
331
+ }
332
+
333
+ export interface FigureScaleFrame {
334
+ frame: number;
335
+ t: number;
336
+ /** Continuous rate-limited figure scale relative to frame 0 (`s[0] = 1`, step ≤ 1%/frame, held on frozen spans). */
337
+ s: number;
338
+ /** Raw standing-height ratio relative to frame 0 (`raw[i] / raw[0]`). */
339
+ rawS: number;
340
+ }
341
+
342
+ export interface FigureScaleReading {
343
+ /** Largest frame-to-frame relative change `|s[i] - s[i-1]| / s[i-1]` and the frame `i` where it occurs. */
344
+ maxStep: { step: number; frame: number; t: number };
345
+ /** Ending scale over frame 0 (`s[n-1] / s[0]`). */
346
+ endOverStart: number;
347
+ /** Per-frame continuous scale `s` and raw scale `rawS`. */
348
+ frames: FigureScaleFrame[];
325
349
  }
326
350
 
327
351
  /** The hip's pivot against the thigh roots, at setup (rig-c/pose#47). */
@@ -433,6 +457,11 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
433
457
  needed.add(map.translate.joint);
434
458
  map.translate.scale.pose.forEach((j) => needed.add(j));
435
459
  }
460
+ if (map.face !== undefined) {
461
+ needed.add('nose');
462
+ needed.add('r_eye');
463
+ needed.add('l_eye');
464
+ }
436
465
  const series = new Map<string, Pt[]>();
437
466
  const good = new Map<string, boolean[]>();
438
467
  const holds: Hold[] = [];
@@ -678,6 +707,60 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
678
707
  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`);
679
708
  }
680
709
  }
710
+ const heldPt = (name: string): Pt[] | null => {
711
+ const known = series.get(name);
712
+ if (known !== undefined) return known;
713
+ const h = heldOf(name);
714
+ return 'never' in h ? null : h.at;
715
+ };
716
+ const rawFigureHeight: number[] = (() => {
717
+ const ar = heldPt('r_ankle');
718
+ const al = heldPt('l_ankle');
719
+ const top = heldPt('nose') ?? heldPt('neck');
720
+ if (ar !== null && al !== null && top !== null) {
721
+ const h = ar.map((r, i) => Math.max(r[1], al[i]![1]) - top[i]![1]);
722
+ if (h.every((v) => v > 1e-6)) return h;
723
+ }
724
+ const hr = heldPt('r_hip');
725
+ const hl = heldPt('l_hip');
726
+ if (ar !== null && al !== null && hr !== null && hl !== null) {
727
+ const h = ar.map((r, i) => Math.max(Math.hypot(r[0] - hr[i]![0], r[1] - hr[i]![1]), Math.hypot(al[i]![0] - hl[i]![0], al[i]![1] - hl[i]![1])));
728
+ if (h.every((v) => v > 1e-6)) return h;
729
+ }
730
+ if (t !== null) {
731
+ const [pa, pb] = t.scale.pose;
732
+ const A = series.get(pa)!;
733
+ const B = series.get(pb)!;
734
+ const h = A.map((a, i) => Math.hypot(B[i]![0] - a[0], B[i]![1] - a[1]));
735
+ if (h.every((v) => v > 1e-6)) return h;
736
+ }
737
+ return new Array<number>(n).fill(1);
738
+ })();
739
+ const continuousScale = figureScaleOf(rawFigureHeight);
740
+ const figureScale: FigureScaleReading = (() => {
741
+ const raw0 = rawFigureHeight[0] ?? 1;
742
+ let maxStep = 0;
743
+ let stepFrame = 0;
744
+ for (let i = 1; i < n; i++) {
745
+ const prev = continuousScale[i - 1]!;
746
+ const st = prev > 1e-9 ? Math.abs(continuousScale[i]! - prev) / prev : 0;
747
+ if (st > maxStep) {
748
+ maxStep = st;
749
+ stepFrame = i;
750
+ }
751
+ }
752
+ return {
753
+ maxStep: { step: r6(maxStep), frame: stepFrame, t: keyTimes[stepFrame] ?? 0 },
754
+ endOverStart: r6(continuousScale[n - 1] ?? 1),
755
+ frames: continuousScale.map((s, i) => ({
756
+ frame: i,
757
+ t: keyTimes[i]!,
758
+ s: r6(s),
759
+ rawS: r6(raw0 > 1e-9 ? rawFigureHeight[i]! / raw0 : 1),
760
+ })),
761
+ };
762
+ })();
763
+ const poseScale: number[] | null = t !== null && scale !== null && n > 25 && !inPlace ? continuousScale : null;
681
764
  if (legScaleOn) {
682
765
  const limits = opts.legScale as [number, number];
683
766
  const rawSegments: Array<{ chain: string; bone: string; joints: [string, string]; kind: string; lengths: number[]; ownStraight: number }> = [];
@@ -703,6 +786,31 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
703
786
  }
704
787
  const straightByKind = new Map<string, number>();
705
788
  for (const seg of rawSegments) straightByKind.set(seg.kind, Math.max(straightByKind.get(seg.kind) ?? 0, seg.ownStraight));
789
+ if (!inPlace && n > 25) {
790
+ for (const seg of rawSegments) {
791
+ seg.lengths = seg.lengths.map((l, i) => (continuousScale[i]! > 1e-6 ? l / continuousScale[i]! : l));
792
+ seg.ownStraight = percentile(seg.lengths, STRAIGHT_PERCENTILE);
793
+ }
794
+ straightByKind.clear();
795
+ for (const seg of rawSegments) straightByKind.set(seg.kind, Math.max(straightByKind.get(seg.kind) ?? 0, seg.ownStraight));
796
+ const byChain = new Map<string, typeof rawSegments>();
797
+ for (const seg of rawSegments) {
798
+ const list = byChain.get(seg.chain) ?? [];
799
+ list.push(seg);
800
+ byChain.set(seg.chain, list);
801
+ }
802
+ const chainTotals = [...byChain.values()].filter((list) => list.length >= 2).map((list) => list[0]!.lengths.map((_, i) => list.reduce((s, seg) => s + seg.lengths[i]!, 0)));
803
+ if (chainTotals.length > 0 && straightByKind.size >= 2) {
804
+ let sumKindStraight = 0;
805
+ for (const ks of straightByKind.values()) sumKindStraight += ks;
806
+ let maxChainTotal = 0;
807
+ for (const tot of chainTotals) maxChainTotal = Math.max(maxChainTotal, percentile(tot, STRAIGHT_PERCENTILE));
808
+ if (sumKindStraight > maxChainTotal && maxChainTotal > 1e-6) {
809
+ const k = maxChainTotal / sumKindStraight;
810
+ for (const [kind, ks] of straightByKind) straightByKind.set(kind, ks * k);
811
+ }
812
+ }
813
+ }
706
814
  for (const seg of rawSegments) {
707
815
  const ls = legScaleOf(seg.lengths, limits, straightByKind.get(seg.kind)!);
708
816
  legScale.push({ chain: seg.chain, bone: seg.bone, joints: seg.joints, ...ls });
@@ -781,9 +889,16 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
781
889
  // (stanceOf's mechanism, its bounds) so its ankle stays on the floor. Before --stance, whose
782
890
  // floor is that supporting ankle. #95: the lower bound on a frame is the leg's own range there.
783
891
  const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
784
- const lower = stanceRangeOf(ankleSeries, { r: series.get('r_hip')!, l: series.get('l_hip')! });
892
+ const lower = stanceRangeOf(ankleSeries, { r: series.get('r_hip')!, l: series.get('l_hip')! }, inPlace, continuousScale);
785
893
  blendHold = holdBlend(planted.blends, n, posedFeet, lower);
894
+ const beforeBlendX = blendHold.stretch.map((b, i) => (b === null ? 0 : posedFeet(i).ankle[b.side][0]));
786
895
  stretchChain(blendHold.stretch);
896
+ blendHold.stretch.forEach((b, i) => {
897
+ if (b === null) return;
898
+ const dx = beforeBlendX[i]! - posedFeet(i).ankle[b.side][0];
899
+ hip[i]![0] += dx;
900
+ travel[i]![0] += dx;
901
+ });
787
902
  // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
788
903
  // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
789
904
  if (opts.stance !== undefined && opts.stance !== 'off') {
@@ -970,11 +1085,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
970
1085
  if (refEye > 1e-6 && rigEye > 1e-6) nodScale = rigEye / refEye;
971
1086
  }
972
1087
  if (neckSeries !== undefined && nodScale !== null) {
973
- const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]));
974
- const dy = refEN.map((v) => (v - refEN[0]!) * nodScale);
1088
+ const bone = rest.get(hb)!;
1089
+ const parent = bone.parent === null ? null : rest.get(bone.parent)!;
1090
+ let neckRestLen = 0;
1091
+ for (let a = parent; a !== null; a = a.parent === null ? null : rest.get(a.parent)!) {
1092
+ const d = Math.hypot(bone.worldX - a.worldX, bone.worldY - a.worldY);
1093
+ if (d > 1e-6) {
1094
+ neckRestLen = d;
1095
+ break;
1096
+ }
1097
+ }
1098
+ const maxNod = !inPlace && neckRestLen > 1e-6 ? 0.02 * neckRestLen : Infinity;
1099
+ const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]) / (poseScale?.[i] ?? 1));
1100
+ const dy = refEN.map((v) => Math.max(-maxNod, Math.min(maxNod, (v - refEN[0]!) * nodScale!)));
975
1101
  if (dy.some((v) => Math.abs(r3(v)) > 0)) {
976
- const bone = rest.get(hb)!;
977
- const parent = bone.parent === null ? null : rest.get(bone.parent)!;
978
1102
  const keys: MotionKey[] = [];
979
1103
  const nod: Pt[] = [];
980
1104
  for (let i = 0; i < n; i++) {
@@ -1281,6 +1405,26 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1281
1405
  }
1282
1406
  }
1283
1407
  }
1408
+ let face: FaceReading | null = null;
1409
+ if (map.face !== undefined) {
1410
+ const rEar = series.get('r_ear') ?? (() => {
1411
+ const h = heldOf('r_ear');
1412
+ return 'never' in h ? null : h.at;
1413
+ })();
1414
+ const lEar = series.get('l_ear') ?? (() => {
1415
+ const h = heldOf('l_ear');
1416
+ return 'never' in h ? null : h.at;
1417
+ })();
1418
+ face = readFace(map.face, series.get('nose')!, series.get('r_eye')!, series.get('l_eye')!, rEar, lEar);
1419
+ for (const axis of ['yaw', 'pitch'] as const) {
1420
+ const ax = face[axis];
1421
+ if (ax === undefined) continue;
1422
+ const rest0 = restAngle(rest, ax.bone, 'x');
1423
+ const above = mappedAbove(ax.bone);
1424
+ targets.set(ax.bone, ax.keys.map((v, i) => wrap(rest0 + (above === null ? 0 : turn(above, i)) + v)));
1425
+ tracks.push({ bone: ax.bone, property: 'rotate', keys: ax.keys.map((v, i) => ({ t: keyTimes[i]!, v: [v] })) });
1426
+ }
1427
+ }
1284
1428
  // Scale keys: a scaled leg segment's own scale, or the scale that lands a bone under one at its asked length.
1285
1429
  for (const c of map.chains) {
1286
1430
  for (const b of c.bones) {
@@ -1296,7 +1440,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1296
1440
  animation: { duration: keyTimes[n - 1]!, tracks },
1297
1441
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
1298
1442
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
1299
- legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip,
1443
+ legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, figureScale,
1300
1444
  };
1301
1445
  }
1302
1446
 
package/src/run.ts CHANGED
@@ -19,6 +19,7 @@ import { landed, measureFeet, measureLanding, measurePosture, measureShear, pose
19
19
  import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
20
20
  import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
21
21
  import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance, SupportScaleRun } from './legs.ts';
22
+ import { faceLine } from './face.ts';
22
23
 
23
24
  export interface RetargetArgs extends RetargetOptions {
24
25
  rig: string;
@@ -40,6 +41,8 @@ export interface RetargetArgs extends RetargetOptions {
40
41
  render?: string;
41
42
  /** Write the run's measurements as JSON here. */
42
43
  report?: string;
44
+ /** Write the sequence's per-frame relative figure scale and pan as a sidecar JSON here (#161). */
45
+ camera?: string;
43
46
  /** The rig's animation read as its idle (default: idle); naming one the motion spec lacks is refused. */
44
47
  idle?: string;
45
48
  /** Loop the idle's dropped tracks into the new animation (#43 item 2). */
@@ -71,7 +74,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
71
74
  if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
72
75
  }
73
76
  // A file this run writes goes into a directory that is there already; --build and --render make theirs.
74
- for (const [flag, path] of [['--out', a.out], ['--report', a.report]] as const) {
77
+ for (const [flag, path] of [['--out', a.out], ['--report', a.report], ['--camera', a.camera]] as const) {
75
78
  if (path === undefined) continue;
76
79
  const dir = dirname(path);
77
80
  if (!existsSync(dir)) args.add('ARG_PATH', `${flag} ${path} — directory ${dir} does not exist`);
@@ -157,6 +160,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
157
160
  if (r.blendHold !== null) for (const l of blendLines(r.blendHold, r.times)) say(` FEET ${l}`);
158
161
  }
159
162
  if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
163
+ if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
160
164
 
161
165
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
162
166
  const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
@@ -183,7 +187,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
183
187
  if ((t.kind === 'bone' || t.kind === 'group') && Array.isArray(sample)) return boneIdentity(property, sample.length);
184
188
  return undefined;
185
189
  };
186
- const mapped = [...map.chains.flatMap((c) => c.bones), ...(map.translate === null ? [] : [map.translate.bone])];
190
+ const mapped = [
191
+ ...map.chains.flatMap((c) => c.bones),
192
+ ...(map.translate === null ? [] : [map.translate.bone]),
193
+ ...(map.face?.yaw === undefined ? [] : [map.face.yaw.bone]),
194
+ ...(map.face?.pitch === undefined ? [] : [map.face.pitch.bone]),
195
+ ];
187
196
  const below = new Map<string, string[]>();
188
197
  for (const m of new Set(mapped)) {
189
198
  for (let b = rest.get(m)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) {
@@ -285,6 +294,35 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
285
294
  if (!kept.green) refuse([{ code: 'RIG_GATE', text: `rigc build into --build ${a.build} is red where the same build was green in scratch (exit ${kept.run.status}); nothing written` }, ...kept.fails.map((x) => ({ code: 'RIGC', text: x }))]);
286
295
  }
287
296
  writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
297
+ if (a.camera !== undefined) {
298
+ const ss = r.figureScale.frames.map((x) => x.s);
299
+ const minS = ss.length === 0 ? 1 : Math.min(...ss);
300
+ const maxS = ss.length === 0 ? 1 : Math.max(...ss);
301
+ writeFileSync(a.camera, `${JSON.stringify({
302
+ spec: 'rig-pose-camera/1',
303
+ rig: input.name,
304
+ animation: a.name,
305
+ frames: r.figureScale.frames.map((f, i) => {
306
+ const cam = seq.camera?.[i];
307
+ const camS = cam?.s ?? 1;
308
+ return {
309
+ frame: f.frame,
310
+ t: f.t,
311
+ s: f.s,
312
+ rawS: f.rawS,
313
+ tx: cam?.tx ?? 0,
314
+ ty: cam?.ty ?? 0,
315
+ videoS: Number((f.s * camS).toFixed(6)),
316
+ };
317
+ }),
318
+ summary: {
319
+ minS,
320
+ maxS,
321
+ maxStep: r.figureScale.maxStep,
322
+ endOverStart: r.figureScale.endOverStart,
323
+ },
324
+ }, null, 2)}\n`);
325
+ }
288
326
  if (a.render !== undefined && f.meshes !== null && f.frames !== '') {
289
327
  rmSync(a.render, { recursive: true, force: true });
290
328
  cpSync(f.frames, a.render, { recursive: true });
@@ -317,6 +355,16 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
317
355
  raised: r.raised,
318
356
  // Only when a set declares `place` (#93): a map without one writes the report it always has.
319
357
  ...(map.alternates.some((x) => x.place !== undefined && x.place !== null) ? { byHip: r.byHip } : {}),
358
+ ...(r.face === null
359
+ ? {}
360
+ : {
361
+ face: {
362
+ ears: r.face.ears,
363
+ read: { yaw: r.face.read.yaw, pitch: r.face.read.pitch },
364
+ ...(r.face.yaw === undefined ? {} : { yaw: { bone: r.face.yaw.bone, limits: r.face.yaw.limits, knee: r.face.yaw.knee, read: r.face.yaw.read, mapped: r.face.yaw.mapped, scale: r.face.yaw.scale, corr: r.face.yaw.corr } }),
365
+ ...(r.face.pitch === undefined ? {} : { pitch: { bone: r.face.pitch.bone, limits: r.face.pitch.limits, knee: r.face.pitch.knee, read: r.face.pitch.read, mapped: r.face.pitch.mapped, scale: r.face.pitch.scale, corr: r.face.pitch.corr } }),
366
+ },
367
+ }),
320
368
  legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
321
369
  pivot: r.pivot,
322
370
  stance: r.stance === null ? null : { tolerance: r.stance.tolerance, release: r.stance.release, minSeconds: r.stance.minSeconds, bounds: r.stance.bounds, ...(r.stance.range === undefined ? {} : { range: r.stance.range }), runs: r.stance.runs, over: r.stance.over },
@@ -328,6 +376,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
328
376
  reduce: reduced === null ? null : { tolerance: a.reduce, hipTolerance: a.reduceHip ?? null, rows: reduced.rows },
329
377
  }, null, 2)}\n`);
330
378
  }
379
+ if (a.camera !== undefined) say(` wrote ${a.camera} — camera track (${r.figureScale.frames.length} frames)`);
331
380
  say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
332
381
  return { lines, written: true };
333
382
  } finally {
package/src/switch.ts CHANGED
@@ -498,10 +498,9 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
498
498
  for (let i = sw.frames[0]; i <= sw.frames[1]; i++) row[i] = o;
499
499
  }
500
500
  }
501
- // #136: when a group's base setup slots sit in front of the torso, an unswitched gap between two
502
- // behind-the-torso switches keeps each chain on its behind-the-torso slots on the frames where that
503
- // chain stays within tolerance of the surrounding behind sets (with the same minimum-span bridging),
504
- // and across a joint behind-to-behind transition where all chains move from one behind set to another.
501
+ // #136/#139/#162: between two sets that both draw an arm behind the torso, a frame that belongs to
502
+ // neither set stays behind (the nearer set's layer). The default front slot is for a frame no set can
503
+ // claim and whose arm is read in front — not a fallback.
505
504
  const chainHolds: Array<{ set: string; chain: string; frames: [number, number] }> = [];
506
505
  for (const group of groups) {
507
506
  const layer = groupLayering(group, map, slots);
@@ -578,6 +577,56 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
578
577
  return false;
579
578
  });
580
579
  if (!frontNear) for (const cn of chains) for (let i = g0; i <= g1; i++) cPick.get(cn)![i] = set0;
580
+ } else {
581
+ for (const cn of chains) {
582
+ const arr = cPick.get(cn)!;
583
+ const frontSets = group.filter((a) => layer.isFront(a) && a.chains.includes(cn));
584
+ const d0s = byChain.get(set0.name)?.get(cn);
585
+ const d1s = byChain.get(set1.name)?.get(cn);
586
+ for (let i = g0; i <= g1; i++) {
587
+ if (arr[i] !== null) continue;
588
+ const claimedFront = frontSets.some((f) => (byChain.get(f.name)?.get(cn)?.[i] ?? Infinity) <= opts.switchTolerance);
589
+ if (!claimedFront) {
590
+ const d0 = d0s?.[i] ?? Infinity;
591
+ const d1 = d1s?.[i] ?? Infinity;
592
+ arr[i] = d0 <= d1 ? set0 : set1;
593
+ }
594
+ }
595
+ for (let i = g0; i <= g1;) {
596
+ const a = arr[i];
597
+ let j = i;
598
+ while (j + 1 <= g1 && arr[j + 1] === a) j++;
599
+ const before = arr[i - 1] ?? null;
600
+ const after = arr[j + 1] ?? null;
601
+ const span = times[j + 1]! - times[i]!;
602
+ if (span < opts.switchMin - 1e-9) {
603
+ if (a === null && before !== null && after !== null) {
604
+ for (let k = i; k <= j; k++) {
605
+ const d0 = d0s?.[k] ?? Infinity;
606
+ const d1 = d1s?.[k] ?? Infinity;
607
+ arr[k] = d0 <= d1 ? set0 : set1;
608
+ }
609
+ } else if (before !== null && before === after && a !== before) {
610
+ for (let k = i; k <= j; k++) arr[k] = before;
611
+ }
612
+ }
613
+ i = j + 1;
614
+ }
615
+ for (let i = g0; i <= g1;) {
616
+ const a = arr[i];
617
+ let j = i;
618
+ while (j + 1 <= g1 && arr[j + 1] === a) j++;
619
+ const before = arr[i - 1] ?? null;
620
+ const after = arr[j + 1] ?? null;
621
+ if (before !== null && before === after && a !== before && times[j + 1]! - times[i]! < opts.switchMin - 1e-9) {
622
+ for (let k = i; k <= j; k++) arr[k] = before;
623
+ } else if (a !== null && i > g0 && j < g1 && times[j + 1]! - times[i]! < opts.switchMin - 1e-9) {
624
+ const fill = before ?? after ?? null;
625
+ for (let k = i; k <= j; k++) arr[k] = fill;
626
+ }
627
+ i = j + 1;
628
+ }
629
+ }
581
630
  }
582
631
  for (const s of layer.touched) {
583
632
  if (!shown.has(s)) shown.set(s, Array.from({ length: n }, () => slots.get(s)!.setup));
package/src/verify.ts CHANGED
@@ -61,13 +61,19 @@ export function poseFrames(doc: CompiledDocument, animation: string, times: read
61
61
  export function measureLanding(doc: CompiledDocument, animation: string, map: JointMap, r: Retarget, posed = poseFrames(doc, animation, r.times)): Landing[] {
62
62
  const out: Landing[] = [];
63
63
  const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
64
+ const faceBones = new Set([r.face?.yaw?.bone, r.face?.pitch?.bone].filter((b): b is string => b !== undefined));
65
+ const isFaceSlider = (c: CompiledDocument['constraints'][number]): boolean => c.record?.kind === 'slider' && c.record.bone !== null && faceBones.has(c.record.bone);
66
+ const hasFaceSliders = faceBones.size > 0 && doc.constraints.some(isFaceSlider);
67
+ const chainPosed = hasFaceSliders
68
+ ? poseFrames({ ...doc, constraints: doc.constraints.filter((c) => !isFaceSlider(c)) }, animation, r.times)
69
+ : posed;
64
70
  for (const c of map.chains) {
65
71
  c.bones.forEach((b, k) => {
66
72
  const aim = c.aim[k]!;
67
73
  const asked = r.targets.get(b)!;
68
74
  let worst = -1;
69
75
  let frame = 0;
70
- posed.forEach((bones, i) => {
76
+ chainPosed.forEach((bones, i) => {
71
77
  const reached = aimAngle(bones, lengths, b, aim);
72
78
  const e = Math.abs(wrap(reached - asked[i]!));
73
79
  if (e > worst) {
@@ -78,6 +84,24 @@ export function measureLanding(doc: CompiledDocument, animation: string, map: Jo
78
84
  out.push({ bone: b, worst, frame, kind: 'angle', tolerance: ANGLE_TOLERANCE });
79
85
  });
80
86
  }
87
+ if (r.face !== null) {
88
+ for (const axis of ['yaw', 'pitch'] as const) {
89
+ const ax = r.face[axis];
90
+ if (ax === undefined) continue;
91
+ const asked = r.targets.get(ax.bone)!;
92
+ let worst = -1;
93
+ let frame = 0;
94
+ posed.forEach((bones, i) => {
95
+ const reached = aimAngle(bones, lengths, ax.bone, 'x');
96
+ const e = Math.abs(wrap(reached - asked[i]!));
97
+ if (e > worst) {
98
+ worst = e;
99
+ frame = i;
100
+ }
101
+ });
102
+ out.push({ bone: ax.bone, worst, frame, kind: 'angle', tolerance: ANGLE_TOLERANCE });
103
+ }
104
+ }
81
105
  const setup = new Map(poseRawSetup(doc).bones.map((b) => [b.name, b]));
82
106
  for (const [b, asked] of r.scales) {
83
107
  const s0 = setup.get(b)!;