Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/posecode-eval/src/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ export function genericChecks(result: ProbeResult): CheckOutcome[] {
detail: `lowest bone ${lowestPoint(p).toFixed(3)}m (want > -0.05)`,
});

// A ground-locked phase declares its effectors planted, so the visible mesh
// must actually rest on the floor — not hover above it. Guards the
// levitating-squat/deadlift regression where levelPlantedFeet lifted the
// sole after ground-lock and an up-only clamp left the whole figure floating.
if (p.groundLock.length > 0) {
out.push({
id: `grounded-not-floating:${p.name}`,
pass: p.meshMinY < 0.02,
detail: `mesh floats ${p.meshMinY.toFixed(3)}m above floor (want < 0.020)`,
});
}

const floorHands = new Set<string>();
for (const r of p.reaches) {
if (r.target !== "floor") continue;
Expand Down
19 changes: 17 additions & 2 deletions packages/posecode-eval/src/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
buildTimeline,
depenetrate,
groundFigure,
levelPlantedFeet,
} from "posecode-render";

export type Vec3 = readonly [x: number, y: number, z: number];
Expand All @@ -40,6 +41,12 @@ export interface PhasePose {
rootYaw: number;
/** True when the phase relies on pins/reach-IK the probe cannot solve. */
usesSceneIk: boolean;
/**
* Height of the lowest visible-mesh point above the floor after the full
* contact solve. ~0 for a grounded pose; a positive value means the figure
* floats (the bug that levelPlantedFeet used to cause on squat/deadlift).
*/
meshMinY: number;
/** World-space position of every bone at the END of this phase. */
bones: ReadonlyMap<string, Vec3>;
/** World-space orientation of every bone at the end of the phase. */
Expand Down Expand Up @@ -160,13 +167,20 @@ export function probeMovement(source: string): ProbeResult {
}
}
alignFloorPalms(m, info.reaches, info.pins);
// Viewer safety net: never leave the lowest mesh point below the floor.
// Plantigrade correction (viewer parity): flatten planted soles. This lifts
// the foot mesh a little, so it must run BEFORE the floor clamp reconciles.
levelPlantedFeet(m, info.groundLock);
// Viewer safety net: a ground-locked phase is planted, so clamp both ways
// (its lowest point sits exactly on the floor); an unlocked phase may be
// airborne, so only rescue parts that dip below y=0. Mirror index.ts.
m.root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(m.root);
if (box.min.y < 0) {
const planted = info.groundLock.length > 0;
if (box.min.y < 0 || (planted && box.min.y > 0)) {
m.root.position.y -= box.min.y;
m.root.updateMatrixWorld(true);
}
const finalBox = new THREE.Box3().setFromObject(m.root);
return {
name: seg.name,
durationSec: authored.durationSec,
Expand All @@ -177,6 +191,7 @@ export function probeMovement(source: string): ProbeResult {
rootOffset: [info.rootOffset.x, 0, info.rootOffset.z],
rootYaw: info.rootYaw,
usesSceneIk: info.pins.length > 0 || info.reaches.length > 0 || info.grips.length > 0,
meshMinY: Number.isFinite(finalBox.min.y) ? finalBox.min.y : 0,
bones: snapshotBones(m.bones),
boneQuaternions: snapshotBoneQuaternions(m.bones),
};
Expand Down
48 changes: 39 additions & 9 deletions packages/posecode-render/src/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,38 +138,68 @@ export function wrapGrip(m: Mannequin, grips: readonly GripTarget[]): void {
if (changed) m.root.updateMatrixWorld(true);
}

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

/**
* Give idle hands a natural relaxed curl instead of a flat splayed palm. Applied
* every frame to any hand that is NOT gripping this phase (those are wrapped by
* `wrapGrip`) and whose fingers are NOT explicitly authored (make-a-fist,
* finger-spell, hand-wave keep their pose). A mesh-only-style aliveness layer:
* it writes only finger-bone locals, so it can never disturb the solved pose.
* Give idle hands a natural relaxed shape instead of a flat splayed palm.
* Applied every frame to any hand that is NOT gripping this phase (those are
* wrapped by `wrapGrip`) and whose fingers are NOT explicitly authored
* (make-a-fist, finger-spell, hand-wave keep their pose).
*
* Two resting shapes by context:
* - **Free hand** (arms swinging, a crunch, hands by the hips): a soft inward
* hook with the fingers drawn slightly together — a relaxed cupped hand.
* - **Floor-planted hand** (`reach`/`pin: hands floor`): fingers stay extended
* and flat so the palm rests on the ground instead of clawing into it.
*
* A mesh-only-style aliveness layer: it writes only finger-bone locals, so it
* can never disturb the solved pose.
*/
export function relaxHands(
m: Mannequin,
gripSides: ReadonlySet<"left" | "right">,
authoredFingers: ReadonlySet<string>,
floorSides: ReadonlySet<"left" | "right"> = new Set(),
): void {
let changed = false;
for (const side of ["left", "right"] as const) {
if (gripSides.has(side)) continue;
const planted = floorSides.has(side);
const curl = planted ? FLOOR_CURL : REST_CURL;
// Adduction sign: fingers on each hand draw toward the middle, i.e. toward
// the thumb side, which is +Z on the left hand and -Z on the right.
const adduct = planted ? 0 : side === "left" ? REST_ADDUCT : -REST_ADDUCT;
for (const f of FINGERS) {
const id = `${f}_${side}`;
if (authoredFingers.has(id)) continue;
const bone = m.bones.get(id);
if (bone) {
bone.rotation.set(REST_CURL, 0, 0);
bone.rotation.set(curl, 0, adduct);
changed = true;
}
}
const thumbId = `thumb_${side}`;
if (!authoredFingers.has(thumbId)) {
const thumb = m.bones.get(thumbId);
if (thumb) {
thumb.rotation.set(REST_CURL * 0.6, 0, side === "left" ? -REST_CURL : REST_CURL);
// Planted: thumb lies alongside the flat palm. Free: opposes softly.
const thumbCurl = planted ? FLOOR_CURL : REST_CURL * 0.6;
const thumbOppose = planted ? 0 : REST_CURL;
thumb.rotation.set(thumbCurl, 0, side === "left" ? -thumbOppose : thumbOppose);
changed = true;
}
}
Expand Down
62 changes: 49 additions & 13 deletions packages/posecode-render/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,21 +648,33 @@ export function createViewer(
// L4.2 aliveness: contralateral arm swing during locomotion (free arms only).
swingArms(mannequin, authoredShoulders, gripSidesOf(info.grips));
// L4.1 aliveness: relax idle hands into a natural curl (grips still wrap).
relaxHands(mannequin, gripSidesOf(info.grips), authoredFingers);
relaxHands(
mannequin,
gripSidesOf(info.grips),
authoredFingers,
floorHandSidesOf(info.reaches, info.pins),
);
// L4.3 aliveness: turn the head toward the active contact (bar / floor reach).
applyLookAt(info);
// Safety net: nothing above ever intentionally pushes part of the body
// below the floor, so clamp the root up whenever the lowest point dips
// below y=0, a no-op whenever the pose is legitimately grounded or
// elevated (bbox min already ≥ 0). This also catches phases with
// neither ground-lock nor a pin (the root stays frozen at the base
// pose's grounded height while FK animates freely on top of it, e.g. a
// prone "superman" lift), and pinned phases where a fixed-height anchor
// (a low chair seat) combined with static leg FK can otherwise let the
// feet sink through the floor as the arms fold (e.g. a chair dip).
// Safety net: reconcile the fully-solved pose with the floor.
//
// A ground-locked phase asserts its effectors (feet, and for a plank the
// forearms) are PLANTED, so its lowest mesh point must sit exactly on the
// floor — clamp the root BOTH ways. This is essential because
// levelPlantedFeet() rotates the ankle flat AFTER ground-lock dropped the
// body, which lifts the sole a couple centimetres; an up-only clamp could
// never recover it and the whole figure floated (squat, deadlift,
// good-morning, forward-fold, plank, …).
//
// A phase with NO ground-lock may be intentionally airborne (a prone
// "superman" lift, a jump), so it stays up-only: never yank a lifted body
// down, only rescue parts that dip below y=0. Pinned phases with a
// fixed-height anchor (a low chair seat) also rely on this up-only rescue
// as the legs fold.
mannequin.root.updateMatrixWorld(true);
const box = new THREE.Box3().setFromObject(mannequin.root);
if (box.min.y < 0) {
const planted = info.groundLock.length > 0;
if (box.min.y < 0 || (planted && box.min.y > 0)) {
mannequin.root.position.y -= box.min.y;
mannequin.root.updateMatrixWorld(true);
}
Expand Down Expand Up @@ -753,7 +765,12 @@ export function createViewer(
authoredShoulders = new Set(timeline.bonesUsed.filter((id) => id.startsWith("shoulder_")));
authoredHead = timeline.bonesUsed.some((id) => id === "head" || id === "neck");
swingArms(mannequin, authoredShoulders, gripSidesOf(ir.phases[0]?.grips ?? []));
relaxHands(mannequin, gripSidesOf(ir.phases[0]?.grips ?? []), authoredFingers);
relaxHands(
mannequin,
gripSidesOf(ir.phases[0]?.grips ?? []),
authoredFingers,
floorHandSidesOf(ir.phases[0]?.reaches ?? [], ir.phases[0]?.pins ?? []),
);
applyLookAt({ grips: ir.phases[0]?.grips ?? [], reaches: ir.phases[0]?.reaches ?? [] });
captureGroundTargets();
baseRootPos.copy(mannequin.root.position);
Expand Down Expand Up @@ -887,6 +904,25 @@ function gripSidesOf(grips: readonly { effector: string }[]): Set<"left" | "righ
return sides;
}

/**
* Hand sides pressed onto the floor this phase (a `reach`/`pin: hands floor`).
* Their fingers rest flat instead of taking the idle inward hook, so a plank or
* push-up hand lies on the ground rather than clawing into it.
*/
function floorHandSidesOf(
reaches: readonly { effector: string; target: string }[],
pins: readonly { effector: string; anchor: string }[],
): Set<"left" | "right"> {
const sides = new Set<"left" | "right">();
const add = (effector: string): void => {
if (effector.endsWith("_left") || effector === "hands") sides.add("left");
if (effector.endsWith("_right") || effector === "hands") sides.add("right");
};
for (const r of reaches) if (r.target === "floor") add(r.effector);
for (const p of pins) if (p.anchor === "floor") add(p.effector);
return sides;
}

function enableShadows(root: THREE.Object3D): void {
root.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) {
Expand Down Expand Up @@ -924,5 +960,5 @@ export {
type ClipSource,
} from "./clips.js";
export { depenetrate } from "./depenetrate.js";
export { alignFloorPalms } from "./contacts.js";
export { alignFloorPalms, levelPlantedFeet } from "./contacts.js";
export type { PhaseSegment } from "./timeline.js";
12 changes: 12 additions & 0 deletions packages/posecode-render/test/contacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ describe("relaxHands (L4.1)", () => {
relaxHands(m, new Set(), new Set(["index_left"]));
expect(m.bones.get("index_left")!.rotation.x).toBeCloseTo(1.4, 5);
});

it("keeps a floor-planted hand's fingers flat instead of clawing", () => {
const m = buildMannequin();
// A free hand takes the soft inward hook...
relaxHands(m, new Set(), new Set(), new Set());
const freeCurl = m.bones.get("index_left")!.rotation.x;
expect(freeCurl).toBeGreaterThan(0.3);
// ...but a hand pressed to the floor (plank/push-up) lies extended.
relaxHands(m, new Set(), new Set(), new Set(["left"]));
expect(m.bones.get("index_left")!.rotation.x).toBeLessThan(0.1); // flat
expect(m.bones.get("index_right")!.rotation.x).toBeGreaterThan(0.3); // right still hooked
});
});

describe("swingArms (L4.2)", () => {
Expand Down
Loading