@@ -58,6 +58,8 @@ export interface Viewer {
5858 /** True while a retargeted mocap clip is driving (or fading over) the pose. */
5959 get clipActive ( ) : boolean ;
6060 getTimeline ( ) : TimelineInfo | null ;
61+ /** Precise visible world bounds; intended for audits and deterministic export. */
62+ getVisibleBounds ( ) : THREE . Box3 ;
6163 /**
6264 * Render the current time synchronously and return the frame as a PNG data
6365 * URL. Works without preserveDrawingBuffer because the read happens in the
@@ -223,6 +225,10 @@ export function createViewer(
223225 const url = name ? opts . clips ?. [ name ] : undefined ;
224226 if ( ! name || ! url || ! character ?. skinnedMesh ) {
225227 clipTargetWeight = 0 ;
228+ clipWeight = 0 ;
229+ clipLayer ?. dispose ( ) ;
230+ clipLayer = null ;
231+ clipLayerName = null ;
226232 return ;
227233 }
228234 if ( clipLayerName === name && clipLayer ) {
@@ -607,8 +613,10 @@ export function createViewer(
607613 }
608614
609615 function frame ( ) : void {
616+ let solvedInfo : ReturnType < NonNullable < typeof timeline > [ "sample" ] > | null = null ;
610617 if ( timeline ) {
611618 const info = timeline . sample ( time , mannequin . bones ) ;
619+ solvedInfo = info ;
612620 // Life layer rides on wall-clock time (not timeline time) so the figure
613621 // keeps breathing and blinking while paused or scrubbing.
614622 applyLife ( performance . now ( ) / 1000 ) ;
@@ -667,15 +675,18 @@ export function createViewer(
667675 // never recover it and the whole figure floated (squat, deadlift,
668676 // good-morning, forward-fold, plank, …).
669677 //
670- // A phase with NO ground-lock may be intentionally airborne (a prone
671- // "superman" lift, a jump), so it stays up-only: never yank a lifted body
672- // down, only rescue parts that dip below y=0. Pinned phases with a
673- // fixed-height anchor (a low chair seat) also rely on this up-only rescue
674- // as the legs fold.
678+ // Explicit elevated support is the opt-out: bar grips and non-floor pins
679+ // (box/chair) preserve their solved height. Everything else remains
680+ // floor-bound; airborne choreography should use a future explicit flight
681+ // contact rather than arise accidentally from missing `ground-lock`.
675682 mannequin . root . updateMatrixWorld ( true ) ;
676683 const box = new THREE . Box3 ( ) . setFromObject ( mannequin . root ) ;
677- const planted = info . groundLock . length > 0 ;
678- if ( box . min . y < 0 || ( planted && box . min . y > 0 ) ) {
684+ // Unless an elevated prop/grip is carrying the body, the movement is
685+ // floor-bound even when the author omitted `ground-lock`. This prevents
686+ // ordinary curls, lunges, stretches, and transitions from inheriting a
687+ // floating root when their FK pose raises the previous lowest point.
688+ const floorBound = isFloorBound ( info ) ;
689+ if ( box . min . y < 0 || ( floorBound && box . min . y > 0 ) ) {
679690 mannequin . root . position . y -= box . min . y ;
680691 mannequin . root . updateMatrixWorld ( true ) ;
681692 }
@@ -694,8 +705,18 @@ export function createViewer(
694705 const gap = clipTargetWeight - clipWeight ;
695706 clipWeight += Math . sign ( gap ) * Math . min ( Math . abs ( gap ) , step ) ;
696707 clipLayer . apply ( time , clipWeight ) ;
697- if ( clipWeight > 0 ) character . group . updateMatrixWorld ( true ) ;
708+ if ( clipWeight > 0 ) {
709+ character . group . updateMatrixWorld ( true ) ;
710+ // Mocap is layered after procedural grounding and can add hip/root bob
711+ // that lifts a planted foot. Restore declared terminal contacts on the
712+ // visible character without removing motion from unconstrained limbs.
713+ if ( solvedInfo ) character . correctContacts ( mannequin , contactBoneIds ( solvedInfo ) ) ;
714+ }
698715 }
716+ // Final visible-surface grounding. The hidden driver uses calibrated proxy
717+ // geometry; a segmented skin can have a different lowest point as limbs
718+ // rotate. Reconcile the actual skinned surface after every animation layer.
719+ if ( character && solvedInfo && isFloorBound ( solvedInfo ) ) character . reconcileFloor ( ) ;
699720 frameDt = 0 ;
700721 if ( easeCamera ) {
701722 controls . target . lerp ( desiredTarget , 0.07 ) ;
@@ -824,6 +845,9 @@ export function createViewer(
824845 segments : timeline . segments ,
825846 } ;
826847 } ,
848+ getVisibleBounds ( ) {
849+ return character ?. getBounds ( ) ?? new THREE . Box3 ( ) . setFromObject ( mannequin . root ) ;
850+ } ,
827851 captureFrame ( ) {
828852 frame ( ) ;
829853 return renderer . domElement . toDataURL ( "image/png" ) ;
@@ -882,6 +906,40 @@ export function createViewer(
882906 return api ;
883907}
884908
909+ /** True unless a grip or non-floor pin intentionally suspends/supports the body. */
910+ function isFloorBound ( info : {
911+ grips : readonly unknown [ ] ;
912+ pins : readonly { anchor : string } [ ] ;
913+ } ) : boolean {
914+ return info . grips . length === 0 && ! info . pins . some ( ( pin ) => pin . anchor !== "floor" ) ;
915+ }
916+
917+ /** Driver terminal bones that must survive a mocap layer unchanged. */
918+ function contactBoneIds ( info : {
919+ groundLock : readonly string [ ] ;
920+ grips : readonly { effector : string } [ ] ;
921+ pins : readonly { effector : string } [ ] ;
922+ reaches : readonly { effector : string ; target : string } [ ] ;
923+ } ) : string [ ] {
924+ const ids = new Set < string > ( ) ;
925+ const addEffector = ( effector : string ) : void => {
926+ if ( effector === "feet" || effector === "foot_left" ) ids . add ( "ankle_left" ) ;
927+ if ( effector === "feet" || effector === "foot_right" ) ids . add ( "ankle_right" ) ;
928+ if ( effector === "hands" || effector === "hand_left" ) ids . add ( "wrist_left" ) ;
929+ if ( effector === "hands" || effector === "hand_right" ) ids . add ( "wrist_right" ) ;
930+ if ( effector === "forearms" ) {
931+ ids . add ( "elbow_left" ) ;
932+ ids . add ( "elbow_right" ) ;
933+ }
934+ } ;
935+ for ( const group of info . groundLock ) addEffector ( group ) ;
936+ for ( const contact of [ ...info . grips , ...info . pins ] ) addEffector ( contact . effector ) ;
937+ for ( const reach of info . reaches ) {
938+ if ( reach . target === "floor" ) addEffector ( reach . effector ) ;
939+ }
940+ return [ ...ids ] ;
941+ }
942+
885943/**
886944 * Show or hide a figure's meshes. The skeleton keeps driving animation and
887945 * bounding-box grounding regardless, so hiding only the meshes lets a hidden
0 commit comments