@rig-c/pose 0.22.2 → 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
@@ -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
  | --- | --- |
@@ -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.2",
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
 
@@ -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[] = [];
@@ -1520,6 +1526,109 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1520
1526
  targets.set(ax.bone, ax.keys.map((v, i) => wrap(rest0 + (above === null ? 0 : turn(above, i)) + v)));
1521
1527
  tracks.push({ bone: ax.bone, property: 'rotate', keys: ax.keys.map((v, i) => ({ t: keyTimes[i]!, v: [v] })) });
1522
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
+ }
1523
1632
  }
1524
1633
  // Scale keys: a scaled leg segment's own scale, or the scale that lands a bone under one at its asked length.
1525
1634
  for (const c of map.chains) {
@@ -1539,7 +1648,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1539
1648
  animation: { duration: keyTimes[n - 1]!, tracks },
1540
1649
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
1541
1650
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
1542
- 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,
1543
1652
  };
1544
1653
  }
1545
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 },