@@ -145,30 +145,31 @@ export function createViewer(
145145 scene . add ( mannequin . root ) ;
146146
147147 // --- Life layer: breathing + blinking so the figure reads as alive even
148- // when the movement is paused. Breathing is a tiny additive sagittal
149- // rotation layered onto the sampled pose each frame; it can never drift
150- // because timeline.sample() rewrites those quaternions every frame.
148+ // when the movement is paused. Both are MESH-only effects. Breathing must
149+ // never rotate skeleton bones: an earlier version breathed via tiny
150+ // chest/spine rotations, but those ran before ground-lock/pin solving,
151+ // which translated the whole figure to re-plant the displaced hands/feet,
152+ // so every movement visibly swayed and the head bobbed. Swelling the
153+ // ribcage mesh cannot disturb any joint, so authored poses stay exact.
151154 const BREATH_PERIOD = 3.8 ; // seconds per breath cycle
152155 const BLINK_DURATION = 0.13 ;
153- const LIFE_AXIS = new THREE . Vector3 ( 1 , 0 , 0 ) ;
154- const LIFE_Q = new THREE . Quaternion ( ) ;
155156 const eyes = [ "eye_left" , "eye_right" ]
156157 . map ( ( n ) => mannequin . root . getObjectByName ( n ) )
157158 . filter ( ( o ) : o is THREE . Object3D => Boolean ( o ) ) ;
159+ const ribcage = mannequin . root . getObjectByName ( "ribcage" ) ;
160+ const ribcageRestScale = ribcage ? ribcage . scale . clone ( ) : null ;
158161 let nextBlink = performance . now ( ) / 1000 + 2 ;
159162
160- function breatheBone ( boneId : string , angle : number ) : void {
161- const bone = mannequin . bones . get ( boneId ) ;
162- if ( ! bone ) return ;
163- LIFE_Q . setFromAxisAngle ( LIFE_AXIS , angle ) ;
164- bone . quaternion . multiply ( LIFE_Q ) ;
165- }
166-
167163 function applyLife ( nowSec : number ) : void {
168- const breath = Math . sin ( ( nowSec * Math . PI * 2 ) / BREATH_PERIOD ) ;
169- breatheBone ( "chest" , breath * 0.022 ) ;
170- breatheBone ( "spine" , breath * 0.012 ) ;
171- breatheBone ( "neck" , breath * - 0.014 ) ; // counter-rotate: head stays level
164+ if ( ribcage && ribcageRestScale ) {
165+ // 0..1 inhale fraction; the chest swells mostly front-to-back.
166+ const breath = 0.5 + 0.5 * Math . sin ( ( nowSec * Math . PI * 2 ) / BREATH_PERIOD ) ;
167+ ribcage . scale . set (
168+ ribcageRestScale . x * ( 1 + breath * 0.015 ) ,
169+ ribcageRestScale . y * ( 1 + breath * 0.01 ) ,
170+ ribcageRestScale . z * ( 1 + breath * 0.05 ) ,
171+ ) ;
172+ }
172173 if ( nowSec >= nextBlink + BLINK_DURATION ) {
173174 nextBlink = nowSec + 2.5 + Math . random ( ) * 3 ;
174175 }
@@ -224,6 +225,7 @@ export function createViewer(
224225 // "Ground-lock" means HOLD the effector where the grounded base pose placed
225226 // it, not drag it to y=0. groundFigure() already set the floor contact.
226227 groundTargets = new Map ( ) ;
228+ frameAnchorMap . clear ( ) ; // drop anchors for effectors no longer captured
227229 for ( const ids of Object . values ( mannequin . effectors ) ) {
228230 for ( const id of ids ) {
229231 const node = mannequin . bones . get ( id ) ;
@@ -236,6 +238,30 @@ export function createViewer(
236238 const WORLD_Y = new THREE . Vector3 ( 0 , 1 , 0 ) ;
237239 const YAW_Q = new THREE . Quaternion ( ) ;
238240
241+ // Per-frame ground anchors: the captured load-time effector positions,
242+ // carried along by the phase's yaw/travel so horizontal foot planting
243+ // composes with choreography instead of fighting it. Values are mutated in
244+ // place each frame; the map is rebuilt on load (captureGroundTargets).
245+ const frameAnchorMap = new Map < string , THREE . Vector3 > ( ) ;
246+ function frameAnchors ( rootYaw : number , rootOffset : { x : number ; z : number } ) : Map < string , THREE . Vector3 > {
247+ for ( const [ id , captured ] of groundTargets ) {
248+ let v = frameAnchorMap . get ( id ) ;
249+ if ( ! v ) {
250+ v = new THREE . Vector3 ( ) ;
251+ frameAnchorMap . set ( id , v ) ;
252+ }
253+ v . copy ( captured ) ;
254+ if ( rootYaw !== 0 ) {
255+ // Yaw spins the body about the vertical axis through the root, so the
256+ // anchors must pivot with it (a quarter-turn carries the feet around).
257+ v . sub ( baseRootPos ) . applyAxisAngle ( WORLD_Y , rootYaw ) . add ( baseRootPos ) ;
258+ }
259+ v . x += rootOffset . x ;
260+ v . z += rootOffset . z ;
261+ }
262+ return frameAnchorMap ;
263+ }
264+
239265 // Friendly DSL effector aliases → the distal bone whose world position is
240266 // driven to the reach target.
241267 const EFFECTOR_BONE : Record < string , string > = {
@@ -406,7 +432,7 @@ export function createViewer(
406432 mannequin . root . position . x += info . rootOffset . x ;
407433 mannequin . root . position . z += info . rootOffset . z ;
408434 mannequin . root . updateMatrixWorld ( true ) ;
409- applyGroundLockTo ( mannequin , info . groundLock ) ;
435+ applyGroundLockTo ( mannequin , info . groundLock , frameAnchors ( info . rootYaw , info . rootOffset ) ) ;
410436 applyPins ( info . pins ) ;
411437 // Safety net: nothing above ever intentionally pushes part of the body
412438 // below the floor, so clamp the root up whenever the lowest point dips
0 commit comments