Skip to content

Commit b634ba1

Browse files
committed
feat(render): look-at head tracking toward active contacts (L4.3)
1 parent 618c745 commit b634ba1

3 files changed

Lines changed: 93 additions & 2 deletions

File tree

packages/posecode-render/src/contacts.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,33 @@ export function swingArms(
211211
}
212212
if (changed) m.root.updateMatrixWorld(true);
213213
}
214+
215+
/** Max head turn toward a look target (radians) so the neck never over-rotates. */
216+
export const MAX_LOOK = 55 * (Math.PI / 180);
217+
const LOOK_FWD = new THREE.Vector3(0, 0, 1);
218+
219+
/**
220+
* Turn the head toward a world focus point (look-at): aims the face (+Z) at the
221+
* target, clamped to MAX_LOOK so the head tracks the action (up at the bar in a
222+
* pull-up, down at the hands in a floor fold) without spinning unnaturally.
223+
*/
224+
export function aimHead(m: Mannequin, focus: THREE.Vector3): void {
225+
const head = m.bones.get("head");
226+
if (!head?.parent) return;
227+
const headPos = head.getWorldPosition(new THREE.Vector3());
228+
const desired = focus.clone().sub(headPos);
229+
if (desired.lengthSq() < 1e-6) return;
230+
desired.normalize();
231+
const world = head.getWorldQuaternion(new THREE.Quaternion());
232+
const currentZ = LOOK_FWD.clone().applyQuaternion(world).normalize();
233+
const full = new THREE.Quaternion().setFromUnitVectors(currentZ, desired);
234+
const angle = 2 * Math.acos(THREE.MathUtils.clamp(Math.abs(full.w), -1, 1));
235+
const correction =
236+
angle > MAX_LOOK
237+
? new THREE.Quaternion().slerpQuaternions(new THREE.Quaternion(), full, MAX_LOOK / angle)
238+
: full;
239+
const desiredWorld = correction.multiply(world);
240+
const parentWorld = head.parent.getWorldQuaternion(new THREE.Quaternion());
241+
head.quaternion.copy(parentWorld.invert().multiply(desiredWorld));
242+
m.root.updateMatrixWorld(true);
243+
}

packages/posecode-render/src/index.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
type ClipSource,
2828
} from "./clips.js";
2929
import { depenetrate } from "./depenetrate.js";
30-
import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms } from "./contacts.js";
30+
import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands, swingArms, aimHead } from "./contacts.js";
3131

3232
const DEG = Math.PI / 180;
3333

@@ -279,6 +279,8 @@ export function createViewer(
279279
let authoredFingers = new Set<string>();
280280
// Shoulders the document poses: L4.2 arm-swing leaves these to the author.
281281
let authoredShoulders = new Set<string>();
282+
// True when the document poses the head/neck: L4.3 look-at then stays off.
283+
let authoredHead = false;
282284
// The last loaded document, kept so the viewer can re-solve base pose and
283285
// ground anchors when the character (with its own proportions) arrives.
284286
let lastIR: PosecodeIR | null = null;
@@ -538,6 +540,31 @@ export function createViewer(
538540
wrapGrip(mannequin, grips);
539541
}
540542

543+
/**
544+
* L4.3 look-at: turn the head toward the action. Collects the world points of
545+
* this phase's active grips/reaches (up at the bar, down at a floor reach) and
546+
* aims the head at their average. Skipped when the document poses the head/neck.
547+
*/
548+
function applyLookAt(info: { grips: GripTarget[]; reaches: ReachTarget[] }): void {
549+
if (authoredHead) return;
550+
const pts: THREE.Vector3[] = [];
551+
const collect = (effectorName: string, anchorName: string): void => {
552+
const bone = EFFECTOR_BONE[effectorName] ?? effectorName;
553+
const eff = mannequin.bones.get(bone);
554+
if (!eff) return;
555+
const t =
556+
resolveReachTarget(anchorName, eff) ??
557+
resolveReachTarget(anchorName.replace(/_(left|right)$/, ""), eff);
558+
if (t) pts.push(t);
559+
};
560+
for (const g of info.grips) collect(g.effector, g.anchor);
561+
for (const r of info.reaches) collect(r.effector, r.target);
562+
if (pts.length === 0) return;
563+
const focus = new THREE.Vector3();
564+
for (const p of pts) focus.add(p);
565+
aimHead(mannequin, focus.multiplyScalar(1 / pts.length));
566+
}
567+
541568
function frameCamera(): void {
542569
// Auto-frame the figure: fit its bounding box, keep a pleasant angle.
543570
// Include any scene prop too: a pull-up bar sits well above the figure's
@@ -609,6 +636,8 @@ export function createViewer(
609636
swingArms(mannequin, authoredShoulders, gripSidesOf(info.grips));
610637
// L4.1 aliveness: relax idle hands into a natural curl (grips still wrap).
611638
relaxHands(mannequin, gripSidesOf(info.grips), authoredFingers);
639+
// L4.3 aliveness: turn the head toward the active contact (bar / floor reach).
640+
applyLookAt(info);
612641
// Safety net: nothing above ever intentionally pushes part of the body
613642
// below the floor, so clamp the root up whenever the lowest point dips
614643
// below y=0, a no-op whenever the pose is legitimately grounded or
@@ -709,8 +738,10 @@ export function createViewer(
709738
levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []);
710739
authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId));
711740
authoredShoulders = new Set(timeline.bonesUsed.filter((id) => id.startsWith("shoulder_")));
741+
authoredHead = timeline.bonesUsed.some((id) => id === "head" || id === "neck");
712742
swingArms(mannequin, authoredShoulders, gripSidesOf(ir.phases[0]?.grips ?? []));
713743
relaxHands(mannequin, gripSidesOf(ir.phases[0]?.grips ?? []), authoredFingers);
744+
applyLookAt({ grips: ir.phases[0]?.grips ?? [], reaches: ir.phases[0]?.reaches ?? [] });
714745
captureGroundTargets();
715746
baseRootPos.copy(mannequin.root.position);
716747
baseRootQuat.copy(mannequin.root.quaternion);

packages/posecode-render/test/contacts.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect } from "vitest";
22
import * as THREE from "three";
33
import { buildMannequin } from "../src/mannequin.js";
4-
import { levelPlantedFeet, relaxHands, swingArms } from "../src/contacts.js";
4+
import { levelPlantedFeet, relaxHands, swingArms, aimHead } from "../src/contacts.js";
55
import { groundFigure } from "../src/groundlock.js";
66

77
const DEG = Math.PI / 180;
@@ -105,3 +105,33 @@ describe("swingArms (L4.2)", () => {
105105
expect(m.bones.get("shoulder_left")!.rotation.x).toBeCloseTo(before, 5);
106106
});
107107
});
108+
109+
describe("aimHead (L4.3 look-at)", () => {
110+
it("turns the head toward a focus point (face +Z tracks the target)", () => {
111+
const m = buildMannequin();
112+
m.root.updateMatrixWorld(true);
113+
const head = m.bones.get("head")!;
114+
const headPos = head.getWorldPosition(new THREE.Vector3());
115+
const focus = headPos.clone().add(new THREE.Vector3(0, 1.2, 0.6));
116+
const faceDir = () =>
117+
new THREE.Vector3(0, 0, 1)
118+
.applyQuaternion(head.getWorldQuaternion(new THREE.Quaternion()))
119+
.normalize();
120+
const want = focus.clone().sub(headPos).normalize();
121+
const before = faceDir().dot(want);
122+
aimHead(m, focus);
123+
m.root.updateMatrixWorld(true);
124+
expect(faceDir().dot(want)).toBeGreaterThan(before);
125+
});
126+
127+
it("clamps the look so the head never spins past its range", () => {
128+
const m = buildMannequin();
129+
m.root.updateMatrixWorld(true);
130+
const head = m.bones.get("head")!;
131+
const headPos = head.getWorldPosition(new THREE.Vector3());
132+
const behind = headPos.clone().add(new THREE.Vector3(0, 0, -2));
133+
aimHead(m, behind);
134+
const e = new THREE.Euler().setFromQuaternion(m.bones.get("head")!.quaternion, "XYZ");
135+
expect(Math.hypot(e.x, e.y, e.z)).toBeLessThan(1.2);
136+
});
137+
});

0 commit comments

Comments
 (0)