@@ -648,21 +648,33 @@ export function createViewer(
648648 // L4.2 aliveness: contralateral arm swing during locomotion (free arms only).
649649 swingArms ( mannequin , authoredShoulders , gripSidesOf ( info . grips ) ) ;
650650 // L4.1 aliveness: relax idle hands into a natural curl (grips still wrap).
651- relaxHands ( mannequin , gripSidesOf ( info . grips ) , authoredFingers ) ;
651+ relaxHands (
652+ mannequin ,
653+ gripSidesOf ( info . grips ) ,
654+ authoredFingers ,
655+ floorHandSidesOf ( info . reaches , info . pins ) ,
656+ ) ;
652657 // L4.3 aliveness: turn the head toward the active contact (bar / floor reach).
653658 applyLookAt ( info ) ;
654- // Safety net: nothing above ever intentionally pushes part of the body
655- // below the floor, so clamp the root up whenever the lowest point dips
656- // below y=0, a no-op whenever the pose is legitimately grounded or
657- // elevated (bbox min already ≥ 0). This also catches phases with
658- // neither ground-lock nor a pin (the root stays frozen at the base
659- // pose's grounded height while FK animates freely on top of it, e.g. a
660- // prone "superman" lift), and pinned phases where a fixed-height anchor
661- // (a low chair seat) combined with static leg FK can otherwise let the
662- // feet sink through the floor as the arms fold (e.g. a chair dip).
659+ // Safety net: reconcile the fully-solved pose with the floor.
660+ //
661+ // A ground-locked phase asserts its effectors (feet, and for a plank the
662+ // forearms) are PLANTED, so its lowest mesh point must sit exactly on the
663+ // floor — clamp the root BOTH ways. This is essential because
664+ // levelPlantedFeet() rotates the ankle flat AFTER ground-lock dropped the
665+ // body, which lifts the sole a couple centimetres; an up-only clamp could
666+ // never recover it and the whole figure floated (squat, deadlift,
667+ // good-morning, forward-fold, plank, …).
668+ //
669+ // A phase with NO ground-lock may be intentionally airborne (a prone
670+ // "superman" lift, a jump), so it stays up-only: never yank a lifted body
671+ // down, only rescue parts that dip below y=0. Pinned phases with a
672+ // fixed-height anchor (a low chair seat) also rely on this up-only rescue
673+ // as the legs fold.
663674 mannequin . root . updateMatrixWorld ( true ) ;
664675 const box = new THREE . Box3 ( ) . setFromObject ( mannequin . root ) ;
665- if ( box . min . y < 0 ) {
676+ const planted = info . groundLock . length > 0 ;
677+ if ( box . min . y < 0 || ( planted && box . min . y > 0 ) ) {
666678 mannequin . root . position . y -= box . min . y ;
667679 mannequin . root . updateMatrixWorld ( true ) ;
668680 }
@@ -753,7 +765,12 @@ export function createViewer(
753765 authoredShoulders = new Set ( timeline . bonesUsed . filter ( ( id ) => id . startsWith ( "shoulder_" ) ) ) ;
754766 authoredHead = timeline . bonesUsed . some ( ( id ) => id === "head" || id === "neck" ) ;
755767 swingArms ( mannequin , authoredShoulders , gripSidesOf ( ir . phases [ 0 ] ?. grips ?? [ ] ) ) ;
756- relaxHands ( mannequin , gripSidesOf ( ir . phases [ 0 ] ?. grips ?? [ ] ) , authoredFingers ) ;
768+ relaxHands (
769+ mannequin ,
770+ gripSidesOf ( ir . phases [ 0 ] ?. grips ?? [ ] ) ,
771+ authoredFingers ,
772+ floorHandSidesOf ( ir . phases [ 0 ] ?. reaches ?? [ ] , ir . phases [ 0 ] ?. pins ?? [ ] ) ,
773+ ) ;
757774 applyLookAt ( { grips : ir . phases [ 0 ] ?. grips ?? [ ] , reaches : ir . phases [ 0 ] ?. reaches ?? [ ] } ) ;
758775 captureGroundTargets ( ) ;
759776 baseRootPos . copy ( mannequin . root . position ) ;
@@ -887,6 +904,25 @@ function gripSidesOf(grips: readonly { effector: string }[]): Set<"left" | "righ
887904 return sides ;
888905}
889906
907+ /**
908+ * Hand sides pressed onto the floor this phase (a `reach`/`pin: hands floor`).
909+ * Their fingers rest flat instead of taking the idle inward hook, so a plank or
910+ * push-up hand lies on the ground rather than clawing into it.
911+ */
912+ function floorHandSidesOf (
913+ reaches : readonly { effector : string ; target : string } [ ] ,
914+ pins : readonly { effector : string ; anchor : string } [ ] ,
915+ ) : Set < "left" | "right" > {
916+ const sides = new Set < "left" | "right" > ( ) ;
917+ const add = ( effector : string ) : void => {
918+ if ( effector . endsWith ( "_left" ) || effector === "hands" ) sides . add ( "left" ) ;
919+ if ( effector . endsWith ( "_right" ) || effector === "hands" ) sides . add ( "right" ) ;
920+ } ;
921+ for ( const r of reaches ) if ( r . target === "floor" ) add ( r . effector ) ;
922+ for ( const p of pins ) if ( p . anchor === "floor" ) add ( p . effector ) ;
923+ return sides ;
924+ }
925+
890926function enableShadows ( root : THREE . Object3D ) : void {
891927 root . traverse ( ( obj ) => {
892928 if ( ( obj as THREE . Mesh ) . isMesh ) {
@@ -924,5 +960,5 @@ export {
924960 type ClipSource ,
925961} from "./clips.js" ;
926962export { depenetrate } from "./depenetrate.js" ;
927- export { alignFloorPalms } from "./contacts.js" ;
963+ export { alignFloorPalms , levelPlantedFeet } from "./contacts.js" ;
928964export type { PhaseSegment } from "./timeline.js" ;
0 commit comments