22 * Turn a PosecodeIR into a looping, eased keyframe timeline.
33 *
44 * Each phase is a keyframe: we accumulate joint overrides forward (a movement
5- * holds prior joint state unless a later phase changes it), then slerp bone
6- * quaternions between consecutive keyframes with the destination phase's easing.
7- * A final wrap segment returns to the base pose so the loop is seamless .
5+ * holds prior joint state unless a later phase changes it), then interpolate
6+ * the DSL's bounded anatomical Euler channels with monotone cubic Hermite
7+ * curves. A final wrap segment returns to the base pose only when necessary .
88 */
99
1010import * as THREE from "three" ;
1111import type { PosecodeIR , ReachTarget , PinTarget , GripTarget , TimingMode } from "posecode-parser" ;
1212import { poseFor , type PoseSpec } from "./poses.js" ;
13- import { squad , squadControl } from "./squad.js" ;
1413
1514const DEG = Math . PI / 180 ;
1615
@@ -23,15 +22,12 @@ interface Keyframe {
2322 easing : TimingMode ;
2423 /**
2524 * The figure is at rest here (zero boundary velocity), so the spline uses this
26- * keyframe's own value as its control (no velocity carried across it). True for
27- * settle/snap phases AND for the two structural anchors — the start pose and
28- * the loop-reset — which represent the figure standing still at the base pose.
29- * Those anchors have no real predecessor/successor, so deriving a squad tangent
30- * from a clamped neighbor yields a backward-biased control that overshoots
31- * (the "snap to fully-curled" biceps bug); a rest tangent slerps cleanly.
25+ * keyframe receives zero velocity. True for settle/snap phases AND for the
26+ * structural start/reset anchors, which represent the figure at rest.
3227 */
3328 rest : boolean ;
34- quats : Map < string , THREE . Quaternion > ;
29+ /** Authored semantic Euler channels, retained so interpolation follows the DSL. */
30+ eulers : Map < string , EulerDegTuple > ;
3531 groundLock : string [ ] ;
3632 reaches : ReachTarget [ ] ;
3733 pins : PinTarget [ ] ;
@@ -50,6 +46,11 @@ export interface PhaseSegment {
5046 cue ?: string ;
5147}
5248
49+ /** A reach constraint blended across a phase boundary. */
50+ export interface WeightedReachTarget extends ReachTarget {
51+ weight : number ;
52+ }
53+
5354export interface BuiltTimeline {
5455 duration : number ;
5556 repeat : number ;
@@ -65,7 +66,7 @@ export interface BuiltTimeline {
6566 phaseName : string ;
6667 cue ?: string ;
6768 groundLock : string [ ] ;
68- reaches : ReachTarget [ ] ;
69+ reaches : WeightedReachTarget [ ] ;
6970 pins : PinTarget [ ] ;
7071 grips : GripTarget [ ] ;
7172 /** Interpolated root facing (yaw about world Y, radians). */
@@ -133,6 +134,68 @@ function rootVelocity(
133134 return span > 1e-6 ? ( read ( next ) - read ( prev ) ) / span : 0 ;
134135}
135136
137+ /**
138+ * Shape-preserving velocity for an authored Euler channel at an interior
139+ * keyframe. Quaternion splines cannot distinguish a deliberate reversal from
140+ * continuing around the sphere: 0° → 160° → 0° was interpreted as a hidden
141+ * full rotation, holding near neutral before flipping through 180°. The DSL is
142+ * expressed as bounded anatomical Euler channels, so interpolate those scalar
143+ * channels directly and stop at reversals.
144+ */
145+ function jointVelocity (
146+ prev : Keyframe ,
147+ current : Keyframe ,
148+ next : Keyframe ,
149+ read : ( keyframe : Keyframe ) => number ,
150+ ) : number {
151+ if ( current . rest ) return 0 ;
152+ const beforeSpan = current . time - prev . time ;
153+ const afterSpan = next . time - current . time ;
154+ if ( beforeSpan <= 1e-6 || afterSpan <= 1e-6 ) return 0 ;
155+ const before = ( read ( current ) - read ( prev ) ) / beforeSpan ;
156+ const after = ( read ( next ) - read ( current ) ) / afterSpan ;
157+ // A plateau or direction change is a real anatomical turnaround.
158+ if ( before * after <= 0 ) return 0 ;
159+ const centered = ( read ( next ) - read ( prev ) ) / ( next . time - prev . time ) ;
160+ // Monotone Hermite cap: never let a tangent create an inter-keyframe
161+ // overshoot even when neighboring phase durations differ greatly.
162+ const limit = 3 * Math . min ( Math . abs ( before ) , Math . abs ( after ) ) ;
163+ return Math . sign ( centered ) * Math . min ( Math . abs ( centered ) , limit ) ;
164+ }
165+
166+ function posesEqual (
167+ a : Map < string , EulerDegTuple > ,
168+ b : Map < string , EulerDegTuple > ,
169+ ) : boolean {
170+ const bones = new Set ( [ ...a . keys ( ) , ...b . keys ( ) ] ) ;
171+ for ( const bone of bones ) {
172+ const av = a . get ( bone ) ?? [ 0 , 0 , 0 ] ;
173+ const bv = b . get ( bone ) ?? [ 0 , 0 , 0 ] ;
174+ if ( av . some ( ( value , axis ) => Math . abs ( value - bv [ axis ] ! ) > 1e-6 ) ) return false ;
175+ }
176+ return true ;
177+ }
178+
179+ function blendReaches (
180+ from : readonly ReachTarget [ ] ,
181+ to : readonly ReachTarget [ ] ,
182+ t : number ,
183+ ) : WeightedReachTarget [ ] {
184+ const key = ( reach : ReachTarget ) : string => `${ reach . effector } \u0000${ reach . target } ` ;
185+ const previous = new Map ( from . map ( ( reach ) => [ key ( reach ) , reach ] ) ) ;
186+ const next = new Map ( to . map ( ( reach ) => [ key ( reach ) , reach ] ) ) ;
187+ const blended : WeightedReachTarget [ ] = [ ] ;
188+ for ( const [ id , reach ] of previous ) {
189+ const weight = next . has ( id ) ? 1 : 1 - t ;
190+ if ( weight > 1e-6 ) blended . push ( { ...reach , weight } ) ;
191+ }
192+ for ( const [ id , reach ] of next ) {
193+ if ( previous . has ( id ) ) continue ;
194+ if ( t > 1e-6 ) blended . push ( { ...reach , weight : t } ) ;
195+ }
196+ return blended ;
197+ }
198+
136199export function buildTimeline ( ir : PosecodeIR ) : BuiltTimeline {
137200 const basePose = poseFor ( ir . startPose ) ;
138201 const baseJoints = new Map < string , EulerDegTuple > (
@@ -153,7 +216,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
153216 name : ir . startPose ?? "start" ,
154217 easing : "flow" ,
155218 rest : true ,
156- quats : snapshot ( curr ) ,
219+ eulers : snapshot ( curr ) ,
157220 groundLock : [ ] ,
158221 reaches : [ ] ,
159222 pins : [ ] ,
@@ -177,7 +240,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
177240 ...( phase . cue ? { cue : phase . cue } : { } ) ,
178241 easing : phase . easing ,
179242 rest : REST_MODE [ phase . easing ] ,
180- quats : snapshot ( curr ) ,
243+ eulers : snapshot ( curr ) ,
181244 groundLock : phase . groundLock ,
182245 reaches : phase . reaches ,
183246 pins : phase . pins ,
@@ -187,20 +250,34 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
187250 } ) ;
188251 }
189252
190- // Wrap back to the base pose (and home position) for a seamless loop. Facing
253+ // Wrap back to the base pose (and home position) for a seamless loop only
254+ // when the author did not already return there. Always adding a first-phase-
255+ // length reset made common out-and-back movements sit idle for roughly a
256+ // third of every repetition. A zero-duration structural reset still gives
257+ // the final real keyframe a rest neighbor without extending the loop.
258+ // Facing
191259 // wraps to the NEAREST FULL TURN to the final yaw, not to 0: a completed 360°
192260 // pirouette then holds its facing through the reset and the loop boundary
193261 // (360°≡0°) is seamless, instead of visibly un-spinning backward. A partial
194262 // turn (e.g. 90°) rounds to 0 and rotates back to front during the reset.
195- const wrap = ir . phases [ 0 ] ?. durationSec ?? 1 ;
263+ const finalPose = snapshot ( curr ) ;
264+ const baseSnapshot = snapshot ( new Map ( baseJoints ) ) ;
265+ const yawAtHome = Math . abs ( currYaw - Math . round ( currYaw / 360 ) * 360 ) < 1e-6 ;
266+ const positionAtHome = Math . abs ( currPos . x ) < 1e-6 && Math . abs ( currPos . z ) < 1e-6 ;
267+ const needsWrap = ! posesEqual ( finalPose , baseSnapshot ) || ! yawAtHome || ! positionAtHome ;
268+ const wrap = needsWrap ? ( ir . phases [ 0 ] ?. durationSec ?? 1 ) : 0 ;
269+ // With no pose wrap, the structural start is also the cyclic successor of
270+ // the final phase. Seed its reach state from that final phase so a constraint
271+ // shared across the boundary (e.g. cobra palms on the floor) stays planted.
272+ if ( ! needsWrap ) keyframes [ 0 ] ! . reaches = [ ...( ir . phases . at ( - 1 ) ?. reaches ?? [ ] ) ] ;
196273 const wrapYaw = Math . round ( currYaw / 360 ) * 360 * DEG ;
197274 t += wrap ;
198275 keyframes . push ( {
199276 time : t ,
200277 name : "reset" ,
201278 easing : "flow" ,
202279 rest : true ,
203- quats : snapshot ( new Map ( baseJoints ) ) ,
280+ eulers : baseSnapshot ,
204281 groundLock : [ ] ,
205282 reaches : [ ] ,
206283 pins : [ ] ,
@@ -209,11 +286,11 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
209286 pos : { x : 0 , z : 0 } ,
210287 } ) ;
211288
212- // Fill every keyframe with the full bone set (missing → identity ).
213- const bonesUsed = [ ...new Set ( keyframes . flatMap ( ( k ) => [ ...k . quats . keys ( ) ] ) ) ] ;
289+ // Fill every keyframe with the full bone set (missing → neutral Euler ).
290+ const bonesUsed = [ ...new Set ( keyframes . flatMap ( ( k ) => [ ...k . eulers . keys ( ) ] ) ) ] ;
214291 for ( const kf of keyframes ) {
215292 for ( const bone of bonesUsed ) {
216- if ( ! kf . quats . has ( bone ) ) kf . quats . set ( bone , new THREE . Quaternion ( ) ) ;
293+ if ( ! kf . eulers . has ( bone ) ) kf . eulers . set ( bone , [ 0 , 0 , 0 ] ) ;
217294 }
218295 }
219296
@@ -255,25 +332,22 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
255332 const local = THREE . MathUtils . clamp ( ( tt - a . time ) / span , 0 , 1 ) ;
256333 const eased = MODE_EASE [ b . easing ] ( local ) ;
257334
258- // Neighbors for the squad control quaternions (clamp at the ends → the
259- // segment endpoint itself, giving a one-sided tangent).
335+ // Neighbors for the time-aware semantic-channel tangents.
260336 const kPrev = keyframes [ Math . max ( 0 , i - 1 ) ] ! ;
261337 const kNext = keyframes [ Math . min ( keyframes . length - 1 , i + 2 ) ] ! ;
262338 for ( const bone of bonesUsed ) {
263339 const node = bones . get ( bone ) ;
264340 if ( ! node ) continue ;
265- const q0 = a . quats . get ( bone ) ! ;
266- const q1 = b . quats . get ( bone ) ! ;
267- // A rest-point keyframe uses its own value as the control (zero tangent
268- // → the spline comes to / leaves from rest there); otherwise the
269- // Shoemake control from the neighboring keyframe carries velocity.
270- const s0 = a . rest
271- ? q0 . clone ( )
272- : squadControl ( kPrev . quats . get ( bone ) ! , q0 , q1 ) ;
273- const s1 = b . rest
274- ? q1 . clone ( )
275- : squadControl ( q0 , q1 , kNext . quats . get ( bone ) ! ) ;
276- squad ( q0 , s0 , s1 , q1 , eased , node . quaternion ) ;
341+ const from = a . eulers . get ( bone ) ! ;
342+ const to = b . eulers . get ( bone ) ! ;
343+ const value = ( [ 0 , 1 , 2 ] as const ) . map ( ( axis ) => {
344+ if ( b . easing === "linear" ) return from [ axis ] + ( to [ axis ] - from [ axis ] ) * eased ;
345+ const read = ( kf : Keyframe ) : number => kf . eulers . get ( bone ) ! [ axis ] ;
346+ const va = jointVelocity ( kPrev , a , b , read ) ;
347+ const vb = jointVelocity ( a , b , kNext , read ) ;
348+ return hermite ( from [ axis ] , to [ axis ] , va , vb , span , eased ) ;
349+ } ) as EulerDegTuple ;
350+ node . quaternion . copy ( eulerToQuat ( value ) ) ;
277351 }
278352 // Root facing/position use the scalar analogue of the joint squad spline:
279353 // cubic Hermite with time-aware centered tangents. This carries velocity
@@ -306,7 +380,7 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
306380 phaseName : b . name ,
307381 ...( b . cue ? { cue : b . cue } : { } ) ,
308382 groundLock : b . groundLock ,
309- reaches : b . reaches ,
383+ reaches : blendReaches ( a . reaches , b . reaches , eased ) ,
310384 pins : b . pins ,
311385 grips : b . grips ,
312386 rootYaw,
@@ -316,9 +390,9 @@ export function buildTimeline(ir: PosecodeIR): BuiltTimeline {
316390 } ;
317391}
318392
319- function snapshot ( curr : Map < string , EulerDegTuple > ) : Map < string , THREE . Quaternion > {
320- const out = new Map < string , THREE . Quaternion > ( ) ;
321- for ( const [ bone , euler ] of curr ) out . set ( bone , eulerToQuat ( euler ) ) ;
393+ function snapshot ( curr : Map < string , EulerDegTuple > ) : Map < string , EulerDegTuple > {
394+ const out = new Map < string , EulerDegTuple > ( ) ;
395+ for ( const [ bone , euler ] of curr ) out . set ( bone , [ ... euler ] ) ;
322396
323397 // Hip-hinge coupling. The `pelvis` is the shared parent of both the torso and
324398 // the legs, so a pelvis X-rotation tips the WHOLE figure forward: torso and
@@ -330,7 +404,7 @@ function snapshot(curr: Map<string, EulerDegTuple>): Map<string, THREE.Quaternio
330404 if ( pelvisX !== 0 ) {
331405 for ( const hip of [ "hip_left" , "hip_right" ] ) {
332406 const [ hx , hy , hz ] = curr . get ( hip ) ?? [ 0 , 0 , 0 ] ;
333- out . set ( hip , eulerToQuat ( [ hx - pelvisX , hy , hz ] ) ) ;
407+ out . set ( hip , [ hx - pelvisX , hy , hz ] ) ;
334408 }
335409 }
336410 return out ;
0 commit comments