@@ -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+
866902function 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" ;
902938export { depenetrate } from "./depenetrate.js" ;
903- export { alignFloorPalms } from "./contacts.js" ;
939+ export { alignFloorPalms , levelPlantedFeet } from "./contacts.js" ;
904940export type { PhaseSegment } from "./timeline.js" ;
0 commit comments