Skip to content

Commit 7734730

Browse files
committed
feat(render): relaxed resting hand pose (L4.1 secondary motion)
1 parent ce3f90e commit 7734730

3 files changed

Lines changed: 87 additions & 2 deletions

File tree

packages/posecode-render/src/contacts.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,42 @@ export function wrapGrip(m: Mannequin, grips: readonly GripTarget[]): void {
137137
}
138138
if (changed) m.root.updateMatrixWorld(true);
139139
}
140+
141+
/** Relaxed resting finger curl (radians) for an idle hand. */
142+
export const REST_CURL = 0.32;
143+
144+
/**
145+
* Give idle hands a natural relaxed curl instead of a flat splayed palm. Applied
146+
* every frame to any hand that is NOT gripping this phase (those are wrapped by
147+
* `wrapGrip`) and whose fingers are NOT explicitly authored (make-a-fist,
148+
* finger-spell, hand-wave keep their pose). A mesh-only-style aliveness layer:
149+
* it writes only finger-bone locals, so it can never disturb the solved pose.
150+
*/
151+
export function relaxHands(
152+
m: Mannequin,
153+
gripSides: ReadonlySet<"left" | "right">,
154+
authoredFingers: ReadonlySet<string>,
155+
): void {
156+
let changed = false;
157+
for (const side of ["left", "right"] as const) {
158+
if (gripSides.has(side)) continue;
159+
for (const f of FINGERS) {
160+
const id = `${f}_${side}`;
161+
if (authoredFingers.has(id)) continue;
162+
const bone = m.bones.get(id);
163+
if (bone) {
164+
bone.rotation.set(REST_CURL, 0, 0);
165+
changed = true;
166+
}
167+
}
168+
const thumbId = `thumb_${side}`;
169+
if (!authoredFingers.has(thumbId)) {
170+
const thumb = m.bones.get(thumbId);
171+
if (thumb) {
172+
thumb.rotation.set(REST_CURL * 0.6, 0, side === "left" ? -REST_CURL : REST_CURL);
173+
changed = true;
174+
}
175+
}
176+
}
177+
if (changed) m.root.updateMatrixWorld(true);
178+
}

packages/posecode-render/src/index.ts

Lines changed: 23 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 } from "./contacts.js";
30+
import { alignFloorPalms, levelPlantedFeet, wrapGrip, relaxHands } from "./contacts.js";
3131

3232
const DEG = Math.PI / 180;
3333

@@ -267,6 +267,9 @@ export function createViewer(
267267
}
268268

269269
let timeline: BuiltTimeline | null = null;
270+
// Finger bones the loaded document explicitly poses (make-a-fist, finger-spell,
271+
// hand-wave): the L4.1 resting-hand curl leaves these alone.
272+
let authoredFingers = new Set<string>();
270273
// The last loaded document, kept so the viewer can re-solve base pose and
271274
// ground anchors when the character (with its own proportions) arrives.
272275
let lastIR: PosecodeIR | null = null;
@@ -593,6 +596,8 @@ export function createViewer(
593596
// lower-body poses (squat, lunge, deadlift) don't balance on the toes.
594597
// Runs before the floor clamp so the leveled sole is what rests on y=0.
595598
levelPlantedFeet(mannequin, info.groundLock);
599+
// L4.1 aliveness: relax idle hands into a natural curl (grips still wrap).
600+
relaxHands(mannequin, gripSidesOf(info.grips), authoredFingers);
596601
// Safety net: nothing above ever intentionally pushes part of the body
597602
// below the floor, so clamp the root up whenever the lowest point dips
598603
// below y=0, a no-op whenever the pose is legitimately grounded or
@@ -691,6 +696,8 @@ export function createViewer(
691696
depenetrate(mannequin);
692697
groundFigureOf(mannequin);
693698
levelPlantedFeet(mannequin, ir.phases[0]?.groundLock ?? []);
699+
authoredFingers = new Set(timeline.bonesUsed.filter(isFingerId));
700+
relaxHands(mannequin, gripSidesOf(ir.phases[0]?.grips ?? []), authoredFingers);
694701
captureGroundTargets();
695702
baseRootPos.copy(mannequin.root.position);
696703
baseRootQuat.copy(mannequin.root.quaternion);
@@ -797,6 +804,21 @@ export function createViewer(
797804
return api;
798805
}
799806

807+
/** True for a finger bone id (thumb/index/middle/ring/pinky_left|right). */
808+
function isFingerId(id: string): boolean {
809+
return /^(thumb|index|middle|ring|pinky)_/.test(id);
810+
}
811+
812+
/** The hand sides ("left"/"right") gripping this phase, from its grip targets. */
813+
function gripSidesOf(grips: readonly { effector: string }[]): Set<"left" | "right"> {
814+
const sides = new Set<"left" | "right">();
815+
for (const g of grips) {
816+
if (g.effector.endsWith("_left") || g.effector === "hands") sides.add("left");
817+
if (g.effector.endsWith("_right") || g.effector === "hands") sides.add("right");
818+
}
819+
return sides;
820+
}
821+
800822
function enableShadows(root: THREE.Object3D): void {
801823
root.traverse((obj) => {
802824
if ((obj as THREE.Mesh).isMesh) {

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

Lines changed: 25 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 } from "../src/contacts.js";
4+
import { levelPlantedFeet, relaxHands } from "../src/contacts.js";
55
import { groundFigure } from "../src/groundlock.js";
66

77
const DEG = Math.PI / 180;
@@ -51,3 +51,27 @@ describe("levelPlantedFeet", () => {
5151
expect(m.bones.get("ankle_left")!.quaternion.angleTo(before)).toBeLessThan(1e-3);
5252
});
5353
});
54+
55+
describe("relaxHands (L4.1)", () => {
56+
it("curls the fingers of an idle, un-authored hand into a natural rest", () => {
57+
const m = buildMannequin();
58+
expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(0, 5); // flat at rest
59+
relaxHands(m, new Set(), new Set());
60+
expect(m.bones.get("index_left")!.rotation.x).toBeGreaterThan(0.1);
61+
expect(m.bones.get("middle_right")!.rotation.x).toBeGreaterThan(0.1);
62+
});
63+
64+
it("leaves a gripping hand for wrapGrip (skips grip sides)", () => {
65+
const m = buildMannequin();
66+
relaxHands(m, new Set(["left"]), new Set());
67+
expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(0, 5); // untouched
68+
expect(m.bones.get("index_right")!.rotation.x).toBeGreaterThan(0.1); // right relaxed
69+
});
70+
71+
it("does not override an explicitly authored finger", () => {
72+
const m = buildMannequin();
73+
m.bones.get("index_left")!.rotation.x = 1.4; // authored fist
74+
relaxHands(m, new Set(), new Set(["index_left"]));
75+
expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(1.4, 5);
76+
});
77+
});

0 commit comments

Comments
 (0)