@rig-c/pose 0.27.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 +5 -4
- package/package.json +1 -1
- package/src/face.ts +20 -3
- package/src/map.ts +45 -2
- package/src/retarget.ts +300 -5
- package/src/switch.ts +4 -1
package/README.md
CHANGED
|
@@ -107,7 +107,7 @@ A retarget of the cleaned dancer, with `--flip 90 --short 0.35 --bends clamp --l
|
|
|
107
107
|
rig-pose retarget --rig out/rig --map map.json --pose dance.pose.json --out motion.json --name dance
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
For every frame of the pose and every bone the map names, the segment that bone carries (an upper arm: shoulder → elbow) points somewhere in the picture. The bone is turned so its segment points the same way: the picture angle, flipped into rig-c's y-up world, minus the bone's angle at the rig's setup pose (read through rig-c's own poser), less the turn of the mapped bone above it — because a `rotate` key in rig-c adds to the setup rotation and a child turns with its parent. The travel of the pelvis midpoint, scaled by a length measured on both the rig and the pose, moves the hip. When a head chain carries the eye line (`r_eye → l_eye`), the eyes' vertical travel against `neck` in the reference, scaled into the rig's units, keys a vertical `translate` nod channel on the head bone ([#111](https://github.com/rig-c/pose/issues/111)),
|
|
110
|
+
For every frame of the pose and every bone the map names, the segment that bone carries (an upper arm: shoulder → elbow) points somewhere in the picture. The bone is turned so its segment points the same way: the picture angle, flipped into rig-c's y-up world, minus the bone's angle at the rig's setup pose (read through rig-c's own poser), less the turn of the mapped bone above it — because a `rotate` key in rig-c adds to the setup rotation and a child turns with its parent. The travel of the pelvis midpoint, scaled by a length measured on both the rig and the pose, moves the hip. When a head chain carries the eye line (`r_eye → l_eye`), the eyes' vertical travel against `neck` in the reference, scaled into the rig's units, keys a vertical `translate` nod channel on the head bone ([#111](https://github.com/rig-c/pose/issues/111)) clamped within `±2%` of the setup neck length (`head − neck`, [#181](https://github.com/rig-c/pose/issues/181)), while any vertical residual removed by that clamp (`rawNod − dy`) is coupled in phase into `face.pitch`'s dial (`+2.8°/unit`, mapped through `face.pitch.range`) and the head bone's `rotate` track (`+1.6°/unit`, [#190](https://github.com/rig-c/pose/issues/190); gains calibrated against the hand-polished reference `p1`), adding `0°` on frames where the clamp does not act. The result is one animation in rig-c's motion format (`rigc-motion/1`): a `rotate` track per mapped bone, a `translate` track on the hip (and on the head when the reference nods), one key per pose frame — and, when the map lists alternate drawings, an `attachment` track per slot they switch (and a `translate` track per bone a set offsets).
|
|
111
111
|
|
|
112
112
|
Nothing is written until every check passes:
|
|
113
113
|
|
|
@@ -315,7 +315,7 @@ Which bones carry which joints. You write it for your rig:
|
|
|
315
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.
|
|
316
316
|
- `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
|
|
317
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.
|
|
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), [#189](https://github.com/rig-c/pose/issues/189)): `{ "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 — where the eye span is foreshortened below `0.85 × median(de)` and the ear-chord offset `xea = (mid_ears − mid_eyes) · u / da` exceeds both `1.8 × |xne|` and `0.15`, yaw blends toward the ear chord `atan(2 × xea)` with weight `w = clamp((0.85 × median(de) − de) / ((0.85 − 0.80) × median(de)), 0, 1)` so a side gaze whose 2D nose sits near the eye midpoint reaches `atan(2 × xea)` below `0.80 × median(de)` and exits without a step), 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`).
|
|
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), [#189](https://github.com/rig-c/pose/issues/189), [#190](https://github.com/rig-c/pose/issues/190)): `{ "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 — where the eye span is foreshortened below `0.85 × median(de)` and the ear-chord offset `xea = (mid_ears − mid_eyes) · u / da` exceeds both `1.8 × |xne|` and `0.15`, yaw blends toward the ear chord `atan(2 × xea)` with weight `w = clamp((0.85 × median(de) − de) / ((0.85 − 0.80) × median(de)), 0, 1)` so a side gaze whose 2D nose sits near the eye midpoint reaches `atan(2 × xea)` below `0.80 × median(de)` and exits without a step), couples any vertical residual removed by the head nod channel's `±2%` neck-length clamp (`+2.8°/unit`, `0°` where un-clamped) into `pitch` in phase ([#190](https://github.com/rig-c/pose/issues/190)), 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
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.
|
|
320
320
|
|
|
321
321
|
### A joint's range: `range`
|
|
@@ -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 `
|
|
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
|
-
-
|
|
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.
|
|
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/face.ts
CHANGED
|
@@ -293,18 +293,35 @@ export function readFace(
|
|
|
293
293
|
lEye: readonly Pt[],
|
|
294
294
|
rEar: readonly Pt[] | null,
|
|
295
295
|
lEar: readonly Pt[] | null,
|
|
296
|
+
nodResidual?: readonly number[] | null,
|
|
297
|
+
pitchGain = 0,
|
|
296
298
|
): FaceReading {
|
|
297
299
|
const { ears, yaw, pitch } = readFaceAngles(nose, rEye, lEye, rEar, lEar);
|
|
300
|
+
const coupledPitch =
|
|
301
|
+
nodResidual !== undefined && nodResidual !== null && nodResidual.length === pitch.length && pitchGain !== 0
|
|
302
|
+
? pitch.map((p, i) => (nodResidual[i]! === 0 ? p : p + pitchGain * nodResidual[i]!))
|
|
303
|
+
: pitch;
|
|
304
|
+
const basePitch = face.pitch === undefined ? undefined : mapFaceAxis(pitch, face.pitch);
|
|
305
|
+
const mappedPitch =
|
|
306
|
+
face.pitch === undefined
|
|
307
|
+
? undefined
|
|
308
|
+
: coupledPitch === pitch
|
|
309
|
+
? basePitch!
|
|
310
|
+
: (() => {
|
|
311
|
+
const m = mapFaceAxis(coupledPitch, face.pitch);
|
|
312
|
+
const keys = m.keys.map((v, i) => (nodResidual![i]! === 0 ? basePitch!.keys[i]! : v));
|
|
313
|
+
return { ...m, mapped: spanOf(keys), corr: corrOf(m.raw, keys), keys };
|
|
314
|
+
})();
|
|
298
315
|
return {
|
|
299
316
|
ears,
|
|
300
317
|
read: {
|
|
301
318
|
yaw: spanOf(yaw),
|
|
302
|
-
pitch: spanOf(
|
|
319
|
+
pitch: spanOf(coupledPitch),
|
|
303
320
|
rawYaw: yaw,
|
|
304
|
-
rawPitch:
|
|
321
|
+
rawPitch: coupledPitch,
|
|
305
322
|
},
|
|
306
323
|
...(face.yaw === undefined ? {} : { yaw: mapFaceAxis(yaw, face.yaw) }),
|
|
307
|
-
...(
|
|
324
|
+
...(mappedPitch === undefined ? {} : { pitch: mappedPitch }),
|
|
308
325
|
};
|
|
309
326
|
}
|
|
310
327
|
|
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
|
-
|
|
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
|
@@ -98,6 +98,38 @@ const DEG = 180 / Math.PI;
|
|
|
98
98
|
*/
|
|
99
99
|
export const HELD_MISS = 0.01;
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Beat-nod phase-coupling gain (`°/unit`) from the vertical residual that the
|
|
103
|
+
* `±2%` neck-length clamp removes from `head.translate.y` (`rawNod - dy`) onto
|
|
104
|
+
* `map.face.pitch` (`pitch_dial.rotate`, `rig-c/pose#190`). Measured on the
|
|
105
|
+
* dancer reference hand pass (`hand/p1` HP2: `+2.8°/unit`).
|
|
106
|
+
*/
|
|
107
|
+
export const NOD_RESIDUAL_PITCH_GAIN = 2.8;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Beat-nod phase-coupling gain (`°/unit`) from the vertical residual that the
|
|
111
|
+
* `±2%` neck-length clamp removes from `head.translate.y` (`rawNod - dy`) onto
|
|
112
|
+
* the head chain bone's `rotate` track (`head.rotate`, `rig-c/pose#190`).
|
|
113
|
+
* Measured on the dancer reference hand pass (`hand/p1` HP2: `+1.6°/unit`).
|
|
114
|
+
*/
|
|
115
|
+
export const NOD_RESIDUAL_HEAD_GAIN = 1.6;
|
|
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
|
+
|
|
125
|
+
export interface HeadNodReading {
|
|
126
|
+
bone: string;
|
|
127
|
+
maxNod: number;
|
|
128
|
+
rawY: number[];
|
|
129
|
+
clampedY: number[];
|
|
130
|
+
residual: number[];
|
|
131
|
+
}
|
|
132
|
+
|
|
101
133
|
export interface RetargetOptions extends SwitchOptions {
|
|
102
134
|
/** A joint with a score below this is not good on that frame (a joint with no score is). */
|
|
103
135
|
minScore: number;
|
|
@@ -334,6 +366,8 @@ export interface Retarget {
|
|
|
334
366
|
body: BodyReading | null;
|
|
335
367
|
/** Continuous per-frame figure scale relative to frame 0 (`#163`, `#161`). */
|
|
336
368
|
figureScale: FigureScaleReading;
|
|
369
|
+
/** Per-frame vertical head nod reading (`rawY`, `clampedY`, and clamped `residual`, `#111`, `#181`, `#190`). */
|
|
370
|
+
headNod: HeadNodReading | null;
|
|
337
371
|
}
|
|
338
372
|
|
|
339
373
|
export interface FigureScaleFrame {
|
|
@@ -455,6 +489,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
455
489
|
// `place: "by-hip"` (#93) measures against the hip in hip widths: the map must name the rig's two hip points.
|
|
456
490
|
const hips = map.alternates.some((a) => a.place !== undefined && a.place !== null) ? hipOf(map) : null;
|
|
457
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
|
+
}
|
|
458
496
|
|
|
459
497
|
// Every point the map reads, held where it is not good.
|
|
460
498
|
const needed = new Set<string>();
|
|
@@ -1080,6 +1118,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1080
1118
|
let headNodBone: string | null = null;
|
|
1081
1119
|
let headNod: Pt[] | null = null;
|
|
1082
1120
|
let headNodTrack: MotionTrack | null = null;
|
|
1121
|
+
let headNodReading: HeadNodReading | null = null;
|
|
1083
1122
|
const posedOrigin = (b: string, i: number): Pt => {
|
|
1084
1123
|
const own = rest.get(b)!;
|
|
1085
1124
|
if (t !== null && b === t.bone) return [own.worldX + (travel[i]?.[0] ?? 0), own.worldY + (travel[i]?.[1] ?? 0)];
|
|
@@ -1271,6 +1310,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1271
1310
|
}
|
|
1272
1311
|
return Math.max(-maxNod, Math.min(maxNod, v));
|
|
1273
1312
|
});
|
|
1313
|
+
const residual = rawNod.map((v, i) => v - dy[i]!);
|
|
1314
|
+
headNodReading = { bone: hb, maxNod, rawY: rawNod, clampedY: dy, residual };
|
|
1274
1315
|
if (dy.some((v) => Math.abs(r3(v)) > 0)) {
|
|
1275
1316
|
const keys: MotionKey[] = [];
|
|
1276
1317
|
const nod: Pt[] = [];
|
|
@@ -1324,11 +1365,147 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1324
1365
|
const raised: Raised[] = [];
|
|
1325
1366
|
const byHip: ByHip[] = [];
|
|
1326
1367
|
const raisedArt: Array<{ sw: Switch; entries: ArtRange[] }> = [];
|
|
1327
|
-
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 }> = [];
|
|
1328
1369
|
const headOk: Anchor | null = head !== null && !('problem' in head) ? { kind: 'head', joints: head.joints, points: head.points } : null;
|
|
1329
1370
|
const hipsOk: Anchor | null = hips !== null && !('problem' in hips) ? { kind: 'hip', joints: hips.joints, points: hips.points } : null;
|
|
1330
1371
|
const fallbackHead = headOf(map);
|
|
1331
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
|
+
};
|
|
1332
1509
|
/**
|
|
1333
1510
|
* #188: For an active raised chain in a set that declares a `support` arm, compute the
|
|
1334
1511
|
* outward-cleared world angles `[a0, a1]` on frame `i` from the scan's 2D shoulder→wrist
|
|
@@ -1566,6 +1743,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1566
1743
|
};
|
|
1567
1744
|
const isChainHeadPlaced = (set: (typeof map.alternates)[number], c: Chain, drawn: number | undefined): boolean => {
|
|
1568
1745
|
if (headOk === null) return false;
|
|
1746
|
+
if (set.anchor?.includes(c.name) === true) return true;
|
|
1569
1747
|
const wJoint = c.joints[c.joints.length - 1];
|
|
1570
1748
|
const ea = set.pose[headOk.joints[0]];
|
|
1571
1749
|
const eb = set.pose[headOk.joints[1]];
|
|
@@ -1707,6 +1885,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1707
1885
|
const upperAsked: number[] = [];
|
|
1708
1886
|
const upperOver: number[] = [];
|
|
1709
1887
|
const heldMiss: number[] = [];
|
|
1888
|
+
const isHeadAnchored = HEAD_ANCHOR_FRAME !== 'off' && set.anchor?.includes(c.name) === true;
|
|
1889
|
+
if (isHeadAnchored) job.saturated = [];
|
|
1710
1890
|
for (const i of span) {
|
|
1711
1891
|
const h = pairOn(anchor, i);
|
|
1712
1892
|
const rc = refCentre(i);
|
|
@@ -1716,6 +1896,25 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1716
1896
|
job.reference.push(ref);
|
|
1717
1897
|
job.before.push([(w[0] - h.centre[0]) / rigEye, (w[1] - h.centre[1]) / rigEye]);
|
|
1718
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
|
+
}
|
|
1719
1918
|
const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
|
|
1720
1919
|
let solveTarget: Pt = target;
|
|
1721
1920
|
if (up && l0 / rigEye <= 3.5 && (turn0 !== undefined || turn1 !== undefined)) {
|
|
@@ -1848,12 +2047,28 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1848
2047
|
if (art.length > 0) raisedArt.push({ sw: s, entries: art });
|
|
1849
2048
|
}
|
|
1850
2049
|
}
|
|
1851
|
-
// #188: Even without --raised-by-head, apply symmetric normalisation
|
|
2050
|
+
// #188 / #191: Even without --raised-by-head, apply symmetric normalisation, support-arm anchoring, and head-anchored IK on each switch's span.
|
|
1852
2051
|
for (const s of sw.switches) {
|
|
1853
2052
|
const set = map.alternates.find((a) => a.name === s.set)!;
|
|
1854
2053
|
if (set.symmetric === true && headOk === null && hipsOk === null) {
|
|
1855
2054
|
for (let i = s.frames[0]; i <= s.frames[1]; i++) solveSymmetricFrame(set, i);
|
|
1856
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
|
+
}
|
|
1857
2072
|
if (set.support !== undefined) {
|
|
1858
2073
|
for (const cn of s.chains) {
|
|
1859
2074
|
const c = map.chains.find((x) => x.name === cn)!;
|
|
@@ -1915,6 +2130,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1915
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])))) },
|
|
1916
2131
|
short,
|
|
1917
2132
|
upperHeld: job.upperHeld,
|
|
2133
|
+
...(job.saturated !== undefined ? { saturated: job.saturated } : {}),
|
|
1918
2134
|
reach: { min: r6(Math.abs(job.l0 - job.l1)), max: r6(job.l0 + job.l1), farthest: r6(job.farthest) },
|
|
1919
2135
|
};
|
|
1920
2136
|
// The unit sits after `seconds`, as the report has always written it.
|
|
@@ -1961,7 +2177,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1961
2177
|
}
|
|
1962
2178
|
}
|
|
1963
2179
|
}
|
|
1964
|
-
// #188: Cover the full shown/bridged span of symmetric sets
|
|
2180
|
+
// #188 / #191: Cover the full shown/bridged span of symmetric sets, support-arm sets, and head-anchored chains after short interpolation.
|
|
1965
2181
|
for (let sIdx = 0; sIdx < sw.switches.length; sIdx++) {
|
|
1966
2182
|
const s = sw.switches[sIdx]!;
|
|
1967
2183
|
const set = map.alternates.find((a) => a.name === s.set)!;
|
|
@@ -1972,6 +2188,46 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1972
2188
|
while (loShow - 1 >= 0 && isSetShownAt(loShow - 1)) loShow--;
|
|
1973
2189
|
let hiShow = s.frames[1];
|
|
1974
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
|
+
}
|
|
1975
2231
|
if (set.symmetric === true) {
|
|
1976
2232
|
for (let i = loShow; i <= s.frames[1]; i++) solveSymmetricFrame(set, i);
|
|
1977
2233
|
if (loShow < s.frames[0]) {
|
|
@@ -2094,6 +2350,31 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
2094
2350
|
}
|
|
2095
2351
|
const solvedKey = (b: string, i: number) => legPoses?.[i]!.solved.get(b);
|
|
2096
2352
|
|
|
2353
|
+
// #190: couple the vertical residual removed by the neck-length clamp into head.rotate
|
|
2354
|
+
// in phase (+NOD_RESIDUAL_HEAD_GAIN °/unit), adding 0 on frames where the clamp did not act.
|
|
2355
|
+
if (headNodReading !== null) {
|
|
2356
|
+
const hb = headNodReading.bone;
|
|
2357
|
+
const tg = targets.get(hb);
|
|
2358
|
+
if (tg !== undefined) {
|
|
2359
|
+
const above = mappedAbove(hb);
|
|
2360
|
+
const hc = map.chains.find((c) => c.bones.includes(hb));
|
|
2361
|
+
const limits = hc?.range[hb];
|
|
2362
|
+
const cov = hc !== undefined ? covered.get(hc.name) : undefined;
|
|
2363
|
+
for (let i = 0; i < n; i++) {
|
|
2364
|
+
const res = headNodReading.residual[i]!;
|
|
2365
|
+
if (res === 0) continue;
|
|
2366
|
+
let next = wrap(tg[i]! + NOD_RESIDUAL_HEAD_GAIN * res);
|
|
2367
|
+
if (limits !== undefined && cov?.[i] !== true) {
|
|
2368
|
+
const key = wrap((next - restOf.get(hb)!) - (above === null ? 0 : turn(above, i)));
|
|
2369
|
+
if (overflow(key, limits) > RANGE_SLACK) {
|
|
2370
|
+
next = wrap(next + Math.min(Math.max(key, limits[0]), limits[1]) - key);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
tg[i] = next;
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2097
2378
|
// Keys: the bone's turn less the turn of the nearest mapped bone above it.
|
|
2098
2379
|
const tracks: Array<MotionTrack | SlotTrack> = [];
|
|
2099
2380
|
for (const c of map.chains) {
|
|
@@ -2129,7 +2410,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
2129
2410
|
const h = heldOf('l_ear');
|
|
2130
2411
|
return 'never' in h ? null : h.at;
|
|
2131
2412
|
})();
|
|
2132
|
-
face = readFace(map.face, series.get('nose')!, series.get('r_eye')!, series.get('l_eye')!, rEar, lEar);
|
|
2413
|
+
face = readFace(map.face, series.get('nose')!, series.get('r_eye')!, series.get('l_eye')!, rEar, lEar, headNodReading?.residual ?? null, NOD_RESIDUAL_PITCH_GAIN);
|
|
2133
2414
|
for (const axis of ['yaw', 'pitch'] as const) {
|
|
2134
2415
|
const ax = face[axis];
|
|
2135
2416
|
if (ax === undefined) continue;
|
|
@@ -2260,7 +2541,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
2260
2541
|
animation: { duration: keyTimes[n - 1]!, tracks },
|
|
2261
2542
|
times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
|
|
2262
2543
|
switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
|
|
2263
|
-
legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, body, figureScale,
|
|
2544
|
+
legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, body, figureScale, headNod: headNodReading,
|
|
2264
2545
|
};
|
|
2265
2546
|
}
|
|
2266
2547
|
|
|
@@ -2327,6 +2608,20 @@ function headOf(map: JointMap): Head | { problem: string } {
|
|
|
2327
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` };
|
|
2328
2609
|
}
|
|
2329
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
|
+
|
|
2330
2625
|
type ShortSpan = Omit<Short, 'chain' | 'bone' | 'joints'>;
|
|
2331
2626
|
|
|
2332
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
|
|
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
|
}
|