@rig-c/pose 0.20.5 → 0.21.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 the rolling 25-frame median trunk length over the sequence median so residual camera zoom does not shrink earlier phrases against a zoomed-in ending, and the shared thigh + shin straight lengths are capped at the larger 95th-percentile whole-leg `hip → ankle` length so a drifting knee split does not inflate their sum, [#159](https://github.com/rig-c/pose/issues/159)), 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)): `{ "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), compresses each axis into its declared range so peaks are scaled rather than clipped flat, and keys the named dial bone's `rotate` track (`FACE`).
315
317
 
316
318
  ### A joint's range: `range`
317
319
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.20.5",
3
+ "version": "0.21.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,221 @@
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
+ export interface FaceSpan {
15
+ min: number;
16
+ max: number;
17
+ range: number;
18
+ }
19
+
20
+ export interface FaceAxisReading {
21
+ bone: string;
22
+ limits: [number, number];
23
+ read: FaceSpan;
24
+ mapped: FaceSpan;
25
+ /** Compression factors [kNeg, kPos] in (0, 1] applied to negative and positive read angles. */
26
+ scale: [number, number];
27
+ /** Pearson correlation between the read angle and the mapped keys over the clip. */
28
+ corr: number;
29
+ /** Per-frame read angle in degrees, before range compression. */
30
+ raw: number[];
31
+ /** Per-frame mapped rotate key in degrees, rounded to 0.001°. */
32
+ keys: number[];
33
+ }
34
+
35
+ export interface FaceReading {
36
+ ears: boolean;
37
+ read: {
38
+ yaw: FaceSpan;
39
+ pitch: FaceSpan;
40
+ rawYaw: number[];
41
+ rawPitch: number[];
42
+ };
43
+ yaw?: FaceAxisReading;
44
+ pitch?: FaceAxisReading;
45
+ }
46
+
47
+ const r3 = (v: number): number => {
48
+ const r = Math.round(v * 1000) / 1000;
49
+ return Object.is(r, -0) ? 0 : r;
50
+ };
51
+
52
+ function median(xs: readonly number[]): number {
53
+ const s = [...xs].sort((a, b) => a - b);
54
+ const m = s.length >> 1;
55
+ return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
56
+ }
57
+
58
+ function spanOf(xs: readonly number[]): FaceSpan {
59
+ let min = Infinity;
60
+ let max = -Infinity;
61
+ for (const x of xs) {
62
+ if (x < min) min = x;
63
+ if (x > max) max = x;
64
+ }
65
+ return { min: r3(min), max: r3(max), range: r3(max - min) };
66
+ }
67
+
68
+ function corrOf(a: readonly number[], b: readonly number[]): number {
69
+ if (a.length < 2 || a.length !== b.length) return 1;
70
+ const ma = a.reduce((x, y) => x + y, 0) / a.length;
71
+ const mb = b.reduce((x, y) => x + y, 0) / b.length;
72
+ let num = 0;
73
+ let da = 0;
74
+ let db = 0;
75
+ for (let i = 0; i < a.length; i++) {
76
+ const xa = a[i]! - ma;
77
+ const xb = b[i]! - mb;
78
+ num += xa * xb;
79
+ da += xa * xa;
80
+ db += xb * xb;
81
+ }
82
+ const den = Math.sqrt(da * db);
83
+ return den > 1e-9 ? r3(num / den) : 1;
84
+ }
85
+
86
+ /**
87
+ * Read per-frame yaw and pitch (degrees) from `nose`, `r_eye`, `l_eye`, and
88
+ * (when both are present) `r_ear` and `l_ear`.
89
+ *
90
+ * Sign convention (matching `rig-c` gallery `look` and the Part 1 range sweep):
91
+ * - `yaw > 0`: nose turned toward the subject's right (`r_eye`, viewer-left).
92
+ * - `pitch > 0`: nose tilted down (chin down / nod down).
93
+ */
94
+ export function readFaceAngles(
95
+ nose: readonly Pt[],
96
+ rEye: readonly Pt[],
97
+ lEye: readonly Pt[],
98
+ rEar: readonly Pt[] | null,
99
+ lEar: readonly Pt[] | null,
100
+ ): { ears: boolean; yaw: number[]; pitch: number[] } {
101
+ const n = nose.length;
102
+ const ears = rEar !== null && lEar !== null && rEar.length === n && lEar.length === n;
103
+ const xne: number[] = [];
104
+ const xna: number[] = [];
105
+ const yne: number[] = [];
106
+ const yna: number[] = [];
107
+
108
+ for (let i = 0; i < n; i++) {
109
+ const N = nose[i]!;
110
+ const Er = rEye[i]!;
111
+ const El = lEye[i]!;
112
+ const ex = El[0] - Er[0];
113
+ const ey = El[1] - Er[1];
114
+ const de = Math.hypot(ex, ey);
115
+ if (!(de > 1e-9)) {
116
+ xne.push(xne[i - 1] ?? 0);
117
+ yne.push(yne[i - 1] ?? 0);
118
+ if (ears) {
119
+ xna.push(xna[i - 1] ?? 0);
120
+ yna.push(yna[i - 1] ?? 0);
121
+ }
122
+ continue;
123
+ }
124
+ const ux = ex / de;
125
+ const uy = ey / de;
126
+ const vx = -uy;
127
+ const vy = ux;
128
+ const mex = 0.5 * (Er[0] + El[0]);
129
+ const mey = 0.5 * (Er[1] + El[1]);
130
+ xne.push(((mex - N[0]) * ux + (mey - N[1]) * uy) / de);
131
+ yne.push(((N[0] - mex) * vx + (N[1] - mey) * vy) / de);
132
+ if (ears) {
133
+ const Ar = rEar[i]!;
134
+ const Al = lEar[i]!;
135
+ const max = 0.5 * (Ar[0] + Al[0]);
136
+ const may = 0.5 * (Ar[1] + Al[1]);
137
+ const da = Math.max(de, (Al[0] - Ar[0]) * ux + (Al[1] - Ar[1]) * uy);
138
+ xna.push(((max - N[0]) * ux + (may - N[1]) * uy) / da);
139
+ yna.push(((N[0] - max) * vx + (N[1] - may) * vy) / de);
140
+ }
141
+ }
142
+
143
+ const yne0 = median(yne);
144
+ const yna0 = ears ? median(yna) : 0;
145
+ const yaw: number[] = [];
146
+ const pitch: number[] = [];
147
+ for (let i = 0; i < n; i++) {
148
+ const ty = ears ? (2 * xne[i]! + xna[i]!) / 2 : 2 * xne[i]!;
149
+ const tp = ears ? (yne[i]! - yne0 + (yna[i]! - yna0)) / 2 : yne[i]! - yne0;
150
+ yaw.push(Math.atan(ty) * DEG);
151
+ pitch.push(Math.atan(tp) * DEG);
152
+ }
153
+ return { ears, yaw, pitch };
154
+ }
155
+
156
+ /**
157
+ * Compress a read angle series into `axis.range` (`[min, max]` with `min <= 0 <= max`):
158
+ * negative readings scale by `kNeg = min(1, limits[0] / rawMin)` and positive
159
+ * readings by `kPos = min(1, limits[1] / rawMax)`, so peaks above the range are
160
+ * compressed to the limit rather than clipped flat, while motion already inside
161
+ * the range keeps unit gain (`1`).
162
+ */
163
+ export function mapFaceAxis(raw: readonly number[], axis: FaceAxis): FaceAxisReading {
164
+ const limits = axis.range;
165
+ let rawMin = Infinity;
166
+ let rawMax = -Infinity;
167
+ for (const x of raw) {
168
+ if (x < rawMin) rawMin = x;
169
+ if (x > rawMax) rawMax = x;
170
+ }
171
+ const kNeg = rawMin < limits[0] && rawMin < -1e-9 ? limits[0] / rawMin : 1;
172
+ const kPos = rawMax > limits[1] && rawMax > 1e-9 ? limits[1] / rawMax : 1;
173
+ const keys = raw.map((x) => r3(x < 0 ? x * kNeg : x * kPos));
174
+ return {
175
+ bone: axis.bone,
176
+ limits: [limits[0], limits[1]],
177
+ read: spanOf(raw),
178
+ mapped: spanOf(keys),
179
+ scale: [r3(kNeg), r3(kPos)],
180
+ corr: corrOf(raw, keys),
181
+ raw: [...raw],
182
+ keys,
183
+ };
184
+ }
185
+
186
+ export function readFace(
187
+ face: FaceMap,
188
+ nose: readonly Pt[],
189
+ rEye: readonly Pt[],
190
+ lEye: readonly Pt[],
191
+ rEar: readonly Pt[] | null,
192
+ lEar: readonly Pt[] | null,
193
+ ): FaceReading {
194
+ const { ears, yaw, pitch } = readFaceAngles(nose, rEye, lEye, rEar, lEar);
195
+ return {
196
+ ears,
197
+ read: {
198
+ yaw: spanOf(yaw),
199
+ pitch: spanOf(pitch),
200
+ rawYaw: yaw,
201
+ rawPitch: pitch,
202
+ },
203
+ ...(face.yaw === undefined ? {} : { yaw: mapFaceAxis(yaw, face.yaw) }),
204
+ ...(face.pitch === undefined ? {} : { pitch: mapFaceAxis(pitch, face.pitch) }),
205
+ };
206
+ }
207
+
208
+ const f1 = (x: number): string => x.toFixed(1);
209
+ const sg = (x: number): string => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
210
+
211
+ export function faceLine(f: FaceReading): string {
212
+ const joints = f.ears ? 'nose against r_eye + l_eye, r_ear + l_ear' : 'nose against r_eye + l_eye (ears absent)';
213
+ 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)}°)`;
214
+ const axes: string[] = [];
215
+ for (const name of ['yaw', 'pitch'] as const) {
216
+ const ax = f[name];
217
+ if (ax === undefined) continue;
218
+ axes.push(`${name} → bone "${ax.bone}" [${ax.limits[0]}, ${ax.limits[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)})`);
219
+ }
220
+ return `${joints}: ${readPart}; mapped ${axes.join(', ')}`;
221
+ }
package/src/legs.ts CHANGED
@@ -842,12 +842,16 @@ export type StanceLower = Record<Side, readonly number[]>;
842
842
  * fold as far as the picture's leg is folded on that frame, never less far
843
843
  * than the default. A leg with no straight length keeps the default.
844
844
  */
845
- export function stanceRangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>): StanceLower {
845
+ export function stanceRangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, inPlace = false): StanceLower {
846
846
  const out = {} as Record<Side, number[]>;
847
847
  for (const s of SIDES) {
848
848
  const len = ankle[s].map((a, i) => Math.hypot(a[0] - hip[s][i]![0], a[1] - hip[s][i]![1]));
849
- const straight = percentile(len, STRAIGHT_PERCENTILE);
850
- out[s] = len.map((l) => (straight > 1e-9 && Number.isFinite(l) ? Math.min(STANCE_BOUNDS[0], l / straight) : STANCE_BOUNDS[0]));
849
+ const n = len.length;
850
+ const globalStraight = percentile(len, STRAIGHT_PERCENTILE);
851
+ out[s] = len.map((l, i) => {
852
+ const straight = inPlace || n <= 25 ? globalStraight : percentile(len.slice(Math.max(0, i - 24), Math.min(n, i + 25)), STRAIGHT_PERCENTILE);
853
+ return straight > 1e-9 && Number.isFinite(l) ? Math.min(STANCE_BOUNDS[0], l / straight) : STANCE_BOUNDS[0];
854
+ });
851
855
  }
852
856
  return out;
853
857
  }
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
@@ -83,6 +83,8 @@ import type { ArtRange, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOpti
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
85
  import { 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,8 @@ 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;
325
329
  }
326
330
 
327
331
  /** The hip's pivot against the thigh roots, at setup (rig-c/pose#47). */
@@ -433,6 +437,11 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
433
437
  needed.add(map.translate.joint);
434
438
  map.translate.scale.pose.forEach((j) => needed.add(j));
435
439
  }
440
+ if (map.face !== undefined) {
441
+ needed.add('nose');
442
+ needed.add('r_eye');
443
+ needed.add('l_eye');
444
+ }
436
445
  const series = new Map<string, Pt[]>();
437
446
  const good = new Map<string, boolean[]>();
438
447
  const holds: Hold[] = [];
@@ -678,6 +687,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
678
687
  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
688
  }
680
689
  }
690
+ const poseScale: number[] | null =
691
+ t !== null && scale !== null && !inPlace
692
+ ? (() => {
693
+ const [pa, pb] = t.scale.pose;
694
+ const A = series.get(pa)!;
695
+ const B = series.get(pb)!;
696
+ const raw = A.map((a, i) => Math.hypot(B[i]![0] - a[0], B[i]![1] - a[1]));
697
+ return raw.map((_, i) => {
698
+ const win = raw.slice(Math.max(0, i - 12), Math.min(n, i + 13)).filter((v) => v > 1e-6);
699
+ const m = win.length > 0 ? median(win) : scale!.pose;
700
+ return m > 1e-6 ? m / scale!.pose : 1;
701
+ });
702
+ })()
703
+ : null;
681
704
  if (legScaleOn) {
682
705
  const limits = opts.legScale as [number, number];
683
706
  const rawSegments: Array<{ chain: string; bone: string; joints: [string, string]; kind: string; lengths: number[]; ownStraight: number }> = [];
@@ -703,6 +726,40 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
703
726
  }
704
727
  const straightByKind = new Map<string, number>();
705
728
  for (const seg of rawSegments) straightByKind.set(seg.kind, Math.max(straightByKind.get(seg.kind) ?? 0, seg.ownStraight));
729
+ if (!inPlace && n > 25) {
730
+ const byChain = new Map<string, typeof rawSegments>();
731
+ for (const seg of rawSegments) {
732
+ const list = byChain.get(seg.chain) ?? [];
733
+ list.push(seg);
734
+ byChain.set(seg.chain, list);
735
+ }
736
+ 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)));
737
+ const rawBySeg = new Map(rawSegments.map((seg) => [seg, [...seg.lengths]]));
738
+ for (const seg of rawSegments) {
739
+ const globalStraight = straightByKind.get(seg.kind)!;
740
+ const peers = rawSegments.filter((s) => s.kind === seg.kind);
741
+ seg.lengths = rawBySeg.get(seg)!.map((l, i) => {
742
+ const lo = Math.max(0, i - 24);
743
+ const hi = Math.min(n, i + 25);
744
+ let localStraight = 0;
745
+ for (const peer of peers) localStraight = Math.max(localStraight, percentile(rawBySeg.get(peer)!.slice(lo, hi), STRAIGHT_PERCENTILE));
746
+ if (chainTotals.length > 0 && straightByKind.size >= 2) {
747
+ let sumKindStraight = 0;
748
+ for (const kind of straightByKind.keys()) {
749
+ let ks = 0;
750
+ for (const s of rawSegments) if (s.kind === kind) ks = Math.max(ks, percentile(rawBySeg.get(s)!.slice(lo, hi), STRAIGHT_PERCENTILE));
751
+ sumKindStraight += ks;
752
+ }
753
+ let maxChainTotal = 0;
754
+ for (const tot of chainTotals) maxChainTotal = Math.max(maxChainTotal, percentile(tot.slice(lo, hi), STRAIGHT_PERCENTILE));
755
+ if (sumKindStraight > maxChainTotal && maxChainTotal > 1e-6) {
756
+ localStraight *= maxChainTotal / sumKindStraight;
757
+ }
758
+ }
759
+ return localStraight > 1e-6 ? l * (globalStraight / localStraight) : l;
760
+ });
761
+ }
762
+ }
706
763
  for (const seg of rawSegments) {
707
764
  const ls = legScaleOf(seg.lengths, limits, straightByKind.get(seg.kind)!);
708
765
  legScale.push({ chain: seg.chain, bone: seg.bone, joints: seg.joints, ...ls });
@@ -781,9 +838,16 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
781
838
  // (stanceOf's mechanism, its bounds) so its ankle stays on the floor. Before --stance, whose
782
839
  // floor is that supporting ankle. #95: the lower bound on a frame is the leg's own range there.
783
840
  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')! });
841
+ const lower = stanceRangeOf(ankleSeries, { r: series.get('r_hip')!, l: series.get('l_hip')! }, inPlace);
785
842
  blendHold = holdBlend(planted.blends, n, posedFeet, lower);
843
+ const beforeBlendX = blendHold.stretch.map((b, i) => (b === null ? 0 : posedFeet(i).ankle[b.side][0]));
786
844
  stretchChain(blendHold.stretch);
845
+ blendHold.stretch.forEach((b, i) => {
846
+ if (b === null) return;
847
+ const dx = beforeBlendX[i]! - posedFeet(i).ankle[b.side][0];
848
+ hip[i]![0] += dx;
849
+ travel[i]![0] += dx;
850
+ });
787
851
  // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
788
852
  // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
789
853
  if (opts.stance !== undefined && opts.stance !== 'off') {
@@ -970,11 +1034,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
970
1034
  if (refEye > 1e-6 && rigEye > 1e-6) nodScale = rigEye / refEye;
971
1035
  }
972
1036
  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);
1037
+ const bone = rest.get(hb)!;
1038
+ const parent = bone.parent === null ? null : rest.get(bone.parent)!;
1039
+ let neckRestLen = 0;
1040
+ for (let a = parent; a !== null; a = a.parent === null ? null : rest.get(a.parent)!) {
1041
+ const d = Math.hypot(bone.worldX - a.worldX, bone.worldY - a.worldY);
1042
+ if (d > 1e-6) {
1043
+ neckRestLen = d;
1044
+ break;
1045
+ }
1046
+ }
1047
+ const maxNod = !inPlace && neckRestLen > 1e-6 ? 0.02 * neckRestLen : Infinity;
1048
+ const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]) / (poseScale?.[i] ?? 1));
1049
+ const dy = refEN.map((v) => Math.max(-maxNod, Math.min(maxNod, (v - refEN[0]!) * nodScale!)));
975
1050
  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
1051
  const keys: MotionKey[] = [];
979
1052
  const nod: Pt[] = [];
980
1053
  for (let i = 0; i < n; i++) {
@@ -1281,6 +1354,26 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1281
1354
  }
1282
1355
  }
1283
1356
  }
1357
+ let face: FaceReading | null = null;
1358
+ if (map.face !== undefined) {
1359
+ const rEar = series.get('r_ear') ?? (() => {
1360
+ const h = heldOf('r_ear');
1361
+ return 'never' in h ? null : h.at;
1362
+ })();
1363
+ const lEar = series.get('l_ear') ?? (() => {
1364
+ const h = heldOf('l_ear');
1365
+ return 'never' in h ? null : h.at;
1366
+ })();
1367
+ face = readFace(map.face, series.get('nose')!, series.get('r_eye')!, series.get('l_eye')!, rEar, lEar);
1368
+ for (const axis of ['yaw', 'pitch'] as const) {
1369
+ const ax = face[axis];
1370
+ if (ax === undefined) continue;
1371
+ const rest0 = restAngle(rest, ax.bone, 'x');
1372
+ const above = mappedAbove(ax.bone);
1373
+ targets.set(ax.bone, ax.keys.map((v, i) => wrap(rest0 + (above === null ? 0 : turn(above, i)) + v)));
1374
+ tracks.push({ bone: ax.bone, property: 'rotate', keys: ax.keys.map((v, i) => ({ t: keyTimes[i]!, v: [v] })) });
1375
+ }
1376
+ }
1284
1377
  // Scale keys: a scaled leg segment's own scale, or the scale that lands a bone under one at its asked length.
1285
1378
  for (const c of map.chains) {
1286
1379
  for (const b of c.bones) {
@@ -1296,7 +1389,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1296
1389
  animation: { duration: keyTimes[n - 1]!, tracks },
1297
1390
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
1298
1391
  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,
1392
+ legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face,
1300
1393
  };
1301
1394
  }
1302
1395
 
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;
@@ -157,6 +158,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
157
158
  if (r.blendHold !== null) for (const l of blendLines(r.blendHold, r.times)) say(` FEET ${l}`);
158
159
  }
159
160
  if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
161
+ if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
160
162
 
161
163
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
162
164
  const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
@@ -183,7 +185,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
183
185
  if ((t.kind === 'bone' || t.kind === 'group') && Array.isArray(sample)) return boneIdentity(property, sample.length);
184
186
  return undefined;
185
187
  };
186
- const mapped = [...map.chains.flatMap((c) => c.bones), ...(map.translate === null ? [] : [map.translate.bone])];
188
+ const mapped = [
189
+ ...map.chains.flatMap((c) => c.bones),
190
+ ...(map.translate === null ? [] : [map.translate.bone]),
191
+ ...(map.face?.yaw === undefined ? [] : [map.face.yaw.bone]),
192
+ ...(map.face?.pitch === undefined ? [] : [map.face.pitch.bone]),
193
+ ];
187
194
  const below = new Map<string, string[]>();
188
195
  for (const m of new Set(mapped)) {
189
196
  for (let b = rest.get(m)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) {
@@ -317,6 +324,16 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
317
324
  raised: r.raised,
318
325
  // Only when a set declares `place` (#93): a map without one writes the report it always has.
319
326
  ...(map.alternates.some((x) => x.place !== undefined && x.place !== null) ? { byHip: r.byHip } : {}),
327
+ ...(r.face === null
328
+ ? {}
329
+ : {
330
+ face: {
331
+ ears: r.face.ears,
332
+ read: { yaw: r.face.read.yaw, pitch: r.face.read.pitch },
333
+ ...(r.face.yaw === undefined ? {} : { yaw: { bone: r.face.yaw.bone, limits: r.face.yaw.limits, read: r.face.yaw.read, mapped: r.face.yaw.mapped, scale: r.face.yaw.scale, corr: r.face.yaw.corr } }),
334
+ ...(r.face.pitch === undefined ? {} : { pitch: { bone: r.face.pitch.bone, limits: r.face.pitch.limits, read: r.face.pitch.read, mapped: r.face.pitch.mapped, scale: r.face.pitch.scale, corr: r.face.pitch.corr } }),
335
+ },
336
+ }),
320
337
  legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
321
338
  pivot: r.pivot,
322
339
  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 },
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)!;