@@ -106,6 +106,33 @@ const REST_MODE: Record<TimingMode, boolean> = {
106106 linear : false ,
107107} ;
108108
109+ /** Cubic Hermite interpolation with endpoint velocities expressed per second. */
110+ function hermite ( a : number , b : number , va : number , vb : number , span : number , t : number ) : number {
111+ const t2 = t * t ;
112+ const t3 = t2 * t ;
113+ return (
114+ ( 2 * t3 - 3 * t2 + 1 ) * a +
115+ ( t3 - 2 * t2 + t ) * span * va +
116+ ( - 2 * t3 + 3 * t2 ) * b +
117+ ( t3 - t2 ) * span * vb
118+ ) ;
119+ }
120+
121+ /**
122+ * Time-aware centered velocity at an interior root keyframe. Rest keyframes
123+ * deliberately have zero velocity; flowing keyframes carry momentum through.
124+ */
125+ function rootVelocity (
126+ prev : Keyframe ,
127+ current : Keyframe ,
128+ next : Keyframe ,
129+ read : ( keyframe : Keyframe ) => number ,
130+ ) : number {
131+ if ( current . rest ) return 0 ;
132+ const span = next . time - prev . time ;
133+ return span > 1e-6 ? ( read ( next ) - read ( prev ) ) / span : 0 ;
134+ }
135+
109136export function buildTimeline ( ir : PosecodeIR ) : BuiltTimeline {
110137 const basePose = poseFor ( ir . startPose ) ;
111138 const baseJoints = new Map < string , EulerDegTuple > (
@@ -248,14 +275,33 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
248275 : squadControl ( q0 , q1 , kNext . quats . get ( bone ) ! ) ;
249276 squad ( q0 , s0 , s1 , q1 , eased , node . quaternion ) ;
250277 }
251- // Root facing/position: linear interpolation of the raw values so a large
252- // turn (e.g. 360°) sweeps the whole way round rather than taking a short
253- // arc. Uses the same eased param as the joints so everything moves as one.
254- const rootYaw = a . yaw + ( b . yaw - a . yaw ) * eased ;
255- const rootOffset = {
256- x : a . pos . x + ( b . pos . x - a . pos . x ) * eased ,
257- z : a . pos . z + ( b . pos . z - a . pos . z ) * eased ,
258- } ;
278+ // Root facing/position use the scalar analogue of the joint squad spline:
279+ // cubic Hermite with time-aware centered tangents. This carries velocity
280+ // through `flow` waypoints instead of hitting every travel point with a
281+ // visible direction/speed kink (box steps, grapevines, waltz, chassé).
282+ // Yaw remains a raw scalar rather than a quaternion so a 360° turn still
283+ // sweeps the full revolution. Rest points receive zero tangent.
284+ const yawA = rootVelocity ( kPrev , a , b , ( kf ) => kf . yaw ) ;
285+ const yawB = rootVelocity ( a , b , kNext , ( kf ) => kf . yaw ) ;
286+ const xA = rootVelocity ( kPrev , a , b , ( kf ) => kf . pos . x ) ;
287+ const xB = rootVelocity ( a , b , kNext , ( kf ) => kf . pos . x ) ;
288+ const zA = rootVelocity ( kPrev , a , b , ( kf ) => kf . pos . z ) ;
289+ const zB = rootVelocity ( a , b , kNext , ( kf ) => kf . pos . z ) ;
290+ // `linear` is an explicit authoring promise, so preserve a literal
291+ // straight interpolation for that mode. The spline applies to the
292+ // expressive timing modes, especially continuous `flow` choreography.
293+ const rootYaw = b . easing === "linear"
294+ ? a . yaw + ( b . yaw - a . yaw ) * eased
295+ : hermite ( a . yaw , b . yaw , yawA , yawB , span , eased ) ;
296+ const rootOffset = b . easing === "linear"
297+ ? {
298+ x : a . pos . x + ( b . pos . x - a . pos . x ) * eased ,
299+ z : a . pos . z + ( b . pos . z - a . pos . z ) * eased ,
300+ }
301+ : {
302+ x : hermite ( a . pos . x , b . pos . x , xA , xB , span , eased ) ,
303+ z : hermite ( a . pos . z , b . pos . z , zA , zB , span , eased ) ,
304+ } ;
259305 return {
260306 phaseName : b . name ,
261307 ...( b . cue ? { cue : b . cue } : { } ) ,
0 commit comments