diff --git a/packages/posecode-eval/src/checks.ts b/packages/posecode-eval/src/checks.ts index 82698c6..bd22e4c 100644 --- a/packages/posecode-eval/src/checks.ts +++ b/packages/posecode-eval/src/checks.ts @@ -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(); for (const r of p.reaches) { if (r.target !== "floor") continue; diff --git a/packages/posecode-eval/src/probe.ts b/packages/posecode-eval/src/probe.ts index a31513d..c877fcb 100644 --- a/packages/posecode-eval/src/probe.ts +++ b/packages/posecode-eval/src/probe.ts @@ -22,6 +22,7 @@ import { buildTimeline, depenetrate, groundFigure, + levelPlantedFeet, } from "posecode-render"; export type Vec3 = readonly [x: number, y: number, z: number]; @@ -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; /** World-space orientation of every bone at the end of the phase. */ @@ -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, @@ -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), }; diff --git a/packages/posecode-render/src/contacts.ts b/packages/posecode-render/src/contacts.ts index 04c8f0c..8dd3e6b 100644 --- a/packages/posecode-render/src/contacts.ts +++ b/packages/posecode-render/src/contacts.ts @@ -138,30 +138,57 @@ 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, + 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; } } @@ -169,7 +196,10 @@ export function relaxHands( 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; } } diff --git a/packages/posecode-render/src/index.ts b/packages/posecode-render/src/index.ts index d6cd572..1c952a1 100644 --- a/packages/posecode-render/src/index.ts +++ b/packages/posecode-render/src/index.ts @@ -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); } @@ -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); @@ -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) { @@ -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"; diff --git a/packages/posecode-render/test/contacts.test.ts b/packages/posecode-render/test/contacts.test.ts index 6636290..338d39e 100644 --- a/packages/posecode-render/test/contacts.test.ts +++ b/packages/posecode-render/test/contacts.test.ts @@ -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)", () => {