@rig-c/pose 0.28.0 → 0.29.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
@@ -349,12 +349,13 @@ Bone rotation cannot carry every pose: an arm behind the head, a forearm toward
349
349
 
350
350
  - `slots`: what each slot of the set shows while it shows; each attachment must be in that slot under some skin of the rig, as rig-c built it. `hides`: the slots that are empty while it shows. A slot's place in the draw order is the rig's own, so a drawing that must sit in front of the torso is an attachment in a slot that is: a switch is attachment keys only, and no draw-order key is written.
351
351
  - `chains`: the map's chains whose pose the set depicts; it is selected by them. `pose`: the key skeleton the art was painted from, body-18 (or `mid_hip`/`mid_shoulder`) in picture px, y down, any scale and place — only its directions are read; every joint the listed chains use must be in it.
352
- - `drawn` (optional): each bone's world angle (Spine degrees) as painted. It changes no key unless the set declares `symmetric` or `support` ([below](#alternate-drawings-alternates)): otherwise, while a set shows, a bone is keyed to the pose as always, and the art turns with it. What it gives is a figure: the art turn, how far each bone is from the angle its art was painted at (`artTurn`, and `ART-RANGE` past the bone's range).
352
+ - `drawn` (optional): each bone's world angle (Spine degrees) as painted. It changes no key unless the set declares `symmetric`, `support` or `anchor` ([below](#alternate-drawings-alternates)): otherwise, while a set shows, a bone is keyed to the pose as always, and the art turns with it. What it gives is a figure: the art turn, how far each bone is from the angle its art was painted at (`artTurn`, and `ART-RANGE` past the bone's range).
353
353
  - `offsets` (optional): a translate per bone, in its parent's axes, held while the set shows and 0 elsewhere (stepped keys), so the joints as painted are the pivots. A bone `translate` already keys (the hip) cannot take one.
354
354
  - `place` (optional): `"by-hip"` places a hand that meets the body by its offset from the hip ([below](#alternate-drawings-alternates)).
355
355
  - `symmetric` (optional, [#188](https://github.com/rig-c/pose/issues/188)): `true` normalises a two-arm symmetric gesture set (such as hands behind the head) to its `drawn` symmetry centre so both elbows sit at the same world height and neither wrist extends past the head silhouette ([below](#alternate-drawings-alternates)). Requires two 2-bone chains with `drawn` angles on all four bones (`MAP_ALT_SYMMETRIC`).
356
356
  - `support` (optional, [#188](https://github.com/rig-c/pose/issues/188)): a chain name or non-empty list of chain names of the set (`"support": "arm_l"` or `["arm_l"]`) whose bones carry a stationary support arm (such as a waist-resting hand while the other arm gestures); while the set shows, each support chain's bones anchor to `drawn` within `±2.5°` (`≤ ±3°`) of the pose's delta relative to the set's reference pose, and an unplaced active raised arm in the same set clears the head/bun outward along the 2D shoulder→wrist direction (`MAP_ALT_SUPPORT`).
357
- - Each name resolves against the built rig and the map, and a miss is refused by name, listing what exists: `MAP_ALT_SLOT`, `MAP_ALT_ATTACHMENT`, `MAP_ALT_HIDES`, `MAP_ALT_CHAIN`, `MAP_ALT_JOINT`, `MAP_ALT_POSE`, `MAP_ALT_DRAWN`, `MAP_ALT_OFFSET` (a bone that is not a bone of the set's chains), `MAP_ALT_PLACE`, `MAP_ALT_SYMMETRIC` and `MAP_ALT_SUPPORT`.
357
+ - `anchor` (optional, [#191](https://github.com/rig-c/pose/issues/191)): a chain name or non-empty list of chain names of the set (`"anchor": "arm_l"` or `["arm_l"]`) whose hand is anchored to the set's drawn hand-tip-to-head offset in the `head` bone's local frame (`MAP_ALT_ANCHOR`, `ANCHOR_HEAD`); while the set's slots for that chain show, the target hand tip is `head_posed_origin + rotate(W_drawn − H_setup, head_posed_rot − head_drawn_rot)` and the 2-bone arm is solved by two-circle intersection (`twoBone` / `reachLimit`) inside the set's `turn`, listing any frame where `turn` saturates and leaves a residual hand-tip shortfall (`saturated: [{ frame, residual }]` on `raised`, printed as `; residual f46 9.68u, …` on `RAISED`).
358
+ - Each name resolves against the built rig and the map, and a miss is refused by name, listing what exists: `MAP_ALT_SLOT`, `MAP_ALT_ATTACHMENT`, `MAP_ALT_HIDES`, `MAP_ALT_CHAIN`, `MAP_ALT_JOINT`, `MAP_ALT_POSE`, `MAP_ALT_DRAWN`, `MAP_ALT_OFFSET` (a bone that is not a bone of the set's chains), `MAP_ALT_PLACE`, `MAP_ALT_SYMMETRIC`, `MAP_ALT_SUPPORT` and `MAP_ALT_ANCHOR`.
358
359
 
359
360
  **When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton, with a foreshortened segment (`SHORT`) weighted at `0.5 ×` (`SHORT_SWITCH_WEIGHT`, [#180](https://github.com/rig-c/pose/issues/180)). On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; once entered, a set stays active until its distance exceeds `--switch-tolerance + 15°` (`SWITCH_LEAVE_HYSTERESIS`, [#180](https://github.com/rig-c/pose/issues/180)) for at least `--switch-min` (default 0.25 s) or a competing set with a lower distance enters within `--switch-tolerance`; a run of one set shorter than `--switch-min` is dropped and the base art shows there. Sets compete only with those they share a slot (shown or hidden) or an offset bone with, so a drawing of each arm can show at once; two that compete never overlap.
360
361
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.28.0",
3
+ "version": "0.29.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/map.ts CHANGED
@@ -104,6 +104,8 @@ export interface AlternateSet {
104
104
  symmetric?: boolean;
105
105
  /** `support` (rig-c/pose#188): the set's chains whose bones anchor to the set's `drawn` local angles while the non-support chains track the active gesture. */
106
106
  support?: string[];
107
+ /** `anchor` (rig-c/pose#191): the set's two-bone arm chains whose hand-tip is anchored to the set's `drawn` head-local offset while the set shows. */
108
+ anchor?: string[];
107
109
  }
108
110
  export interface YawSet {
109
111
  set: string;
@@ -439,7 +441,7 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
439
441
  p.add('MAP_ALT_SHAPE', `${aw} is ${typeName(a)}; a set is ${SET_SHAPE}`);
440
442
  return;
441
443
  }
442
- refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames', 'place', 'symmetric', 'support']);
444
+ refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames', 'place', 'symmetric', 'support', 'anchor']);
443
445
  const name = typeof a.name === 'string' && a.name !== '' ? a.name : null;
444
446
  if (name === null) p.add('MAP_ALT_NAME', `${aw} "name" is ${typeName(a.name)}; it must be a non-empty string`);
445
447
  else if (names.has(name)) p.add('MAP_ALT_NAME', `${aw} "name" "${name}" names two sets`);
@@ -602,7 +604,48 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
602
604
  if (ok) support = sup;
603
605
  }
604
606
  }
605
- if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames, ...(place === null ? {} : { place }), ...(symmetric === undefined ? {} : { symmetric }), ...(support === undefined ? {} : { support }) });
607
+ let anchor: string[] | undefined;
608
+ if ('anchor' in a) {
609
+ const anc = typeof a.anchor === 'string' && a.anchor !== '' ? [a.anchor] : strings(a.anchor);
610
+ if (anc === null || anc.length === 0 || anc.some((s) => s === '')) {
611
+ p.add('MAP_ALT_ANCHOR', `${label} "anchor" is ${JSON.stringify(a.anchor)}; it must name one or more of the set's chains (${(cs ?? []).join(', ') || 'none'})`);
612
+ ok = false;
613
+ } else {
614
+ if (new Set(anc).size !== anc.length) {
615
+ p.add('MAP_ALT_ANCHOR', `${label} "anchor" names a chain twice: ${anc.join(', ')}`);
616
+ ok = false;
617
+ }
618
+ const outside = anc.filter((c) => !(cs ?? []).includes(c));
619
+ if (outside.length > 0) {
620
+ p.add('MAP_ALT_ANCHOR', `${label} "anchor" names chain ${outside.map((c) => `"${c}"`).join(', ')}, which is not in the set's chains (${(cs ?? []).join(', ') || 'none'})`);
621
+ ok = false;
622
+ }
623
+ const ancChains = own.filter((c) => anc.includes(c.name));
624
+ const nonTwo = ancChains.filter((c) => c.bones.length !== 2);
625
+ if (nonTwo.length > 0) {
626
+ p.add('MAP_ALT_ANCHOR', `${label} "anchor" chain ${nonTwo.map((c) => `"${c.name}" (${c.bones.length} bone(s))`).join(', ')} is not a two-bone chain; a head-anchored chain solves a two-bone arm`);
627
+ ok = false;
628
+ }
629
+ if (a.symmetric === true) {
630
+ p.add('MAP_ALT_ANCHOR', `${label} names both "symmetric" and "anchor"; a set either normalises both arms symmetrically or anchors an arm to the head`);
631
+ ok = false;
632
+ }
633
+ const supRaw = typeof a.support === 'string' ? [a.support] : strings(a.support) ?? [];
634
+ const bothSup = anc.filter((c) => supRaw.includes(c));
635
+ if (bothSup.length > 0) {
636
+ p.add('MAP_ALT_ANCHOR', `${label} names chain ${bothSup.map((c) => `"${c}"`).join(', ')} in both "support" and "anchor"; a chain is anchored one way`);
637
+ ok = false;
638
+ }
639
+ const ancBones = ancChains.flatMap((c) => c.bones);
640
+ const missing = ancBones.filter((b) => !(b in drawn));
641
+ if (missing.length > 0) {
642
+ p.add('MAP_ALT_ANCHOR', `${label} "anchor" chain has no "drawn" angle for ${missing.map((b) => `"${b}"`).join(', ')}; a head-anchored chain derives its head-local offset from its drawn angles`);
643
+ ok = false;
644
+ }
645
+ if (ok) anchor = anc;
646
+ }
647
+ }
648
+ if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames, ...(place === null ? {} : { place }), ...(symmetric === undefined ? {} : { symmetric }), ...(support === undefined ? {} : { support }), ...(anchor === undefined ? {} : { anchor }) });
606
649
  });
607
650
  return out;
608
651
  }
package/src/retarget.ts CHANGED
@@ -114,6 +114,14 @@ export const NOD_RESIDUAL_PITCH_GAIN = 2.8;
114
114
  */
115
115
  export const NOD_RESIDUAL_HEAD_GAIN = 1.6;
116
116
 
117
+ /**
118
+ * Reference frame for sets declaring `anchor` (`rig-c/pose#191`): `'head-local'`
119
+ * rotates the set's drawn hand-tip-to-head offset by the head bone's posed world
120
+ * turn on each frame; `'world-fixed'` holds the unrotated setup world offset;
121
+ * `'off'` disables head-anchored end-effector solving.
122
+ */
123
+ export const HEAD_ANCHOR_FRAME: 'head-local' | 'world-fixed' | 'off' = 'head-local';
124
+
117
125
  export interface HeadNodReading {
118
126
  bone: string;
119
127
  maxNod: number;
@@ -481,6 +489,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
481
489
  // `place: "by-hip"` (#93) measures against the hip in hip widths: the map must name the rig's two hip points.
482
490
  const hips = map.alternates.some((a) => a.place !== undefined && a.place !== null) ? hipOf(map) : null;
483
491
  if (hips !== null && 'problem' in hips) p.add('PLACE_HIP', hips.problem);
492
+ const headAnchorBone = map.alternates.some((a) => a.anchor !== undefined) ? headBoneOf(map) : null;
493
+ if (map.alternates.some((a) => a.anchor !== undefined) && headAnchorBone === null) {
494
+ p.add('ANCHOR_HEAD', '"anchor" anchors a set\'s hand to the head bone\'s local frame, and no chain of the map carries the eye line (joints r_eye → l_eye)');
495
+ }
484
496
 
485
497
  // Every point the map reads, held where it is not good.
486
498
  const needed = new Set<string>();
@@ -1353,11 +1365,147 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1353
1365
  const raised: Raised[] = [];
1354
1366
  const byHip: ByHip[] = [];
1355
1367
  const raisedArt: Array<{ sw: Switch; entries: ArtRange[] }> = [];
1356
- const raisedJobs: Array<{ anchor: Anchor; sw: Switch; chain: Chain; wrist: (i: number) => Pt; before: Pt[]; reference: Pt[]; placed: Pt[]; refEye: number; rigEye: number; short: Array<{ i: number; how: 'far' | 'near' | 'missed'; shortfall: number }>; upperHeld: number[]; l0: number; l1: number; farthest: number }> = [];
1368
+ const raisedJobs: Array<{ anchor: Anchor; sw: Switch; chain: Chain; wrist: (i: number) => Pt; before: Pt[]; reference: Pt[]; placed: Pt[]; refEye: number; rigEye: number; short: Array<{ i: number; how: 'far' | 'near' | 'missed'; shortfall: number }>; upperHeld: number[]; saturated?: Array<{ frame: number; residual: number }>; l0: number; l1: number; farthest: number }> = [];
1357
1369
  const headOk: Anchor | null = head !== null && !('problem' in head) ? { kind: 'head', joints: head.joints, points: head.points } : null;
1358
1370
  const hipsOk: Anchor | null = hips !== null && !('problem' in hips) ? { kind: 'hip', joints: hips.joints, points: hips.points } : null;
1359
1371
  const fallbackHead = headOf(map);
1360
1372
  const gestureHead: Anchor | null = headOk ?? (!('problem' in fallbackHead) ? { kind: 'head', joints: fallbackHead.joints, points: fallbackHead.points } : null);
1373
+ /**
1374
+ * #191: Posed origin and final world rotation (degrees, including #190's nod-residual coupling
1375
+ * and `headNodTrack` translation) of the head bone `hb` on frame `i`.
1376
+ */
1377
+ const finalHeadPose = (hb: string, i: number): { origin: Pt; rotDeg: number } => {
1378
+ let origin = posedOrigin(hb, i);
1379
+ if (headNodTrack !== null && headNodTrack.bone === hb) {
1380
+ const k = headNodTrack.keys.find((x) => Math.abs(x.t - keyTimes[i]!) < 1e-5);
1381
+ if (k !== undefined && Array.isArray(k.v)) {
1382
+ const pName = rest.get(hb)?.parent ?? null;
1383
+ const pRest = pName !== null ? rest.get(pName) : undefined;
1384
+ if (pName !== null && pRest !== undefined) {
1385
+ const pTurn = turnAbove(pName, i);
1386
+ const rad = pTurn / DEG;
1387
+ const c = Math.cos(rad);
1388
+ const s = Math.sin(rad);
1389
+ const pa = c * pRest.a - s * pRest.c;
1390
+ const pb = c * pRest.b - s * pRest.d;
1391
+ const pc = s * pRest.a + c * pRest.c;
1392
+ const pd = s * pRest.b + c * pRest.d;
1393
+ const [lx, ly] = k.v;
1394
+ origin = [origin[0] + pa * lx + pb * ly, origin[1] + pc * lx + pd * ly];
1395
+ }
1396
+ }
1397
+ }
1398
+ let rotDeg = targets.get(hb)?.[i] ?? restOf.get(hb) ?? 0;
1399
+ if (headNodReading !== null && headNodReading.bone === hb) {
1400
+ const res = headNodReading.residual[i] ?? 0;
1401
+ if (res !== 0) {
1402
+ const above = mappedAbove(hb);
1403
+ const hc = map.chains.find((c) => c.bones.includes(hb));
1404
+ const limits = hc?.range[hb];
1405
+ const cov = hc !== undefined ? covered.get(hc.name) : undefined;
1406
+ let next = wrap(rotDeg + NOD_RESIDUAL_HEAD_GAIN * res);
1407
+ if (limits !== undefined && cov?.[i] !== true) {
1408
+ const key = wrap((next - restOf.get(hb)!) - (above === null ? 0 : turn(above, i)));
1409
+ if (overflow(key, limits) > RANGE_SLACK) {
1410
+ next = wrap(next + Math.min(Math.max(key, limits[0]), limits[1]) - key);
1411
+ }
1412
+ }
1413
+ rotDeg = next;
1414
+ }
1415
+ }
1416
+ return { origin, rotDeg };
1417
+ };
1418
+ /**
1419
+ * #191: For a chain listed in `set.anchor`, compute the target hand-tip position from the set's
1420
+ * `drawn` hand-tip-to-head offset in the `head` bone's local frame (or fixed world offset when
1421
+ * `HEAD_ANCHOR_FRAME === 'world-fixed'`), and solve the two-bone arm inside `set.turn`.
1422
+ */
1423
+ const solveHeadAnchorFrame = (
1424
+ set: (typeof map.alternates)[number],
1425
+ c: Chain,
1426
+ i: number,
1427
+ ): {
1428
+ a0: number;
1429
+ a1: number;
1430
+ targetTip: Pt;
1431
+ placedTip: Pt;
1432
+ achievedTip: Pt;
1433
+ residual: number;
1434
+ saturated: boolean;
1435
+ upperHeld: boolean;
1436
+ } | null => {
1437
+ if (HEAD_ANCHOR_FRAME === 'off' || headAnchorBone === null || c.bones.length < 2) return null;
1438
+ const [b0, b1] = c.bones as [string, string];
1439
+ const drawn0 = set.drawn[b0];
1440
+ const drawn1 = set.drawn[b1];
1441
+ if (drawn0 === undefined || drawn1 === undefined) return null;
1442
+ const S0 = rigPoint(rest, b0);
1443
+ const E0 = rigPoint(rest, b1);
1444
+ const wSpec = c.aim[1] === 'x' ? `${b1}:tip` : c.aim[1]!;
1445
+ const W0 = rigPoint(rest, wSpec);
1446
+ const H0 = rigPoint(rest, headAnchorBone);
1447
+ const headRestRot = restOf.get(headAnchorBone);
1448
+ if (S0 === null || E0 === null || W0 === null || H0 === null || headRestRot === undefined) return null;
1449
+ const l0 = Math.hypot(E0[0] - S0[0], E0[1] - S0[1]);
1450
+ const l1 = Math.hypot(W0[0] - E0[0], W0[1] - E0[1]);
1451
+ const d0 = wrap(Math.atan2(E0[1] - S0[1], E0[0] - S0[0]) * DEG - restOf.get(b0)!);
1452
+ const d1 = wrap(Math.atan2(W0[1] - E0[1], W0[0] - E0[0]) * DEG - restOf.get(b1)!);
1453
+ const r0Drawn = (drawn0 + d0) / DEG;
1454
+ const r1Drawn = (drawn1 + d1) / DEG;
1455
+ const WDrawn: Pt = [
1456
+ S0[0] + l0 * Math.cos(r0Drawn) + l1 * Math.cos(r1Drawn),
1457
+ S0[1] + l0 * Math.sin(r0Drawn) + l1 * Math.sin(r1Drawn),
1458
+ ];
1459
+ const dWorld0: Pt = [WDrawn[0] - H0[0], WDrawn[1] - H0[1]];
1460
+ const drawnHeadRot = set.drawn[headAnchorBone] ?? headRestRot;
1461
+ const { origin: H, rotDeg: headRot } = finalHeadPose(headAnchorBone, i);
1462
+ const dRotRad = HEAD_ANCHOR_FRAME === 'world-fixed' ? 0 : wrap(headRot - drawnHeadRot) / DEG;
1463
+ const cosH = Math.cos(dRotRad);
1464
+ const sinH = Math.sin(dRotRad);
1465
+ const targetTip: Pt = [
1466
+ H[0] + dWorld0[0] * cosH - dWorld0[1] * sinH,
1467
+ H[1] + dWorld0[0] * sinH + dWorld0[1] * cosH,
1468
+ ];
1469
+ const shoulder = posedOrigin(b0, i);
1470
+ const place = reachLimit(H, targetTip, shoulder, l0, l1);
1471
+ const P = place.point;
1472
+ const side: 1 | -1 = wrap(drawn1 + d1 - (drawn0 + d0)) >= 0 ? 1 : -1;
1473
+ const sol = twoBone(shoulder, P, l0, l1, side);
1474
+ const turn0 = set.turn[b0];
1475
+ const turn1 = set.turn[b1];
1476
+ let a0 = sol.upper !== null ? wrap(sol.upper - d0) : drawn0;
1477
+ let a1 = sol.upper !== null ? wrap(sol.fore - d1) : drawn1;
1478
+ let upperHeld = false;
1479
+ let foreHeld = false;
1480
+ if (turn0 !== undefined) {
1481
+ const tt0 = wrap(a0 - drawn0);
1482
+ const cl0 = Math.min(Math.max(tt0, turn0[0]), turn0[1]);
1483
+ if (Math.abs(cl0 - tt0) > RANGE_SLACK) {
1484
+ upperHeld = true;
1485
+ a0 = wrap(drawn0 + cl0);
1486
+ const r0 = (a0 + d0) / DEG;
1487
+ const E: Pt = [shoulder[0] + l0 * Math.cos(r0), shoulder[1] + l0 * Math.sin(r0)];
1488
+ a1 = wrap(Math.atan2(P[1] - E[1], P[0] - E[0]) * DEG - d1);
1489
+ }
1490
+ }
1491
+ if (turn1 !== undefined) {
1492
+ const tt1 = wrap(a1 - drawn1);
1493
+ const cl1 = Math.min(Math.max(tt1, turn1[0]), turn1[1]);
1494
+ if (Math.abs(cl1 - tt1) > RANGE_SLACK) {
1495
+ foreHeld = true;
1496
+ a1 = wrap(drawn1 + cl1);
1497
+ }
1498
+ }
1499
+ const r0 = (a0 + d0) / DEG;
1500
+ const r1 = (a1 + d1) / DEG;
1501
+ const achievedTip: Pt = [
1502
+ shoulder[0] + l0 * Math.cos(r0) + l1 * Math.cos(r1),
1503
+ shoulder[1] + l0 * Math.sin(r0) + l1 * Math.sin(r1),
1504
+ ];
1505
+ const residual = Math.hypot(achievedTip[0] - targetTip[0], achievedTip[1] - targetTip[1]);
1506
+ const saturated = (place.how !== 'in' || upperHeld || foreHeld) && residual > 1e-3;
1507
+ return { a0, a1, targetTip, placedTip: P, achievedTip, residual, saturated, upperHeld };
1508
+ };
1361
1509
  /**
1362
1510
  * #188: For an active raised chain in a set that declares a `support` arm, compute the
1363
1511
  * outward-cleared world angles `[a0, a1]` on frame `i` from the scan's 2D shoulder→wrist
@@ -1595,6 +1743,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1595
1743
  };
1596
1744
  const isChainHeadPlaced = (set: (typeof map.alternates)[number], c: Chain, drawn: number | undefined): boolean => {
1597
1745
  if (headOk === null) return false;
1746
+ if (set.anchor?.includes(c.name) === true) return true;
1598
1747
  const wJoint = c.joints[c.joints.length - 1];
1599
1748
  const ea = set.pose[headOk.joints[0]];
1600
1749
  const eb = set.pose[headOk.joints[1]];
@@ -1736,6 +1885,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1736
1885
  const upperAsked: number[] = [];
1737
1886
  const upperOver: number[] = [];
1738
1887
  const heldMiss: number[] = [];
1888
+ const isHeadAnchored = HEAD_ANCHOR_FRAME !== 'off' && set.anchor?.includes(c.name) === true;
1889
+ if (isHeadAnchored) job.saturated = [];
1739
1890
  for (const i of span) {
1740
1891
  const h = pairOn(anchor, i);
1741
1892
  const rc = refCentre(i);
@@ -1745,6 +1896,25 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1745
1896
  job.reference.push(ref);
1746
1897
  job.before.push([(w[0] - h.centre[0]) / rigEye, (w[1] - h.centre[1]) / rigEye]);
1747
1898
  const shoulder = posedOrigin(b0, i);
1899
+ if (isHeadAnchored) {
1900
+ const ha = solveHeadAnchorFrame(set, c, i);
1901
+ if (ha !== null) {
1902
+ job.farthest = Math.max(job.farthest, Math.hypot(ha.targetTip[0] - shoulder[0], ha.targetTip[1] - shoulder[1]));
1903
+ job.placed.push([(ha.placedTip[0] - h.centre[0]) / rigEye, (ha.placedTip[1] - h.centre[1]) / rigEye]);
1904
+ if (ha.upperHeld) job.upperHeld.push(i);
1905
+ if (ha.saturated) job.saturated!.push({ frame: i, residual: r3(ha.residual) });
1906
+ const tt0 = turn0 === undefined ? 0 : wrap(ha.a0 - set.drawn[b0]!);
1907
+ const tt1 = turn1 === undefined ? 0 : wrap(ha.a1 - set.drawn[b1]!);
1908
+ upperAsked.push(tt0);
1909
+ upperOver.push(0);
1910
+ foreAsked.push(tt1);
1911
+ foreOver.push(0);
1912
+ heldMiss.push(ha.saturated ? ha.residual / rigEye : 0);
1913
+ t0[i] = ha.a0;
1914
+ t1[i] = ha.a1;
1915
+ continue;
1916
+ }
1917
+ }
1748
1918
  const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
1749
1919
  let solveTarget: Pt = target;
1750
1920
  if (up && l0 / rigEye <= 3.5 && (turn0 !== undefined || turn1 !== undefined)) {
@@ -1877,12 +2047,28 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1877
2047
  if (art.length > 0) raisedArt.push({ sw: s, entries: art });
1878
2048
  }
1879
2049
  }
1880
- // #188: Even without --raised-by-head, apply symmetric normalisation and support-arm anchoring on each switch's span.
2050
+ // #188 / #191: Even without --raised-by-head, apply symmetric normalisation, support-arm anchoring, and head-anchored IK on each switch's span.
1881
2051
  for (const s of sw.switches) {
1882
2052
  const set = map.alternates.find((a) => a.name === s.set)!;
1883
2053
  if (set.symmetric === true && headOk === null && hipsOk === null) {
1884
2054
  for (let i = s.frames[0]; i <= s.frames[1]; i++) solveSymmetricFrame(set, i);
1885
2055
  }
2056
+ if (set.anchor !== undefined && headOk === null && hipsOk === null && HEAD_ANCHOR_FRAME !== 'off') {
2057
+ for (const cn of set.anchor) {
2058
+ if (!s.chains.includes(cn)) continue;
2059
+ const c = map.chains.find((x) => x.name === cn);
2060
+ if (!c || c.bones.length < 2) continue;
2061
+ const t0 = targets.get(c.bones[0]!)!;
2062
+ const t1 = targets.get(c.bones[1]!)!;
2063
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) {
2064
+ const ha = solveHeadAnchorFrame(set, c, i);
2065
+ if (ha !== null) {
2066
+ t0[i] = ha.a0;
2067
+ t1[i] = ha.a1;
2068
+ }
2069
+ }
2070
+ }
2071
+ }
1886
2072
  if (set.support !== undefined) {
1887
2073
  for (const cn of s.chains) {
1888
2074
  const c = map.chains.find((x) => x.name === cn)!;
@@ -1944,6 +2130,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1944
2130
  worst: { before: worst(job.before), after: worst(after), placed: r6(Math.max(...after.map((v, k) => Math.hypot(v[0] - job.placed[k]![0], v[1] - job.placed[k]![1])))) },
1945
2131
  short,
1946
2132
  upperHeld: job.upperHeld,
2133
+ ...(job.saturated !== undefined ? { saturated: job.saturated } : {}),
1947
2134
  reach: { min: r6(Math.abs(job.l0 - job.l1)), max: r6(job.l0 + job.l1), farthest: r6(job.farthest) },
1948
2135
  };
1949
2136
  // The unit sits after `seconds`, as the report has always written it.
@@ -1990,7 +2177,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1990
2177
  }
1991
2178
  }
1992
2179
  }
1993
- // #188: Cover the full shown/bridged span of symmetric sets and support-arm sets after short interpolation.
2180
+ // #188 / #191: Cover the full shown/bridged span of symmetric sets, support-arm sets, and head-anchored chains after short interpolation.
1994
2181
  for (let sIdx = 0; sIdx < sw.switches.length; sIdx++) {
1995
2182
  const s = sw.switches[sIdx]!;
1996
2183
  const set = map.alternates.find((a) => a.name === s.set)!;
@@ -2001,6 +2188,46 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2001
2188
  while (loShow - 1 >= 0 && isSetShownAt(loShow - 1)) loShow--;
2002
2189
  let hiShow = s.frames[1];
2003
2190
  while (hiShow + 1 < n && isSetShownAt(hiShow + 1)) hiShow++;
2191
+ if (set.anchor !== undefined && HEAD_ANCHOR_FRAME !== 'off') {
2192
+ for (const cn of set.anchor) {
2193
+ const c = map.chains.find((x) => x.name === cn);
2194
+ if (!c || c.bones.length < 2) continue;
2195
+ const chainSlots = setSlots.filter(([sl]) => {
2196
+ const sb = slots.get(sl)?.bone;
2197
+ return sb !== undefined && c.bones.includes(sb);
2198
+ });
2199
+ const isChainShownAt = (i: number): boolean =>
2200
+ chainSlots.length > 0
2201
+ ? chainSlots.every(([sl, att]) => sw.shown.get(sl)?.[i] === att)
2202
+ : isSetShownAt(i);
2203
+ let loC = s.frames[0];
2204
+ while (loC - 1 >= 0 && isChainShownAt(loC - 1)) loC--;
2205
+ let hiC = s.frames[1];
2206
+ while (hiC + 1 < n && isChainShownAt(hiC + 1)) hiC++;
2207
+ const t0 = targets.get(c.bones[0]!)!;
2208
+ const t1 = targets.get(c.bones[1]!)!;
2209
+ for (let i = loC; i <= hiC; i++) {
2210
+ const ha = solveHeadAnchorFrame(set, c, i);
2211
+ if (ha !== null) {
2212
+ t0[i] = ha.a0;
2213
+ t1[i] = ha.a1;
2214
+ }
2215
+ }
2216
+ // If `short` or `chainHolds` ramp-interpolated the boundary frame immediately outside the
2217
+ // anchored chain's shown window (`loC - 1` or `hiC + 1`) while a neighbouring set's
2218
+ // attachment is still displayed on that bone, restore that boundary frame from the
2219
+ // neighbouring set's clamped key (`loC - 2` / `hiC + 2`) so the neighbouring set's art
2220
+ // is not bent toward the anchored pose before its slot switches.
2221
+ if (loC >= 2 && covered.get(cn)?.[loC - 1] === true) {
2222
+ t0[loC - 1] = t0[loC - 2]!;
2223
+ t1[loC - 1] = t1[loC - 2]!;
2224
+ }
2225
+ if (hiC + 2 < n && covered.get(cn)?.[hiC + 1] === true) {
2226
+ t0[hiC + 1] = t0[hiC + 2]!;
2227
+ t1[hiC + 1] = t1[hiC + 2]!;
2228
+ }
2229
+ }
2230
+ }
2004
2231
  if (set.symmetric === true) {
2005
2232
  for (let i = loShow; i <= s.frames[1]; i++) solveSymmetricFrame(set, i);
2006
2233
  if (loShow < s.frames[0]) {
@@ -2381,6 +2608,20 @@ function headOf(map: JointMap): Head | { problem: string } {
2381
2608
  return { problem: `${lead}; no chain of the map carries the eye line (joints r_eye → l_eye), so the rig has no eye points to measure` };
2382
2609
  }
2383
2610
 
2611
+ /** The rig's head bone (the bone carrying `r_eye → l_eye` or `l_eye → r_eye` in the map), or null if no chain carries the eye line. */
2612
+ function headBoneOf(map: JointMap): string | null {
2613
+ for (const c of map.chains) {
2614
+ for (let k = 0; k + 1 < c.joints.length; k++) {
2615
+ const a = c.joints[k]!;
2616
+ const b = c.joints[k + 1]!;
2617
+ if ((a === 'r_eye' && b === 'l_eye') || (a === 'l_eye' && b === 'r_eye')) {
2618
+ return c.bones[k] ?? null;
2619
+ }
2620
+ }
2621
+ }
2622
+ return null;
2623
+ }
2624
+
2384
2625
  type ShortSpan = Omit<Short, 'chain' | 'bone' | 'joints'>;
2385
2626
 
2386
2627
  /**
package/src/switch.ts CHANGED
@@ -1426,6 +1426,8 @@ export interface Raised {
1426
1426
  short: ReachShort[];
1427
1427
  /** The frames whose upper arm the set's `turn` held: the forearm re-aimed at the placed wrist. */
1428
1428
  upperHeld: number[];
1429
+ /** For a head-anchored chain (`anchor`, rig-c/pose#191): each frame where the set's `turn` clamp saturated and left a non-zero wrist residual, in rig world units. */
1430
+ saturated?: Array<{ frame: number; residual: number }>;
1429
1431
  /** The arm's reach (from |l0 − l1| to l0 + l1, world units from the shoulder) and the farthest target from the shoulder over the span. */
1430
1432
  reach: { min: number; max: number; farthest: number };
1431
1433
  }
@@ -1459,7 +1461,8 @@ export function raisedLine(e: Raised | ByHip, times: readonly number[]): string
1459
1461
  const w = anchorOf(e);
1460
1462
  const units = 'hips' in e ? e.hips : e.eyes;
1461
1463
  const med = (vs: readonly P2[]): P2 => [median(vs.map((v) => v[0])), median(vs.map((v) => v[1]))];
1462
- const held = e.upperHeld.length === 0 ? 'the upper arm within its turn on every frame' : `the upper arm held by the set's turn on ${e.upperHeld.length} frame(s) (${framesText(e.upperHeld)}), the forearm re-aimed`;
1464
+ const satText = e.saturated !== undefined && e.saturated.length > 0 ? `; residual ${e.saturated.map((s) => `f${s.frame} ${s.residual.toFixed(2)}u`).join(', ')}` : '';
1465
+ const held = e.upperHeld.length === 0 ? 'the upper arm within its turn on every frame' : `the upper arm held by the set's turn on ${e.upperHeld.length} frame(s) (${framesText(e.upperHeld)}${satText}), the forearm re-aimed`;
1463
1466
  const out = e.short.length === 0 ? 'every target in reach' : `out of reach on ${e.short.reduce((k, s) => k + s.frames[1] - s.frames[0] + 1, 0)} frame(s) (REACH)`;
1464
1467
  return `set "${e.set}" chain "${e.chain}" ${spanOf(e.frames, times)}: the wrist placed against ${w.against}; ${w.unitOne} (median) rig ${units.rig.toFixed(2)} units, reference ${units.reference.toFixed(2)} px; wrist − ${w.centre} in ${w.unit} (x right, y up), median reference ${xy(med(e.reference))}, placed ${xy(med(e.placed))}, before ${xy(med(e.before))}, after ${xy(med(e.after))}; worst |rig − reference| before ${xy(e.worst.before)}, after ${xy(e.worst.after)}; worst |after − placed| ${e.worst.placed.toFixed(2)}; ${held}; ${out}`;
1465
1468
  }