Skip to content

Commit 706171e

Browse files
committed
feat(render): enhance hand relaxation for floor contact and adjust finger curl behavior
1 parent 07fe312 commit 706171e

5 files changed

Lines changed: 129 additions & 24 deletions

File tree

packages/posecode-eval/src/checks.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@ export function genericChecks(result: ProbeResult): CheckOutcome[] {
9090
detail: `lowest bone ${lowestPoint(p).toFixed(3)}m (want > -0.05)`,
9191
});
9292

93+
// A ground-locked phase declares its effectors planted, so the visible mesh
94+
// must actually rest on the floor — not hover above it. Guards the
95+
// levitating-squat/deadlift regression where levelPlantedFeet lifted the
96+
// sole after ground-lock and an up-only clamp left the whole figure floating.
97+
if (p.groundLock.length > 0) {
98+
out.push({
99+
id: `grounded-not-floating:${p.name}`,
100+
pass: p.meshMinY < 0.02,
101+
detail: `mesh floats ${p.meshMinY.toFixed(3)}m above floor (want < 0.020)`,
102+
});
103+
}
104+
93105
const floorHands = new Set<string>();
94106
for (const r of p.reaches) {
95107
if (r.target !== "floor") continue;

packages/posecode-eval/src/probe.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
buildTimeline,
2323
depenetrate,
2424
groundFigure,
25+
levelPlantedFeet,
2526
} from "posecode-render";
2627

2728
export type Vec3 = readonly [x: number, y: number, z: number];
@@ -40,6 +41,12 @@ export interface PhasePose {
4041
rootYaw: number;
4142
/** True when the phase relies on pins/reach-IK the probe cannot solve. */
4243
usesSceneIk: boolean;
44+
/**
45+
* Height of the lowest visible-mesh point above the floor after the full
46+
* contact solve. ~0 for a grounded pose; a positive value means the figure
47+
* floats (the bug that levelPlantedFeet used to cause on squat/deadlift).
48+
*/
49+
meshMinY: number;
4350
/** World-space position of every bone at the END of this phase. */
4451
bones: ReadonlyMap<string, Vec3>;
4552
/** World-space orientation of every bone at the end of the phase. */
@@ -160,13 +167,20 @@ export function probeMovement(source: string): ProbeResult {
160167
}
161168
}
162169
alignFloorPalms(m, info.reaches, info.pins);
163-
// Viewer safety net: never leave the lowest mesh point below the floor.
170+
// Plantigrade correction (viewer parity): flatten planted soles. This lifts
171+
// the foot mesh a little, so it must run BEFORE the floor clamp reconciles.
172+
levelPlantedFeet(m, info.groundLock);
173+
// Viewer safety net: a ground-locked phase is planted, so clamp both ways
174+
// (its lowest point sits exactly on the floor); an unlocked phase may be
175+
// airborne, so only rescue parts that dip below y=0. Mirror index.ts.
164176
m.root.updateMatrixWorld(true);
165177
const box = new THREE.Box3().setFromObject(m.root);
166-
if (box.min.y < 0) {
178+
const planted = info.groundLock.length > 0;
179+
if (box.min.y < 0 || (planted && box.min.y > 0)) {
167180
m.root.position.y -= box.min.y;
168181
m.root.updateMatrixWorld(true);
169182
}
183+
const finalBox = new THREE.Box3().setFromObject(m.root);
170184
return {
171185
name: seg.name,
172186
durationSec: authored.durationSec,
@@ -177,6 +191,7 @@ export function probeMovement(source: string): ProbeResult {
177191
rootOffset: [info.rootOffset.x, 0, info.rootOffset.z],
178192
rootYaw: info.rootYaw,
179193
usesSceneIk: info.pins.length > 0 || info.reaches.length > 0 || info.grips.length > 0,
194+
meshMinY: Number.isFinite(finalBox.min.y) ? finalBox.min.y : 0,
180195
bones: snapshotBones(m.bones),
181196
boneQuaternions: snapshotBoneQuaternions(m.bones),
182197
};

packages/posecode-render/src/contacts.ts

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,38 +138,68 @@ export function wrapGrip(m: Mannequin, grips: readonly GripTarget[]): void {
138138
if (changed) m.root.updateMatrixWorld(true);
139139
}
140140

141-
/** Relaxed resting finger curl (radians) for an idle hand. */
142-
export const REST_CURL = 0.32;
141+
/**
142+
* Relaxed resting finger curl (radians) for an idle hand in the air. A truly
143+
* relaxed hand is not flat: the fingers settle into a soft inward hook (~30°),
144+
* which reads as a natural cupped hand instead of a stiff splayed palm.
145+
*/
146+
export const REST_CURL = 0.55;
147+
/** Slight finger adduction (radians) drawing splayed digits toward the middle
148+
* finger, so a relaxed hand closes softly rather than fanning like jazz-hands. */
149+
export const REST_ADDUCT = 0.12;
150+
/**
151+
* Near-flat curl (radians) for a hand pressed onto the floor (plank, push-up,
152+
* cobra). The palm lies flat with the fingers extended forward; the resting
153+
* inward hook would instead claw the fingertips into the ground.
154+
*/
155+
export const FLOOR_CURL = 0.06;
143156

144157
/**
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.
158+
* Give idle hands a natural relaxed shape instead of a flat splayed palm.
159+
* Applied every frame to any hand that is NOT gripping this phase (those are
160+
* wrapped by `wrapGrip`) and whose fingers are NOT explicitly authored
161+
* (make-a-fist, finger-spell, hand-wave keep their pose).
162+
*
163+
* Two resting shapes by context:
164+
* - **Free hand** (arms swinging, a crunch, hands by the hips): a soft inward
165+
* hook with the fingers drawn slightly together — a relaxed cupped hand.
166+
* - **Floor-planted hand** (`reach`/`pin: hands floor`): fingers stay extended
167+
* and flat so the palm rests on the ground instead of clawing into it.
168+
*
169+
* A mesh-only-style aliveness layer: it writes only finger-bone locals, so it
170+
* can never disturb the solved pose.
150171
*/
151172
export function relaxHands(
152173
m: Mannequin,
153174
gripSides: ReadonlySet<"left" | "right">,
154175
authoredFingers: ReadonlySet<string>,
176+
floorSides: ReadonlySet<"left" | "right"> = new Set(),
155177
): void {
156178
let changed = false;
157179
for (const side of ["left", "right"] as const) {
158180
if (gripSides.has(side)) continue;
181+
const planted = floorSides.has(side);
182+
const curl = planted ? FLOOR_CURL : REST_CURL;
183+
// Adduction sign: fingers on each hand draw toward the middle, i.e. toward
184+
// the thumb side, which is +Z on the left hand and -Z on the right.
185+
const adduct = planted ? 0 : side === "left" ? REST_ADDUCT : -REST_ADDUCT;
159186
for (const f of FINGERS) {
160187
const id = `${f}_${side}`;
161188
if (authoredFingers.has(id)) continue;
162189
const bone = m.bones.get(id);
163190
if (bone) {
164-
bone.rotation.set(REST_CURL, 0, 0);
191+
bone.rotation.set(curl, 0, adduct);
165192
changed = true;
166193
}
167194
}
168195
const thumbId = `thumb_${side}`;
169196
if (!authoredFingers.has(thumbId)) {
170197
const thumb = m.bones.get(thumbId);
171198
if (thumb) {
172-
thumb.rotation.set(REST_CURL * 0.6, 0, side === "left" ? -REST_CURL : REST_CURL);
199+
// Planted: thumb lies alongside the flat palm. Free: opposes softly.
200+
const thumbCurl = planted ? FLOOR_CURL : REST_CURL * 0.6;
201+
const thumbOppose = planted ? 0 : REST_CURL;
202+
thumb.rotation.set(thumbCurl, 0, side === "left" ? -thumbOppose : thumbOppose);
173203
changed = true;
174204
}
175205
}

packages/posecode-render/src/index.ts

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -635,21 +635,33 @@ export function createViewer(
635635
// L4.2 aliveness: contralateral arm swing during locomotion (free arms only).
636636
swingArms(mannequin, authoredShoulders, gripSidesOf(info.grips));
637637
// L4.1 aliveness: relax idle hands into a natural curl (grips still wrap).
638-
relaxHands(mannequin, gripSidesOf(info.grips), authoredFingers);
638+
relaxHands(
639+
mannequin,
640+
gripSidesOf(info.grips),
641+
authoredFingers,
642+
floorHandSidesOf(info.reaches, info.pins),
643+
);
639644
// L4.3 aliveness: turn the head toward the active contact (bar / floor reach).
640645
applyLookAt(info);
641-
// Safety net: nothing above ever intentionally pushes part of the body
642-
// below the floor, so clamp the root up whenever the lowest point dips
643-
// below y=0, a no-op whenever the pose is legitimately grounded or
644-
// elevated (bbox min already ≥ 0). This also catches phases with
645-
// neither ground-lock nor a pin (the root stays frozen at the base
646-
// pose's grounded height while FK animates freely on top of it, e.g. a
647-
// prone "superman" lift), and pinned phases where a fixed-height anchor
648-
// (a low chair seat) combined with static leg FK can otherwise let the
649-
// feet sink through the floor as the arms fold (e.g. a chair dip).
646+
// Safety net: reconcile the fully-solved pose with the floor.
647+
//
648+
// A ground-locked phase asserts its effectors (feet, and for a plank the
649+
// forearms) are PLANTED, so its lowest mesh point must sit exactly on the
650+
// floor — clamp the root BOTH ways. This is essential because
651+
// levelPlantedFeet() rotates the ankle flat AFTER ground-lock dropped the
652+
// body, which lifts the sole a couple centimetres; an up-only clamp could
653+
// never recover it and the whole figure floated (squat, deadlift,
654+
// good-morning, forward-fold, plank, …).
655+
//
656+
// A phase with NO ground-lock may be intentionally airborne (a prone
657+
// "superman" lift, a jump), so it stays up-only: never yank a lifted body
658+
// down, only rescue parts that dip below y=0. Pinned phases with a
659+
// fixed-height anchor (a low chair seat) also rely on this up-only rescue
660+
// as the legs fold.
650661
mannequin.root.updateMatrixWorld(true);
651662
const box = new THREE.Box3().setFromObject(mannequin.root);
652-
if (box.min.y < 0) {
663+
const planted = info.groundLock.length > 0;
664+
if (box.min.y < 0 || (planted && box.min.y > 0)) {
653665
mannequin.root.position.y -= box.min.y;
654666
mannequin.root.updateMatrixWorld(true);
655667
}
@@ -740,7 +752,12 @@ export function createViewer(
740752
authoredShoulders = new Set(timeline.bonesUsed.filter((id) => id.startsWith("shoulder_")));
741753
authoredHead = timeline.bonesUsed.some((id) => id === "head" || id === "neck");
742754
swingArms(mannequin, authoredShoulders, gripSidesOf(ir.phases[0]?.grips ?? []));
743-
relaxHands(mannequin, gripSidesOf(ir.phases[0]?.grips ?? []), authoredFingers);
755+
relaxHands(
756+
mannequin,
757+
gripSidesOf(ir.phases[0]?.grips ?? []),
758+
authoredFingers,
759+
floorHandSidesOf(ir.phases[0]?.reaches ?? [], ir.phases[0]?.pins ?? []),
760+
);
744761
applyLookAt({ grips: ir.phases[0]?.grips ?? [], reaches: ir.phases[0]?.reaches ?? [] });
745762
captureGroundTargets();
746763
baseRootPos.copy(mannequin.root.position);
@@ -863,6 +880,25 @@ function gripSidesOf(grips: readonly { effector: string }[]): Set<"left" | "righ
863880
return sides;
864881
}
865882

883+
/**
884+
* Hand sides pressed onto the floor this phase (a `reach`/`pin: hands floor`).
885+
* Their fingers rest flat instead of taking the idle inward hook, so a plank or
886+
* push-up hand lies on the ground rather than clawing into it.
887+
*/
888+
function floorHandSidesOf(
889+
reaches: readonly { effector: string; target: string }[],
890+
pins: readonly { effector: string; anchor: string }[],
891+
): Set<"left" | "right"> {
892+
const sides = new Set<"left" | "right">();
893+
const add = (effector: string): void => {
894+
if (effector.endsWith("_left") || effector === "hands") sides.add("left");
895+
if (effector.endsWith("_right") || effector === "hands") sides.add("right");
896+
};
897+
for (const r of reaches) if (r.target === "floor") add(r.effector);
898+
for (const p of pins) if (p.anchor === "floor") add(p.effector);
899+
return sides;
900+
}
901+
866902
function enableShadows(root: THREE.Object3D): void {
867903
root.traverse((obj) => {
868904
if ((obj as THREE.Mesh).isMesh) {
@@ -900,5 +936,5 @@ export {
900936
type ClipSource,
901937
} from "./clips.js";
902938
export { depenetrate } from "./depenetrate.js";
903-
export { alignFloorPalms } from "./contacts.js";
939+
export { alignFloorPalms, levelPlantedFeet } from "./contacts.js";
904940
export type { PhaseSegment } from "./timeline.js";

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ describe("relaxHands (L4.1)", () => {
7474
relaxHands(m, new Set(), new Set(["index_left"]));
7575
expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(1.4, 5);
7676
});
77+
78+
it("keeps a floor-planted hand's fingers flat instead of clawing", () => {
79+
const m = buildMannequin();
80+
// A free hand takes the soft inward hook...
81+
relaxHands(m, new Set(), new Set(), new Set());
82+
const freeCurl = m.bones.get("index_left")!.rotation.x;
83+
expect(freeCurl).toBeGreaterThan(0.3);
84+
// ...but a hand pressed to the floor (plank/push-up) lies extended.
85+
relaxHands(m, new Set(), new Set(), new Set(["left"]));
86+
expect(m.bones.get("index_left")!.rotation.x).toBeLessThan(0.1); // flat
87+
expect(m.bones.get("index_right")!.rotation.x).toBeGreaterThan(0.3); // right still hooked
88+
});
7789
});
7890

7991
describe("swingArms (L4.2)", () => {

0 commit comments

Comments
 (0)