@rig-c/pose 0.22.1 → 0.23.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
@@ -114,7 +114,7 @@ Nothing is written until every check passes:
114
114
  | Check | Fails with |
115
115
  | --- | --- |
116
116
  | rig-c's gate is green with the rig as it is, and again with the animation in (`rigc build`) | `RIG_GATE_BASE`, `RIG_GATE`, then rigc's own `FAIL` lines |
117
- | every mapped bone **lands** on the angle the pose asks for, on every frame — the gated build is posed by rig-c's poser and measured (0.01° / 0.01 units); with `--leg-scale`, every scaled bone's x axis on its asked scale (0.0001 of its setup length), and with either leg flag each ankle where the keys put it (0.01 units + 0.0001 × its setup distance from the hip) | `POSE_LANDS`: the bone, the worst error, the frame |
117
+ | every mapped bone **lands** on the angle the pose asks for, on every frame — the gated build is posed by rig-c's poser and measured (0.01° / 0.01 units); with `--leg-scale`, every scaled bone's x axis (and the trunk's y axis when trunk foreshortening is keyed, [#172](https://github.com/rig-c/pose/issues/172)) on its asked scale (0.0001 of its setup length), and with either leg flag each ankle where the keys put it (0.01 units + 0.0001 × its setup distance from the hip) | `POSE_LANDS`: the bone, the worst error, the frame |
118
118
  | elbows and knees bend only the way they can: at every joint inside a chain, the inner angle stays on the side of the rest pose's | `POSE_BEND_ONE_WAY`: the chain, the joint, the frame, the angle |
119
119
  | every point the map needs is good on at least one frame | `POSE_POINT_NEVER_GOOD` |
120
120
  | the map resolves against the rig: bones exist, chains descend, aims descend | `MAP_*`, naming both sides |
@@ -143,10 +143,11 @@ 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
+ - `FACE` — with `face` in the map ([#138](https://github.com/rig-c/pose/issues/138), [#173](https://github.com/rig-c/pose/issues/173)): 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, when `face.yaw.sets` (or `face.sets`) is declared, every yaw-switched set's entered spans, swap count, and any `hides` slot keyed by another track, written as `face` in `--report`.
147
+ - `BODY` — per-frame body yaw read from the camera-normalised shoulder width (`r_shoulder + l_shoulder`) and hip width (`r_hip + l_hip`) projected perpendicular to the trunk against the sequence's standing width (`yaw ≈ acos(w / w_standing)`, [#173](https://github.com/rig-c/pose/issues/173)): its overall and opening (`frames 0–47`) range, every span with `|yaw| ≥ 45°`, and, when `body.yaw` or `body.sets` is declared in the map, its knee-mapped dial keys and every yaw-switched set's entered spans, swap count, and any `hides` slot keyed by another track, written as `body` in `--report`.
147
148
  - `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.
148
149
 
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:
150
+ `--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`, `body` (`standing`, `read`, `over45`, `framesOver45`, and with `body.yaw` or `body.sets` in the map, `yaw` and `sets`), `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:
150
151
 
151
152
  | field | each entry |
152
153
  | --- | --- |
@@ -196,7 +197,7 @@ Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. O
196
197
 
197
198
  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:
198
199
 
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.
200
+ **`--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` derived from posture-invariant measures — `max(r_thigh + r_shin, l_thigh + l_shin)`, shoulder width and eye width, never the trunk — with frame-to-frame step ≤ 1 % and frozen spans ≥ 5 frames held at their median, so neither camera zoom nor a forward trunk bend reads as the camera moving, 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), [#172](https://github.com/rig-c/pose/issues/172)), clamped to [min, max]. When the camera-normalised trunk (`neck → mid_hip` or `mid_shoulder → mid_hip`) foreshortens or extends by more than 4 % from its standing length, the trunk bone is keyed in `scaley` (for a `+y`-aimed trunk, `scalex` for `+x`) clamped to the same `[min, max]` bounds, carrying the trunk and head's depth bend while keeping the aimed rotation ([#172](https://github.com/rig-c/pose/issues/172)). 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.
200
201
 
201
202
  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.
202
203
 
@@ -314,7 +315,8 @@ Which bones carry which joints. You write it for your rig:
314
315
  ⚠️ **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.
315
316
  - `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
316
317
  - `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.
317
- - `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`).
318
+ - `face` (optional, [#138](https://github.com/rig-c/pose/issues/138), [#164](https://github.com/rig-c/pose/issues/164), [#173](https://github.com/rig-c/pose/issues/173)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15], "sets"?: [{ "set": "T1", "above": 45 }] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] }, "sets"?: [{ "set": "T1", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Reads per-frame yaw and pitch from `nose` against `r_eye + l_eye` (and `r_ear + l_ear` when present), maps 1:1 inside `0.8 × range` (`[-12, 12]°` on `[-15, 15]°`) and compresses only the overshoot above the knee into the remaining 20% of the range so a single outlier frame does not crush the rest of the clip, and keys the named dial bone's `rotate` track (`FACE`). When `sets` (`[{ "set": "<name>", "above": <deg> }]`, `0 < above ≤ 90`) is declared on `face.yaw.sets` or `face.sets`, each set enters on frames where `|yaw| ≥ above` (or the highest `above` satisfied when multiple sets are declared) and leaves only when `|yaw| < above − 10°` (`10°` hysteresis), emitting stepped slot `attachment` keys for the set's `slots` and `hides` (resolved from `alternates` in the map or `sets.json` (`rig-parts-sets/1`) beside `rig.json`; an unknown set is refused by name as `MAP_SET_EXISTS`).
319
+ - `body` (optional, [#173](https://github.com/rig-c/pose/issues/173)): `{ "yaw"?: { "bone": "body_yaw", "range": [-30, 30], "sets"?: [{ "set": "T8", "above": 45 }] }, "sets"?: [{ "set": "T8", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Maps the per-frame body yaw read from the camera-normalised shoulder and hip width (`BODY`) into `range` with the same `0.8 × range` knee mapping as `face`, keying the named dial bone's `rotate` track, and switches any declared `sets` (`body.sets` or `body.yaw.sets`) with the same `above` / `above − 10°` hysteresis (overriding `face` sets and arm `alternates` on slots they share while active). Without `body` in the map, `BODY` and `--report`'s `body` still report the reading while leaving every track in `motion.json` unchanged.
318
320
 
319
321
  ### A joint's range: `range`
320
322
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.22.1",
3
+ "version": "0.23.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 CHANGED
@@ -5,7 +5,7 @@
5
5
  * and map each configured axis into its declared range by compression (never
6
6
  * clipping, so motion changes at the extremes survive).
7
7
  */
8
- import type { FaceAxis, FaceMap } from './map.ts';
8
+ import type { BodyMap, FaceAxis, FaceMap, YawSet } from './map.ts';
9
9
 
10
10
  type Pt = [number, number];
11
11
 
@@ -18,6 +18,21 @@ const DEG = 180 / Math.PI;
18
18
  */
19
19
  export const FACE_KNEE = 0.8;
20
20
 
21
+ /** Hysteresis in degrees for leaving a yaw-selected set (`leave = above - 10°`, `rig-c/pose#173`). */
22
+ export const SET_HYSTERESIS_DEG = 10;
23
+
24
+ export interface YawSetSwitch {
25
+ set: string;
26
+ above: number;
27
+ leave: number;
28
+ /** Inclusive `[first, last]` frame spans where the set is shown. */
29
+ spans: Array<[number, number]>;
30
+ /** Total number of attachment transitions (enter + leave boundaries inside the clip). */
31
+ swaps: number;
32
+ /** Any `hides` slot of this set that is also keyed by another track, with the track that keys it. */
33
+ hidesKeyed: Array<{ slot: string; by: string }>;
34
+ }
35
+
21
36
  export interface FaceSpan {
22
37
  min: number;
23
38
  max: number;
@@ -51,6 +66,7 @@ export interface FaceReading {
51
66
  };
52
67
  yaw?: FaceAxisReading;
53
68
  pitch?: FaceAxisReading;
69
+ sets?: YawSetSwitch[];
54
70
  }
55
71
 
56
72
  const r3 = (v: number): number => {
@@ -246,6 +262,26 @@ export function readFace(
246
262
  const f1 = (x: number): string => x.toFixed(1);
247
263
  const sg = (x: number): string => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
248
264
 
265
+ function formatYawSets(sets: readonly YawSetSwitch[] | undefined, raw: readonly number[]): string {
266
+ if (sets === undefined || sets.length === 0) return '';
267
+ const parts = sets.map((s) => {
268
+ const spanStr = s.spans.length === 0
269
+ ? 'none (0 swaps)'
270
+ : `${s.spans.map(([lo, hi]) => {
271
+ const slice = raw.slice(lo, hi + 1);
272
+ const sMin = Math.min(...slice);
273
+ const sMax = Math.max(...slice);
274
+ const where = lo === hi ? `frame ${lo}` : `frames ${lo}–${hi}`;
275
+ return `${where} (${lo === hi ? `${sg(sMax)}°` : `${sg(sMin)}° … ${sg(sMax)}°`})`;
276
+ }).join(', ')} (${s.swaps} swap${s.swaps === 1 ? '' : 's'})`;
277
+ const hidesStr = s.hidesKeyed.length === 0
278
+ ? ''
279
+ : `; hides keyed by another track: ${s.hidesKeyed.map((h) => `slot "${h.slot}" (${h.by})`).join(', ')}`;
280
+ return `set "${s.set}" (≥ ${s.above}°, leave < ${s.leave}°): ${spanStr}${hidesStr}`;
281
+ });
282
+ return `; sets ${parts.join('; ')}`;
283
+ }
284
+
249
285
  export function faceLine(f: FaceReading): string {
250
286
  const joints = f.ears ? 'nose against r_eye + l_eye, r_ear + l_ear' : 'nose against r_eye + l_eye (ears absent)';
251
287
  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)}°)`;
@@ -255,5 +291,262 @@ export function faceLine(f: FaceReading): string {
255
291
  if (ax === undefined) continue;
256
292
  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
293
  }
258
- return `${joints}: ${readPart}; mapped ${axes.join(', ')}`;
294
+ const mappedPart = axes.length === 0 ? '' : `; mapped ${axes.join(', ')}`;
295
+ return `${joints}: ${readPart}${mappedPart}${formatYawSets(f.sets, f.read.rawYaw)}`;
259
296
  }
297
+
298
+ /**
299
+ * Select yaw-switched sets (`rig-c/pose#173` Part 2):
300
+ * - A set is entered when `raw[i] >= above` for at least `switchMin` seconds,
301
+ * and left when `raw[i] < above - SET_HYSTERESIS_DEG` (`above - 10°`) for at
302
+ * least `switchMin` seconds.
303
+ * - Between two frames of the same set separated by `< switchMin`, nothing flips (`#162`).
304
+ */
305
+ export function selectYawSets(
306
+ raw: readonly number[],
307
+ declared: readonly YawSet[] | undefined,
308
+ times: readonly number[],
309
+ switchMin: number,
310
+ ): YawSetSwitch[] {
311
+ if (declared === undefined || declared.length === 0 || raw.length === 0) return [];
312
+ const n = raw.length;
313
+ const endOf = (i: number): number => (i + 1 < n ? times[i + 1]! : n > 1 ? 2 * times[i]! - times[i - 1]! : times[i]!);
314
+ const spanSec = (lo: number, hi: number): number => endOf(hi) - times[lo]!;
315
+
316
+ return declared.map((decl) => {
317
+ const above = decl.above;
318
+ const leave = r3(above - SET_HYSTERESIS_DEG);
319
+ const active = new Array<boolean>(n).fill(false);
320
+ let inSet = false;
321
+ let i = 0;
322
+ while (i < n) {
323
+ if (!inSet) {
324
+ if (raw[i]! >= above) {
325
+ let j = i;
326
+ while (j + 1 < n && raw[j + 1]! >= above) j++;
327
+ if (spanSec(i, j) >= switchMin - 1e-9) {
328
+ inSet = true;
329
+ for (let k = i; k <= j; k++) active[k] = true;
330
+ }
331
+ i = j + 1;
332
+ } else {
333
+ i++;
334
+ }
335
+ } else {
336
+ if (raw[i]! < leave) {
337
+ let j = i;
338
+ while (j + 1 < n && raw[j + 1]! < leave) j++;
339
+ if (spanSec(i, j) >= switchMin - 1e-9) {
340
+ inSet = false;
341
+ } else {
342
+ for (let k = i; k <= j; k++) active[k] = true;
343
+ }
344
+ i = j + 1;
345
+ } else {
346
+ active[i] = true;
347
+ i++;
348
+ }
349
+ }
350
+ }
351
+ // #162: bridge any remaining gap between two spans of the same set shorter than switchMin.
352
+ for (let k = 0; k < n;) {
353
+ if (active[k]) {
354
+ k++;
355
+ continue;
356
+ }
357
+ let j = k;
358
+ while (j + 1 < n && !active[j + 1]) j++;
359
+ if (k > 0 && j + 1 < n && active[k - 1] && active[j + 1] && spanSec(k, j) < switchMin - 1e-9) {
360
+ for (let m = k; m <= j; m++) active[m] = true;
361
+ }
362
+ k = j + 1;
363
+ }
364
+ const spans: Array<[number, number]> = [];
365
+ for (let k = 0; k < n; k++) {
366
+ if (!active[k]) continue;
367
+ const last = spans[spans.length - 1];
368
+ if (last !== undefined && last[1] === k - 1) last[1] = k;
369
+ else spans.push([k, k]);
370
+ }
371
+ let swaps = 0;
372
+ for (const [lo, hi] of spans) {
373
+ swaps++;
374
+ if (hi < n - 1) swaps++;
375
+ }
376
+ return { set: decl.set, above, leave, spans, swaps, hidesKeyed: [] };
377
+ });
378
+ }
379
+
380
+ /** Threshold in degrees at or above which a frame is listed on `BODY` as needing a turned-body drawing (`rig-c/pose#173`). */
381
+ export const BODY_TURN_DEG = 45;
382
+
383
+ export interface BodyReading {
384
+ /** Camera-normalised standing widths in picture pixels (at frame 0's scale). */
385
+ standing: {
386
+ shoulder: number;
387
+ hip: number;
388
+ };
389
+ read: {
390
+ yaw: FaceSpan;
391
+ opening: FaceSpan;
392
+ rawYaw: number[];
393
+ };
394
+ /** Inclusive `[first, last]` frame spans where `|yaw| >= BODY_TURN_DEG` (`45°`). */
395
+ over45: Array<[number, number]>;
396
+ /** Frame indices where `|yaw| >= BODY_TURN_DEG` (`45°`). */
397
+ framesOver45: number[];
398
+ /** Present when the map declares `body.yaw`. */
399
+ yaw?: FaceAxisReading;
400
+ /** Present when the map declares `body.sets` or `body.yaw.sets`. */
401
+ sets?: YawSetSwitch[];
402
+ }
403
+
404
+ /**
405
+ * Read per-frame body yaw (degrees) from camera-normalised shoulder width
406
+ * (`r_shoulder`, `l_shoulder`) and hip width (`r_hip`, `l_hip`), projected
407
+ * perpendicular to the trunk (`mid_hip → neck`) so raising one shoulder or
408
+ * tilting the torso in 2D roll does not distort the horizontal torso width,
409
+ * against the sequence's standing width (`yaw ≈ acos(w / w_standing)`,
410
+ * `rig-c/pose#173`).
411
+ *
412
+ * Sign convention (matching `readFaceAngles`):
413
+ * - `yaw > 0`: torso turned toward the subject's right (viewer-left, left shoulder nearer).
414
+ * - `yaw < 0`: torso turned toward the subject's left (viewer-right, right shoulder nearer).
415
+ */
416
+ export function readBodyYaw(
417
+ rShoulder: readonly Pt[],
418
+ lShoulder: readonly Pt[],
419
+ rHip: readonly Pt[] | null,
420
+ lHip: readonly Pt[] | null,
421
+ neck: readonly Pt[] | null,
422
+ scale: readonly number[] | null,
423
+ faceYaw: readonly number[] | null,
424
+ ): { standing: { shoulder: number; hip: number }; yaw: number[] } {
425
+ const n = rShoulder.length;
426
+ const hasHips = rHip !== null && lHip !== null && rHip.length === n && lHip.length === n;
427
+ const wSh: number[] = [];
428
+ const wHip: number[] = [];
429
+ const signs: number[] = [];
430
+ const revSh: boolean[] = [];
431
+
432
+ for (let i = 0; i < n; i++) {
433
+ const rS = rShoulder[i]!;
434
+ const lS = lShoulder[i]!;
435
+ const s = scale !== null && scale[i] !== undefined && scale[i]! > 1e-6 ? scale[i]! : 1;
436
+ const mS: Pt = [0.5 * (rS[0] + lS[0]), 0.5 * (rS[1] + lS[1])];
437
+ const mH: Pt = hasHips ? [0.5 * (rHip[i]![0] + lHip[i]![0]), 0.5 * (rHip[i]![1] + lHip[i]![1])] : [mS[0], mS[1] + 100];
438
+ const top: Pt = neck !== null && neck[i] !== undefined ? neck[i]! : mS;
439
+ const tx = top[0] - mH[0];
440
+ const ty = top[1] - mH[1];
441
+ const tl = Math.hypot(tx, ty);
442
+ const nx = tl > 1e-6 ? -ty / tl : 1;
443
+ const ny = tl > 1e-6 ? tx / tl : 0;
444
+
445
+ const projSh = ((lS[0] - rS[0]) * nx + (lS[1] - rS[1]) * ny) / s;
446
+ const absSh = Math.abs(projSh);
447
+ wSh.push(absSh);
448
+ revSh.push(projSh < -1e-6);
449
+
450
+ if (hasHips) {
451
+ const rH = rHip[i]!;
452
+ const lH = lHip[i]!;
453
+ const projHip = ((lH[0] - rH[0]) * nx + (lH[1] - rH[1]) * ny) / s;
454
+ wHip.push(Math.abs(projHip));
455
+ } else {
456
+ wHip.push(absSh);
457
+ }
458
+
459
+ const neckOff = neck !== null && neck[i] !== undefined ? ((mS[0] - neck[i]![0]) * nx + (mS[1] - neck[i]![1]) * ny) / s : 0;
460
+ const fy = faceYaw !== null && faceYaw[i] !== undefined ? faceYaw[i]! : 0;
461
+ let sign = 1;
462
+ if (Math.abs(neckOff) > 0.05 * Math.max(1, absSh)) sign = neckOff >= 0 ? 1 : -1;
463
+ else if (Math.abs(fy) > 0.5) sign = fy >= 0 ? 1 : -1;
464
+ else {
465
+ const shear = (mS[0] - mH[0]) * nx + (mS[1] - mH[1]) * ny;
466
+ if (Math.abs(shear) > 1e-6) sign = shear >= 0 ? 1 : -1;
467
+ else if (i > 0) sign = signs[i - 1]!;
468
+ }
469
+ signs.push(sign);
470
+ }
471
+
472
+ const openN = Math.min(n, 48);
473
+ const sh0 = Math.max(1e-6, Math.min(median(wSh.slice(0, openN)), median(wSh)));
474
+ const hip0 = Math.max(1e-6, Math.min(median(wHip.slice(0, openN)), median(wHip)));
475
+
476
+ const yaw: number[] = [];
477
+ for (let i = 0; i < n; i++) {
478
+ if (revSh[i]) {
479
+ yaw.push(signs[i]! * 90);
480
+ continue;
481
+ }
482
+ const rS = Math.min(1, wSh[i]! / sh0);
483
+ const rH = hasHips ? Math.min(1, wHip[i]! / hip0) : rS;
484
+ const mx = Math.min(Math.max(rS, rH), rS / 0.65);
485
+ const mn = Math.min(rS, rH);
486
+ const alpha = Math.max(0, Math.min(1, (mx - 0.85) / 0.1));
487
+ const w = Math.max(0, Math.min(1, alpha * Math.max(rS, rH) + (1 - alpha) * mn));
488
+ const mag = Math.acos(w) * DEG;
489
+ yaw.push(mag < 1e-6 ? 0 : signs[i]! * mag);
490
+ }
491
+
492
+ return {
493
+ standing: { shoulder: r3(sh0), hip: r3(hasHips ? hip0 : 0) },
494
+ yaw,
495
+ };
496
+ }
497
+
498
+ export function readBody(
499
+ body: BodyMap | undefined,
500
+ rShoulder: readonly Pt[],
501
+ lShoulder: readonly Pt[],
502
+ rHip: readonly Pt[] | null,
503
+ lHip: readonly Pt[] | null,
504
+ neck: readonly Pt[] | null,
505
+ scale: readonly number[] | null,
506
+ faceYaw: readonly number[] | null,
507
+ ): BodyReading {
508
+ const { standing, yaw } = readBodyYaw(rShoulder, lShoulder, rHip, lHip, neck, scale, faceYaw);
509
+ const openN = Math.min(yaw.length, 48);
510
+ const framesOver45: number[] = [];
511
+ const over45: Array<[number, number]> = [];
512
+ for (let i = 0; i < yaw.length; i++) {
513
+ if (Math.abs(yaw[i]!) >= BODY_TURN_DEG) {
514
+ framesOver45.push(i);
515
+ const last = over45[over45.length - 1];
516
+ if (last !== undefined && last[1] === i - 1) last[1] = i;
517
+ else over45.push([i, i]);
518
+ }
519
+ }
520
+ return {
521
+ standing,
522
+ read: {
523
+ yaw: spanOf(yaw),
524
+ opening: spanOf(yaw.slice(0, openN)),
525
+ rawYaw: yaw.map(r3),
526
+ },
527
+ over45,
528
+ framesOver45,
529
+ ...(body?.yaw === undefined ? {} : { yaw: mapFaceAxis(yaw, body.yaw) }),
530
+ };
531
+ }
532
+
533
+ export function bodyLine(b: BodyReading): string {
534
+ const openEnd = Math.min(b.read.rawYaw.length, 48) - 1;
535
+ const openAbs = Math.max(Math.abs(b.read.opening.min), Math.abs(b.read.opening.max));
536
+ const spans = b.over45.map(([lo, hi]) => {
537
+ const slice = b.read.rawYaw.slice(lo, hi + 1);
538
+ const sMin = Math.min(...slice);
539
+ const sMax = Math.max(...slice);
540
+ const where = lo === hi ? `frame ${lo}` : `frames ${lo}–${hi}`;
541
+ return `${where} (${lo === hi ? `${sg(sMax)}°` : `${sg(sMin)}° … ${sg(sMax)}°`})`;
542
+ });
543
+ const overPart = b.framesOver45.length === 0
544
+ ? `≥ ${BODY_TURN_DEG}°: none`
545
+ : `≥ ${BODY_TURN_DEG}° on ${b.framesOver45.length} frame(s): ${spans.join(', ')}`;
546
+ const head = `r_shoulder + l_shoulder, r_hip + l_hip (standing shoulder ${b.standing.shoulder.toFixed(1)}px, hip ${b.standing.hip.toFixed(1)}px): read yaw ${sg(b.read.yaw.min)}° … ${sg(b.read.yaw.max)}° (${f1(b.read.yaw.range)}°), opening (frames 0–${openEnd}) ${sg(b.read.opening.min)}° … ${sg(b.read.opening.max)}° (max |yaw| ${f1(openAbs)}°); ${overPart}`;
547
+ const mappedPart = b.yaw === undefined
548
+ ? ''
549
+ : `; mapped yaw → bone "${b.yaw.bone}" [${b.yaw.limits[0]}, ${b.yaw.limits[1]}]° (knee [${b.yaw.knee[0]}, ${b.yaw.knee[1]}]°): ${sg(b.yaw.mapped.min)}° … ${sg(b.yaw.mapped.max)}° (scale ${b.yaw.scale[0].toFixed(3)}/${b.yaw.scale[1].toFixed(3)}, r=${b.yaw.corr.toFixed(3)})`;
550
+ return `${head}${mappedPart}${formatYawSets(b.sets, b.read.rawYaw)}`;
551
+ }
552
+
package/src/map.ts CHANGED
@@ -101,20 +101,38 @@ 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 YawSet {
105
+ set: string;
106
+ above: number;
107
+ }
104
108
  export interface FaceAxis {
105
109
  bone: string;
106
110
  range: [number, number];
111
+ sets?: YawSet[];
107
112
  }
108
113
  export interface FaceMap {
109
114
  yaw?: FaceAxis;
110
115
  pitch?: FaceAxis;
116
+ sets?: YawSet[];
117
+ }
118
+ export interface BodyMap {
119
+ yaw?: FaceAxis;
120
+ sets?: YawSet[];
111
121
  }
112
122
  export interface JointMap {
113
123
  chains: Chain[];
114
124
  translate: Translate | null;
115
125
  alternates: AlternateSet[];
116
126
  face?: FaceMap;
127
+ body?: BodyMap;
128
+ }
129
+
130
+ /** An alternate attachment set from `sets.json` (`rig-parts-sets/1`, `rig-c/pose#173`). */
131
+ export interface RigSet {
132
+ slots: Record<string, string>;
133
+ hides: string[];
117
134
  }
135
+ export type RigSets = ReadonlyMap<string, RigSet>;
118
136
 
119
137
  /** What a slot of the built rig shows at setup, and every attachment any skin gives it. */
120
138
  export interface RigSlot {
@@ -144,7 +162,7 @@ export function readMap(text: string, where: string): JointMap {
144
162
  p.throwIfAny();
145
163
  throw new Error('unreachable');
146
164
  }
147
- refuseUnknownKeys(p, 'MAP_KEYS', where, doc, ['spec', 'note', 'chains', 'translate', 'alternates', 'face']);
165
+ refuseUnknownKeys(p, 'MAP_KEYS', where, doc, ['spec', 'note', 'chains', 'translate', 'alternates', 'face', 'body']);
148
166
  if (doc.spec !== MAP_SPEC) p.add('MAP_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${MAP_SPEC}"`);
149
167
  const chains: Chain[] = [];
150
168
  if (!Array.isArray(doc.chains) || doc.chains.length === 0) {
@@ -245,22 +263,60 @@ export function readMap(text: string, where: string): JointMap {
245
263
  }
246
264
  const alternates = 'alternates' in doc ? readAlternates(p, where, doc.alternates, chains, translate) : [];
247
265
  const face = 'face' in doc ? readFaceMap(p, where, doc.face) : undefined;
266
+ const body = 'body' in doc ? readBodyMap(p, where, doc.body) : undefined;
248
267
  p.throwIfAny();
249
- return { chains, translate, alternates, ...(face === undefined ? {} : { face }) };
268
+ return { chains, translate, alternates, ...(face === undefined ? {} : { face }), ...(body === undefined ? {} : { body }) };
269
+ }
270
+
271
+ function readYawSets(p: Problems, where: string, raw: unknown, shapeCode: string): YawSet[] | undefined {
272
+ if (!Array.isArray(raw) || raw.length === 0) {
273
+ p.add(shapeCode, `${where} is ${typeName(raw)}; it must be a non-empty array of { set, above }`);
274
+ return undefined;
275
+ }
276
+ const out: YawSet[] = [];
277
+ const seen = new Set<string>();
278
+ raw.forEach((entry: unknown, i: number) => {
279
+ const ew = `${where}[${i}]`;
280
+ if (!isObject(entry)) {
281
+ p.add(shapeCode, `${ew} is ${typeName(entry)}; it is { set, above }`);
282
+ return;
283
+ }
284
+ refuseUnknownKeys(p, 'MAP_KEYS', ew, entry, ['set', 'above']);
285
+ const set = typeof entry.set === 'string' && entry.set !== '' ? entry.set : null;
286
+ if (set === null) p.add('MAP_SET_NAME', `${ew} "set" is ${typeName(entry.set)}; it must be a non-empty string`);
287
+ else if (seen.has(set)) p.add('MAP_SET_NAME', `${ew} "set" "${set}" names two entries`);
288
+ if (set !== null) seen.add(set);
289
+ const above = typeof entry.above === 'number' && Number.isFinite(entry.above) && entry.above > 0 ? entry.above : null;
290
+ if (above === null) p.add('MAP_SET_ABOVE', `${ew} "above" is ${JSON.stringify(entry.above)}; it must be a positive finite number of degrees`);
291
+ if (set !== null && above !== null) out.push({ set, above });
292
+ });
293
+ return out.length > 0 ? out : undefined;
294
+ }
295
+
296
+ export function faceYawSets(face: FaceMap | undefined): YawSet[] | undefined {
297
+ return face?.yaw?.sets ?? face?.sets;
298
+ }
299
+
300
+ export function bodyYawSets(body: BodyMap | undefined): YawSet[] | undefined {
301
+ return body?.sets ?? body?.yaw?.sets;
250
302
  }
251
303
 
252
304
  function readFaceMap(p: Problems, where: string, raw: unknown): FaceMap | undefined {
253
305
  const fw = `${where} "face"`;
254
306
  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] } }`);
307
+ p.add('MAP_FACE_SHAPE', `${fw} is ${typeName(raw)}; it is { yaw?: { bone, range: [min, max], sets? }, pitch?: { bone, range: [min, max] }, sets? }`);
256
308
  return undefined;
257
309
  }
258
- refuseUnknownKeys(p, 'MAP_KEYS', fw, raw, ['yaw', 'pitch']);
259
- if (!('yaw' in raw) && !('pitch' in raw)) {
310
+ refuseUnknownKeys(p, 'MAP_KEYS', fw, raw, ['yaw', 'pitch', 'sets']);
311
+ if (!('yaw' in raw) && !('pitch' in raw) && !('sets' in raw)) {
260
312
  p.add('MAP_FACE_SHAPE', `${fw} names neither "yaw" nor "pitch"; it must name at least one axis`);
261
313
  return undefined;
262
314
  }
263
315
  const out: FaceMap = {};
316
+ if ('sets' in raw) {
317
+ const sets = readYawSets(p, `${fw} "sets"`, raw.sets, 'MAP_FACE_SHAPE');
318
+ if (sets !== undefined) out.sets = sets;
319
+ }
264
320
  for (const axis of ['yaw', 'pitch'] as const) {
265
321
  if (!(axis in raw)) continue;
266
322
  const aw = `${fw} "${axis}"`;
@@ -269,19 +325,67 @@ function readFaceMap(p: Problems, where: string, raw: unknown): FaceMap | undefi
269
325
  p.add('MAP_FACE_SHAPE', `${aw} is ${typeName(ax)}; it is { bone, range: [min, max] }`);
270
326
  continue;
271
327
  }
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] };
328
+ refuseUnknownKeys(p, 'MAP_KEYS', aw, ax, axis === 'yaw' ? ['bone', 'range', 'sets'] : ['bone', 'range']);
329
+ const sets = axis === 'yaw' && 'sets' in ax ? readYawSets(p, `${aw} "sets"`, ax.sets, 'MAP_FACE_SHAPE') : undefined;
330
+ const hasBoneOrRange = 'bone' in ax || 'range' in ax || !('sets' in ax);
331
+ if (hasBoneOrRange) {
332
+ const bone = typeof ax.bone === 'string' && ax.bone !== '' ? ax.bone : null;
333
+ if (bone === null) p.add('MAP_FACE_BONE', `${aw} "bone" is ${typeName(ax.bone)}; it must name a bone`);
334
+ const v = ax.range;
335
+ 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)) {
336
+ 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`);
337
+ } else if (bone !== null) {
338
+ out[axis] = { bone, range: [v[0] as number, v[1] as number], ...(sets === undefined ? {} : { sets }) };
339
+ }
340
+ } else if (sets !== undefined) {
341
+ out.sets = sets;
280
342
  }
281
343
  }
282
344
  return out;
283
345
  }
284
346
 
347
+ function readBodyMap(p: Problems, where: string, raw: unknown): BodyMap | undefined {
348
+ const bw = `${where} "body"`;
349
+ if (!isObject(raw)) {
350
+ p.add('MAP_BODY_SHAPE', `${bw} is ${typeName(raw)}; it is { yaw?: { bone, range: [min, max] }, sets?: [{ set, above }] }`);
351
+ return undefined;
352
+ }
353
+ refuseUnknownKeys(p, 'MAP_KEYS', bw, raw, ['yaw', 'sets']);
354
+ if (!('yaw' in raw) && !('sets' in raw)) {
355
+ p.add('MAP_BODY_SHAPE', `${bw} does not name "yaw" or "sets"; it must name at least one`);
356
+ return undefined;
357
+ }
358
+ const out: BodyMap = {};
359
+ if ('sets' in raw) {
360
+ const sets = readYawSets(p, `${bw} "sets"`, raw.sets, 'MAP_BODY_SHAPE');
361
+ if (sets !== undefined) out.sets = sets;
362
+ }
363
+ if ('yaw' in raw) {
364
+ const aw = `${bw} "yaw"`;
365
+ const ax = raw.yaw;
366
+ if (!isObject(ax)) {
367
+ p.add('MAP_BODY_SHAPE', `${aw} is ${typeName(ax)}; it is { bone, range: [min, max] }`);
368
+ return undefined;
369
+ }
370
+ refuseUnknownKeys(p, 'MAP_KEYS', aw, ax, ['bone', 'range', 'sets']);
371
+ const sets = 'sets' in ax ? readYawSets(p, `${aw} "sets"`, ax.sets, 'MAP_BODY_SHAPE') : undefined;
372
+ const hasBoneOrRange = 'bone' in ax || 'range' in ax || !('sets' in ax);
373
+ if (hasBoneOrRange) {
374
+ const bone = typeof ax.bone === 'string' && ax.bone !== '' ? ax.bone : null;
375
+ if (bone === null) p.add('MAP_BODY_BONE', `${aw} "bone" is ${typeName(ax.bone)}; it must name a bone`);
376
+ const v = ax.range;
377
+ 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)) {
378
+ p.add('MAP_BODY_RANGE', `${aw} "range" is ${JSON.stringify(v)}; it must be [min, max], two finite numbers of degrees with min ≤ 0 ≤ max and min < max`);
379
+ return undefined;
380
+ }
381
+ if (bone !== null) out.yaw = { bone, range: [v[0] as number, v[1] as number], ...(sets === undefined ? {} : { sets }) };
382
+ } else if (sets !== undefined) {
383
+ out.sets = sets;
384
+ }
385
+ }
386
+ return Object.keys(out).length > 0 ? out : undefined;
387
+ }
388
+
285
389
  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" }';
286
390
 
287
391
  /** The joints a wrist is, for `place: "by-hip"`: a chain whose last joint is one of them carries a wrist. */
@@ -535,8 +639,15 @@ export function rigPoint(rest: Rest, spec: string): [number, number] | null {
535
639
  return pointAt(rest, new Map([...rest].map(([n, b]) => [n, b.length])), spec);
536
640
  }
537
641
 
642
+ export function knownSetsOf(map: JointMap, rigSets: RigSets = new Map()): Map<string, RigSet> {
643
+ const out = new Map<string, RigSet>();
644
+ for (const [k, v] of rigSets) out.set(k, v);
645
+ for (const a of map.alternates) if (!out.has(a.name)) out.set(a.name, { slots: a.slots, hides: a.hides });
646
+ return out;
647
+ }
648
+
538
649
  /** Check every name the map gives against the rig; every miss is named. */
539
- export function resolveMap(map: JointMap, rest: Rest, where: string, slots: RigSlots = new Map()): void {
650
+ export function resolveMap(map: JointMap, rest: Rest, where: string, slots: RigSlots = new Map(), rigSets: RigSets = new Map()): void {
540
651
  const p = new Problems();
541
652
  const all = [...rest.keys()];
542
653
  const listed = all.length <= 40 ? all.join(', ') : `${all.slice(0, 40).join(', ')} … (${all.length} in all)`;
@@ -619,6 +730,46 @@ export function resolveMap(map: JointMap, rest: Rest, where: string, slots: RigS
619
730
  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
731
  }
621
732
  }
733
+ if (map.body?.yaw !== undefined) {
734
+ const ax = map.body.yaw;
735
+ const label = '"body.yaw"';
736
+ if (!rest.has(ax.bone)) {
737
+ p.add('MAP_BONE_EXISTS', `${label} — bone "${ax.bone}" is not in the rig; its bones are ${listed}`);
738
+ } else {
739
+ const prev = owner.get(ax.bone);
740
+ 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`);
741
+ owner.set(ax.bone, label);
742
+ const o = rest.get(ax.bone)!;
743
+ 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)`);
744
+ }
745
+ }
746
+ const knownSets = knownSetsOf(map, rigSets);
747
+ const allSets = [...knownSets.keys()];
748
+ const setsListed = allSets.length === 0 ? 'none' : allSets.join(', ');
749
+ const allSlots = [...slots.keys()];
750
+ const slotsListed = allSlots.length === 0 ? 'none' : allSlots.length <= 40 ? allSlots.join(', ') : `${allSlots.slice(0, 40).join(', ')} … (${allSlots.length} in all)`;
751
+ for (const [label, declared] of [['"face.yaw.sets"', faceYawSets(map.face)], ['"body.sets"', bodyYawSets(map.body)]] as const) {
752
+ if (declared === undefined) continue;
753
+ for (const s of declared) {
754
+ const def = knownSets.get(s.set);
755
+ if (def === undefined) {
756
+ p.add('MAP_SET_EXISTS', `${label} — set "${s.set}" is not in the rig; its sets are ${setsListed}`);
757
+ continue;
758
+ }
759
+ if (slots.size === 0) continue;
760
+ for (const [slot, att] of Object.entries(def.slots)) {
761
+ const rs = slots.get(slot);
762
+ if (rs === undefined) p.add('MAP_ALT_SLOT', `set "${s.set}" — slot "${slot}" is not in the rig; its slots are ${slotsListed}`);
763
+ else if (!rs.attachments.has(att)) {
764
+ const has = [...rs.attachments];
765
+ p.add('MAP_ALT_ATTACHMENT', `set "${s.set}" — slot "${slot}" has no attachment "${att}" under any skin; it has ${has.length === 0 ? 'none' : has.join(', ')}`);
766
+ }
767
+ }
768
+ for (const h of def.hides) {
769
+ if (!slots.has(h)) p.add('MAP_ALT_HIDES', `set "${s.set}" hides slot "${h}", which is not in the rig; its slots are ${slotsListed}`);
770
+ }
771
+ }
772
+ }
622
773
  resolveAlternates(p, map.alternates, slots);
623
774
  if (p.any) p.list.unshift({ code: 'MAP_RESOLVES', text: `${where} does not resolve against the rig` });
624
775
  p.throwIfAny();
package/src/retarget.ts CHANGED
@@ -74,17 +74,17 @@
74
74
  import { screenToSpineDegrees } from 'rig-c/transform';
75
75
  import type { Body18 } from './joints.ts';
76
76
  import { isBody18, VIRTUAL } from './joints.ts';
77
- import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
78
- import { aimAngle, aimEnds, descends, partBone, pointParts, rigPoint } from './map.ts';
77
+ import type { Chain, JointMap, Rest, RigSets, RigSlots } from './map.ts';
78
+ import { aimAngle, aimEnds, bodyYawSets, descends, faceYawSets, knownSetsOf, partBone, pointParts, rigPoint } from './map.ts';
79
79
  import type { Joint, Travel } from './pose.ts';
80
80
  import { TRAVEL_IN_PLACE } from './pose.ts';
81
81
  import { Problems, Refusal } from './problems.ts';
82
- import type { ArtRange, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
82
+ import type { ArtRange, AttachmentKey, 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
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
+ import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
87
+ import { readBody, readFace, readFaceAngles, selectYawSets } from './face.ts';
88
88
 
89
89
  const DEG = 180 / Math.PI;
90
90
 
@@ -219,7 +219,7 @@ export interface MotionKey {
219
219
  }
220
220
  export interface MotionTrack {
221
221
  bone: string;
222
- property: 'rotate' | 'translate' | 'scalex';
222
+ property: 'rotate' | 'translate' | 'scalex' | 'scaley';
223
223
  keys: MotionKey[];
224
224
  }
225
225
  export interface Animation {
@@ -326,6 +326,8 @@ export interface Retarget {
326
326
  byHip: ByHip[];
327
327
  /** With "face" in the map (#138): per-frame yaw/pitch reading and range-compressed mapping. */
328
328
  face: FaceReading | null;
329
+ /** Per-frame body yaw read from camera-normalised shoulder and hip width (#173), and optional "body.yaw" dial mapping. */
330
+ body: BodyReading | null;
329
331
  /** Continuous per-frame figure scale relative to frame 0 (`#163`, `#161`). */
330
332
  figureScale: FigureScaleReading;
331
333
  }
@@ -430,7 +432,7 @@ function pivotOf(rest: Rest, bone: string | null, legs: ReturnType<typeof legsOf
430
432
  * good, when a hinge's side cannot be read off a straight rest, and — with
431
433
  * `bends: 'refuse'` — when a hinge bends the wrong way.
432
434
  */
433
- export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions, slots: RigSlots = new Map()): Retarget {
435
+ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions, slots: RigSlots = new Map(), rigSets: RigSets = new Map()): Retarget {
434
436
  const p = new Problems();
435
437
  const n = frames.length;
436
438
  const t0 = times[0]!;
@@ -462,6 +464,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
462
464
  needed.add('r_eye');
463
465
  needed.add('l_eye');
464
466
  }
467
+ if (map.body !== undefined) {
468
+ needed.add('r_shoulder');
469
+ needed.add('l_shoulder');
470
+ }
465
471
  const series = new Map<string, Pt[]>();
466
472
  const good = new Map<string, boolean[]>();
467
473
  const holds: Hold[] = [];
@@ -713,19 +719,76 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
713
719
  const h = heldOf(name);
714
720
  return 'never' in h ? null : h.at;
715
721
  };
722
+ const winPercentile = (xs: readonly number[], w: number, q: number): number[] => {
723
+ const effW = Math.min(w, Math.floor((n - 1) / 2));
724
+ if (effW <= 0) return [...xs];
725
+ return xs.map((_, i) => {
726
+ const c = Math.max(effW, Math.min(n - 1 - effW, i));
727
+ return percentile(xs.slice(c - effW, c + effW + 1), q);
728
+ });
729
+ };
730
+ const dist2 = (a: Pt, b: Pt): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
716
731
  const rawFigureHeight: number[] = (() => {
717
732
  const ar = heldPt('r_ankle');
718
733
  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
734
  const hr = heldPt('r_hip');
725
735
  const hl = heldPt('l_hip');
726
736
  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;
737
+ const kr = heldPt('r_knee');
738
+ const kl = heldPt('l_knee');
739
+ const legSum = ar.map((r, i) =>
740
+ kr !== null && kl !== null
741
+ ? Math.max(dist2(hr[i]!, kr[i]!) + dist2(kr[i]!, r), dist2(hl[i]!, kl[i]!) + dist2(kl[i]!, al[i]!))
742
+ : Math.max(dist2(r, hr[i]!), dist2(al[i]!, hl[i]!)),
743
+ );
744
+ if (legSum.every((v) => v > 1e-6)) {
745
+ if (n > 160) {
746
+ const seriesList: number[][] = [winPercentile(legSum, 75, STRAIGHT_PERCENTILE)];
747
+ const rs = heldPt('r_shoulder');
748
+ const ls = heldPt('l_shoulder');
749
+ if (rs !== null && ls !== null) {
750
+ const shW = rs.map((r, i) => dist2(r, ls[i]!));
751
+ if (shW.every((v) => v > 1e-6)) seriesList.push(winPercentile(shW, 75, STRAIGHT_PERCENTILE));
752
+ }
753
+ const re = heldPt('r_eye');
754
+ const le = heldPt('l_eye');
755
+ if (re !== null && le !== null) {
756
+ const eyeW = re.map((r, i) => dist2(r, le[i]!));
757
+ if (eyeW.every((v) => v > 1e-6)) seriesList.push(winPercentile(eyeW, 75, STRAIGHT_PERCENTILE));
758
+ }
759
+ if (kr !== null && kl !== null) {
760
+ const thighMin = hr.map((r, i) => Math.min(dist2(r, kr[i]!), dist2(hl[i]!, kl[i]!)));
761
+ if (thighMin.every((v) => v > 1e-6)) seriesList.push(winPercentile(thighMin, 75, 0.5));
762
+ }
763
+ const rel = heldPt('r_elbow');
764
+ const lel = heldPt('l_elbow');
765
+ const rw = heldPt('r_wrist');
766
+ const lw = heldPt('l_wrist');
767
+ if (rs !== null && ls !== null && rel !== null && lel !== null && rw !== null && lw !== null) {
768
+ const armSum = rs.map((r, i) => Math.max(dist2(r, rel[i]!) + dist2(rel[i]!, rw[i]!), dist2(ls[i]!, lel[i]!) + dist2(lel[i]!, lw[i]!)));
769
+ if (armSum.every((v) => v > 1e-6)) seriesList.push(winPercentile(armSum, 75, 0.5));
770
+ }
771
+ if (seriesList.length >= 3) {
772
+ const norms = seriesList.map((s) => s.map((v) => v / s[0]!));
773
+ const cons = legSum.map((_, i) => {
774
+ let minR = Infinity;
775
+ let maxR = -Infinity;
776
+ for (const nr of norms) {
777
+ if (nr[i]! < minR) minR = nr[i]!;
778
+ if (nr[i]! > maxR) maxR = nr[i]!;
779
+ }
780
+ return minR > 1 ? minR : maxR < 1 ? maxR : 1;
781
+ });
782
+ if (cons.every((c) => Math.abs(c - 1) <= 0.02)) {
783
+ return cons.map((c) => c * legSum[0]!);
784
+ }
785
+ }
786
+ const out = winPercentile(legSum, 1, STRAIGHT_PERCENTILE);
787
+ out[0] = legSum[0]!;
788
+ return out;
789
+ }
790
+ return legSum;
791
+ }
729
792
  }
730
793
  if (t !== null) {
731
794
  const [pa, pb] = t.scale.pose;
@@ -761,6 +824,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
761
824
  };
762
825
  })();
763
826
  const poseScale: number[] | null = t !== null && scale !== null && n > 25 && !inPlace ? continuousScale : null;
827
+ let trunkScaleBone: string | null = null;
828
+ let trunkScale: number[] | null = null;
764
829
  if (legScaleOn) {
765
830
  const limits = opts.legScale as [number, number];
766
831
  const rawSegments: Array<{ chain: string; bone: string; joints: [string, string]; kind: string; lengths: number[]; ownStraight: number }> = [];
@@ -817,6 +882,43 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
817
882
  scales.set(seg.bone, ls.scale);
818
883
  }
819
884
  if (legScale.length === 0 && !p.any) p.add('LEGS_MAP', '--leg-scale scales the leg segments (two of one side\'s hip, knee and ankle), and the map aims none');
885
+ const isTrunkPair = (a: string, b: string): boolean => (a === 'mid_hip' && (b === 'neck' || b === 'mid_shoulder')) || (b === 'mid_hip' && (a === 'neck' || a === 'mid_shoulder'));
886
+ const trunkChain = map.chains.find((c) => c.bones.length === 1 && isTrunkPair(c.joints[0]!, c.joints[1]!));
887
+ if (!inPlace && trunkChain !== undefined && !p.any) {
888
+ const tb = trunkChain.bones[0]!;
889
+ const from = series.get(trunkChain.joints[0]!)!;
890
+ const to = series.get(trunkChain.joints[1]!)!;
891
+ const rawT = from.map((f, i) => dist2(f, to[i]!));
892
+ const frameScale = (i: number): number => (!inPlace && n > 25 && rawFigureHeight[0]! > 1e-6 && rawFigureHeight[i]! > 1e-6 ? rawFigureHeight[i]! / rawFigureHeight[0]! : 1);
893
+ const normT = rawT.map((l, i) => l / frameScale(i));
894
+ const standingT = median(normT);
895
+ if (standingT > 1e-6) {
896
+ const rawRatio = normT.map((l) => l / standingT);
897
+ const re = heldPt('r_eye');
898
+ const le = heldPt('l_eye');
899
+ let effRatio = rawRatio;
900
+ if (re !== null && le !== null) {
901
+ const mh = series.get('mid_hip') ?? from;
902
+ const rawEH = mh.map((h, i) => dist2(h, [(re[i]![0] + le[i]![0]) / 2, (re[i]![1] + le[i]![1]) / 2]));
903
+ const normEH = rawEH.map((l, i) => l / frameScale(i));
904
+ const standingEH = median(normEH);
905
+ if (standingEH > 1e-6) {
906
+ effRatio = rawRatio.map((r, i) => (r > 1 ? Math.min(r, Math.max(1, normEH[i]! / standingEH)) : r));
907
+ }
908
+ }
909
+ const minR = Math.max(0.5, Math.min(...effRatio));
910
+ const supFloor = Math.min(limits[0], minR);
911
+ const sy = effRatio.map((r, i) => {
912
+ const supported = plantOn && feet !== null && (feet.support[i] !== null || feet.tracked?.some((tr) => i >= tr.frames[0] && i <= tr.frames[1]) === true);
913
+ const lo = supported ? supFloor : limits[0];
914
+ return Math.min(Math.max(r, lo), limits[1]);
915
+ });
916
+ if (effRatio.some((v) => Math.abs(v - 1) > 0.04) && sy.some((v) => Math.abs(r6(v) - 1) > 0)) {
917
+ trunkScaleBone = tb;
918
+ trunkScale = sy;
919
+ }
920
+ }
921
+ }
820
922
  }
821
923
  if (opts.stance !== undefined && opts.stance !== 'off' && !plantOn) p.add('STANCE', `--stance ${opts.stance} plants the free foot beside the supporting one that --plant-feet holds, and --plant-feet is not given`);
822
924
  if (opts.floor !== undefined && !plantOn) p.add('FLOOR', `--floor ${opts.floor} is the floor --plant-feet plants every support span on, and --plant-feet is not given`);
@@ -1424,6 +1526,109 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1424
1526
  targets.set(ax.bone, ax.keys.map((v, i) => wrap(rest0 + (above === null ? 0 : turn(above, i)) + v)));
1425
1527
  tracks.push({ bone: ax.bone, property: 'rotate', keys: ax.keys.map((v, i) => ({ t: keyTimes[i]!, v: [v] })) });
1426
1528
  }
1529
+ const fSets = faceYawSets(map.face);
1530
+ if (fSets !== undefined) face.sets = selectYawSets(face.read.rawYaw, fSets, keyTimes, opts.switchMin);
1531
+ }
1532
+ let body: BodyReading | null = null;
1533
+ {
1534
+ const rSh = heldPt('r_shoulder');
1535
+ const lSh = heldPt('l_shoulder');
1536
+ if (rSh !== null && lSh !== null) {
1537
+ const rHip = heldPt('r_hip');
1538
+ const lHip = heldPt('l_hip');
1539
+ const neck = heldPt('neck');
1540
+ const faceYaw = face !== null ? face.read.rawYaw : (() => {
1541
+ const nose = heldPt('nose');
1542
+ const rEye = heldPt('r_eye');
1543
+ const lEye = heldPt('l_eye');
1544
+ if (nose === null || rEye === null || lEye === null) return null;
1545
+ return readFaceAngles(nose, rEye, lEye, heldPt('r_ear'), heldPt('l_ear')).yaw;
1546
+ })();
1547
+ body = readBody(map.body, rSh, lSh, rHip, lHip, neck, !inPlace && n > 25 ? continuousScale : null, faceYaw);
1548
+ const bodyAx = body.yaw;
1549
+ if (bodyAx !== undefined) {
1550
+ const rest0 = restAngle(rest, bodyAx.bone, 'x');
1551
+ const above = mappedAbove(bodyAx.bone);
1552
+ targets.set(bodyAx.bone, bodyAx.keys.map((v, i) => wrap(rest0 + (above === null ? 0 : turn(above, i)) + v)));
1553
+ tracks.push({ bone: bodyAx.bone, property: 'rotate', keys: bodyAx.keys.map((v, i) => ({ t: keyTimes[i]!, v: [v] })) });
1554
+ }
1555
+ const bSets = bodyYawSets(map.body);
1556
+ if (bSets !== undefined) body.sets = selectYawSets(body.read.rawYaw, bSets, keyTimes, opts.switchMin);
1557
+ }
1558
+ }
1559
+ const activeYawSets: YawSetSwitch[] = [...(face?.sets ?? []), ...(body?.sets ?? [])];
1560
+ if (activeYawSets.some((s) => s.spans.length > 0)) {
1561
+ const known = knownSetsOf(map, rigSets);
1562
+ const slotOrder: string[] = [];
1563
+ for (const tr of sw.slotTracks) if (!slotOrder.includes(tr.slot)) slotOrder.push(tr.slot);
1564
+ for (const s of activeYawSets) {
1565
+ const def = known.get(s.set);
1566
+ if (def === undefined) continue;
1567
+ for (const sl of [...Object.keys(def.slots), ...def.hides]) {
1568
+ if (!slotOrder.includes(sl)) slotOrder.push(sl);
1569
+ }
1570
+ }
1571
+ const yawExactAt = new Map<string, Set<number>>();
1572
+ for (const s of activeYawSets) {
1573
+ const def = known.get(s.set);
1574
+ if (def === undefined) continue;
1575
+ const values: Array<[string, string | null]> = [...Object.entries(def.slots), ...def.hides.map((h): [string, null] => [h, null])];
1576
+ for (const [sl, v] of values) {
1577
+ if (!sw.shown.has(sl)) sw.shown.set(sl, Array.from({ length: n }, () => slots.get(sl)?.setup ?? null));
1578
+ const row = sw.shown.get(sl)!;
1579
+ for (const [lo, hi] of s.spans) {
1580
+ for (let i = lo; i <= hi; i++) row[i] = v;
1581
+ const exact = yawExactAt.get(sl) ?? new Set<number>();
1582
+ exact.add(lo);
1583
+ if (hi + 1 < n) exact.add(hi + 1);
1584
+ yawExactAt.set(sl, exact);
1585
+ }
1586
+ }
1587
+ }
1588
+ const rebuilt: SlotTrack[] = [];
1589
+ for (const sl of slotOrder) {
1590
+ const row = sw.shown.get(sl);
1591
+ if (row === undefined) continue;
1592
+ let prev = slots.get(sl)?.setup ?? null;
1593
+ const keys: AttachmentKey[] = [];
1594
+ const exact = yawExactAt.get(sl);
1595
+ row.forEach((v, i) => {
1596
+ if (v === prev) return;
1597
+ const t = i === 0 ? 0 : exact?.has(i) === true ? keyTimes[i]! : r6((keyTimes[i - 1]! + keyTimes[i]!) / 2);
1598
+ keys.push({ t, v });
1599
+ prev = v;
1600
+ });
1601
+ if (keys.length > 0) rebuilt.push({ slot: sl, property: 'attachment', keys });
1602
+ }
1603
+ sw.slotTracks.splice(0, sw.slotTracks.length, ...rebuilt);
1604
+ }
1605
+ if (activeYawSets.length > 0) {
1606
+ const known = knownSetsOf(map, rigSets);
1607
+ const otherSlotKeyers = new Map<string, string[]>();
1608
+ const addKeyer = (sl: string, who: string) => {
1609
+ const list = otherSlotKeyers.get(sl) ?? [];
1610
+ if (!list.includes(who)) list.push(who);
1611
+ otherSlotKeyers.set(sl, list);
1612
+ };
1613
+ for (const aSw of sw.switches) {
1614
+ for (const sl of [...Object.keys(aSw.slots), ...aSw.hides]) addKeyer(sl, `set "${aSw.set}"`);
1615
+ }
1616
+ for (const s of activeYawSets) {
1617
+ if (s.spans.length === 0) continue;
1618
+ const def = known.get(s.set);
1619
+ if (def === undefined) continue;
1620
+ for (const sl of [...Object.keys(def.slots), ...def.hides]) addKeyer(sl, `set "${s.set}"`);
1621
+ }
1622
+ for (const s of activeYawSets) {
1623
+ const def = known.get(s.set);
1624
+ if (def === undefined) continue;
1625
+ for (const h of def.hides) {
1626
+ const keyers = (otherSlotKeyers.get(h) ?? []).filter((who) => who !== `set "${s.set}"`);
1627
+ if (keyers.length > 0) {
1628
+ s.hidesKeyed.push({ slot: h, by: `slot "${h}" attachment (${keyers.join(', ')})` });
1629
+ }
1630
+ }
1631
+ }
1427
1632
  }
1428
1633
  // Scale keys: a scaled leg segment's own scale, or the scale that lands a bone under one at its asked length.
1429
1634
  for (const c of map.chains) {
@@ -1432,6 +1637,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1432
1637
  tracks.push({ bone: b, property: 'scalex', keys: Array.from({ length: n }, (_, i) => ({ t: keyTimes[i]!, v: [r6(solvedKey(b, i)?.scale ?? scales.get(b)?.[i] ?? 1)] })) });
1433
1638
  }
1434
1639
  }
1640
+ if (trunkScaleBone !== null && trunkScale !== null) {
1641
+ tracks.push({ bone: trunkScaleBone, property: 'scaley', keys: trunkScale.map((v, i) => ({ t: keyTimes[i]!, v: [r6(v)] })) });
1642
+ }
1435
1643
  if (translateTrack !== null) tracks.push(translateTrack);
1436
1644
  if (headNodTrack !== null) tracks.push(headNodTrack);
1437
1645
  tracks.push(...sw.offsetTracks, ...sw.slotTracks);
@@ -1440,7 +1648,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1440
1648
  animation: { duration: keyTimes[n - 1]!, tracks },
1441
1649
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
1442
1650
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
1443
- legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, figureScale,
1651
+ legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, body, figureScale,
1444
1652
  };
1445
1653
  }
1446
1654
 
package/src/rigc.ts CHANGED
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'no
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { poseRawSetup, readModel, type CompiledDocument } from 'rig-c/core';
16
- import type { InheritMode, Rest, RestBone, RigSlot, RigSlots } from './map.ts';
16
+ import type { InheritMode, Rest, RestBone, RigSet, RigSets, RigSlot, RigSlots } from './map.ts';
17
17
  import { isObject, parseJson, Problems, Refusal, typeName } from './problems.ts';
18
18
 
19
19
  export interface RigInput {
@@ -23,6 +23,32 @@ export interface RigInput {
23
23
  name: string;
24
24
  /** The motion spec the rig is built with, or null when it has none. */
25
25
  motion: string | null;
26
+ /** Any alternate sets declared in `sets.json` (`rig-parts-sets/1`) beside `rig.json`. */
27
+ sets: RigSets;
28
+ }
29
+
30
+ function readRigSets(path: string): RigSets {
31
+ const out = new Map<string, RigSet>();
32
+ if (!existsSync(path)) return out;
33
+ const doc = parseJson(readFileSync(path, 'utf8'), path, 'SETS_JSON');
34
+ if (!isObject(doc) || (doc.spec !== 'rig-parts-sets/1' && doc.spec !== 'spine-parts-sets/1') || (!isObject(doc.sets) && !Array.isArray(doc.sets))) {
35
+ throw new Refusal([{ code: 'SETS_SPEC', text: `${path} is not a rig-parts-sets/1 spec with a "sets" object` }]);
36
+ }
37
+ const addSet = (name: string, s: unknown) => {
38
+ if (!isObject(s) || !isObject(s.slots)) return;
39
+ const slots: Record<string, string> = {};
40
+ for (const [k, v] of Object.entries(s.slots)) if (typeof v === 'string') slots[k] = v;
41
+ const hides = Array.isArray(s.hides) ? s.hides.filter((h): h is string => typeof h === 'string') : [];
42
+ out.set(name, { slots, hides });
43
+ };
44
+ if (Array.isArray(doc.sets)) {
45
+ for (const s of doc.sets) {
46
+ if (isObject(s) && typeof s.name === 'string') addSet(s.name, s);
47
+ }
48
+ } else {
49
+ for (const [name, s] of Object.entries(doc.sets)) addSet(name, s);
50
+ }
51
+ return out;
26
52
  }
27
53
 
28
54
  /**
@@ -49,7 +75,8 @@ export function resolveRig(path: string, baseMotion: string | undefined): RigInp
49
75
  if (!isObject(doc) || doc.spec !== 'rigc-rig/1' || typeof doc.name !== 'string') {
50
76
  throw new Refusal([{ code: 'RIG_SPEC', text: `${rig} is not a rig spec: "spec" is ${isObject(doc) ? typeName(doc.spec) : typeName(doc)} and "name" ${isObject(doc) ? typeName(doc.name) : '-'}; it must be "rigc-rig/1" with a name` }]);
51
77
  }
52
- return { rig, name: doc.name, motion };
78
+ const sets = readRigSets(join(dirname(rig), 'sets.json'));
79
+ return { rig, name: doc.name, motion, sets };
53
80
  }
54
81
 
55
82
  /** The motion spec the rig is built with, or an empty one under its name. */
package/src/run.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { dirname, join, resolve } from 'node:path';
10
- import { readMap, resolveMap, type JointMap, type Rest } from './map.ts';
10
+ import { knownSetsOf, readMap, resolveMap, type JointMap, type Rest } from './map.ts';
11
11
  import { cleanLine, inferredSource } from './clean.ts';
12
12
  import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
13
13
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
@@ -19,7 +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, smoothTrunk, 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
+ import { bodyLine, faceLine } from './face.ts';
23
23
 
24
24
  export interface RetargetArgs extends RetargetOptions {
25
25
  rig: string;
@@ -108,9 +108,25 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
108
108
  const rigc = rigcVersion();
109
109
  const rest = restOf(doc0);
110
110
  const slots = slotsOf(doc0);
111
- resolveMap(map, rest, a.map, slots);
111
+ resolveMap(map, rest, a.map, slots, input.sets);
112
112
 
113
- const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}) }, slots);
113
+ const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}) }, slots, input.sets);
114
+ if (fragment.length > 0 && ((r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
115
+ const known = knownSetsOf(map, input.sets);
116
+ for (const s of [...(r.face?.sets ?? []), ...(r.body?.sets ?? [])]) {
117
+ const def = known.get(s.set);
118
+ if (def === undefined) continue;
119
+ for (const h of def.hides) {
120
+ for (const tr of fragment) {
121
+ const tgt = targetOf(tr);
122
+ if (tgt?.kind === 'slot' && tgt.name === h) {
123
+ const by = `slot "${h}" ${tr.property} (--merge ${a.merge})`;
124
+ if (!s.hidesKeyed.some((x) => x.slot === h && x.by === by)) s.hidesKeyed.push({ slot: h, by });
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
114
130
  say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.raisedByHead === true ? ', raised-by-head' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
115
131
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
116
132
  say(` CLEAN ${cleanLine(seq.cleaned!)}`);
@@ -161,6 +177,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
161
177
  }
162
178
  if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
163
179
  if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
180
+ if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
164
181
 
165
182
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
166
183
  const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
@@ -192,6 +209,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
192
209
  ...(map.translate === null ? [] : [map.translate.bone]),
193
210
  ...(map.face?.yaw === undefined ? [] : [map.face.yaw.bone]),
194
211
  ...(map.face?.pitch === undefined ? [] : [map.face.pitch.bone]),
212
+ ...(map.body?.yaw === undefined ? [] : [map.body.yaw.bone]),
195
213
  ];
196
214
  const below = new Map<string, string[]>();
197
215
  for (const m of new Set(mapped)) {
@@ -283,7 +301,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
283
301
  else if (f.meshes.length === 0) say(` FOLD rigc render --folds at ${fps} fps: no mesh folds`);
284
302
  else for (const m of f.meshes) say(` FOLD mesh "${m.attachment}" (slot "${m.slot}") folds on ${m.times.length} frame(s) at ${fps} fps: t=${spans(m.times, 1 / fps)}`);
285
303
  const uncovered = f.meshes === null ? null : uncoveredFolds(f.meshes, r.shown, r.times, slots, (slot, att) => chainsMoving(attachmentBones(doc, slot, att), rest, map));
286
- if (uncovered !== null && map.alternates.length > 0) {
304
+ if (uncovered !== null && (map.alternates.length > 0 || (r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
287
305
  for (const u of uncovered) say(` UNCOVERED mesh "${u.attachment}" (slot "${u.slot}"; chains ${u.chains.length === 0 ? 'none' : u.chains.join(', ')}) folds with no switch covering it on ${u.times.length} frame(s): t=${spans(u.times, 1 / fps)}`);
288
306
  }
289
307
 
@@ -363,8 +381,19 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
363
381
  read: { yaw: r.face.read.yaw, pitch: r.face.read.pitch },
364
382
  ...(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
383
  ...(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 } }),
384
+ ...(r.face.sets === undefined ? {} : { sets: r.face.sets }),
366
385
  },
367
386
  }),
387
+ body: r.body === null
388
+ ? null
389
+ : {
390
+ standing: r.body.standing,
391
+ read: { yaw: r.body.read.yaw, opening: r.body.read.opening },
392
+ over45: r.body.over45,
393
+ framesOver45: r.body.framesOver45,
394
+ ...(r.body.yaw === undefined ? {} : { yaw: { bone: r.body.yaw.bone, limits: r.body.yaw.limits, knee: r.body.yaw.knee, read: r.body.yaw.read, mapped: r.body.yaw.mapped, scale: r.body.yaw.scale, corr: r.body.yaw.corr } }),
395
+ ...(r.body.sets === undefined ? {} : { sets: r.body.sets }),
396
+ },
368
397
  legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
369
398
  pivot: r.pivot,
370
399
  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
@@ -64,8 +64,29 @@ export function measureLanding(doc: CompiledDocument, animation: string, map: Jo
64
64
  const faceBones = new Set([r.face?.yaw?.bone, r.face?.pitch?.bone].filter((b): b is string => b !== undefined));
65
65
  const isFaceSlider = (c: CompiledDocument['constraints'][number]): boolean => c.record?.kind === 'slider' && c.record.bone !== null && faceBones.has(c.record.bone);
66
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)
67
+ const hasScaleY = doc.animations.some((a) => a.name === animation && a.timelines.bones.some((b) => b.timelines.some((t) => t.kind === 'scaley')));
68
+ const chainPosed = hasFaceSliders || hasScaleY
69
+ ? poseFrames(
70
+ {
71
+ ...doc,
72
+ constraints: doc.constraints.filter((c) => !isFaceSlider(c)),
73
+ animations: hasScaleY
74
+ ? doc.animations.map((a) =>
75
+ a.name === animation
76
+ ? {
77
+ ...a,
78
+ timelines: {
79
+ ...a.timelines,
80
+ bones: a.timelines.bones.map((b) => ({ ...b, timelines: b.timelines.filter((t) => t.kind !== 'scaley') })),
81
+ },
82
+ }
83
+ : a,
84
+ )
85
+ : doc.animations,
86
+ },
87
+ animation,
88
+ r.times,
89
+ )
69
90
  : posed;
70
91
  for (const c of map.chains) {
71
92
  c.bones.forEach((b, k) => {