Skip to main content

cranpose_liquid/
dynamics.rs

1//! Water-droplet motion physics shared by every travelling liquid lens.
2//!
3//! The reference lens (example/iphone17_records, example/target/tab-swipe)
4//! deforms with its motion, not with its position: cruising speed stretches
5//! the bubble along the travel axis, acceleration compresses it against the
6//! push, deceleration releases that compression past neutral and swells the
7//! leading edge — and whatever one axis does, the orthogonal axis does in
8//! reverse so the droplet keeps its area (an incompressible bubble).
9//!
10//! [`LiquidDynamics`] is the one frame integrator implementing that law.
11//! Widgets feed it the lens ride position once per drawn frame (from inside
12//! their `glass_effect_with` closure) and map the returned [`LiquidPose`]
13//! onto the morph uniforms. Time comes from the runtime's animation clock —
14//! never wall time — so poses stay exact under robot keyframe captures and
15//! on wasm.
16
17use std::{cell::Cell, rc::Rc};
18
19use cranpose_core::{RuntimeHandle, with_current_composer};
20use cranpose_macros::composable;
21
22use crate::material::GlassDeformation;
23
24/// Stretch gained per dp/s of travel speed. The target redistributes volume
25/// visibly but remains a landscape lens through launch and cruise.
26const STRETCH_PER_SPEED: f32 = 3.2e-4;
27/// Stretch removed per dp/s² of forward acceleration (and added back per
28/// dp/s² of braking): launch blunts the bubble, arrival elongates it.
29const STRETCH_PER_ACCEL: f32 = 3.5e-5;
30/// The droplet never deforms past these bounds, however violent the fling.
31/// Public so hosting nodes can budget layout headroom for the extremes.
32pub const STRETCH_MIN: f32 = 0.78;
33pub const STRETCH_MAX: f32 = 1.50;
34/// Perimeter displacement per dp/s² of acceleration. Launch inertia trails
35/// opposite the acceleration; braking inertia runs ahead of the bubble.
36const BULGE_PER_ACCELERATION: f32 = 4.5e-4;
37pub const BULGE_MAX: f32 = 8.0;
38/// Low-pass time constants (s): the fluid's visual inertia is asymmetric —
39/// excitation grabs the droplet fast, but it relaxes back viscously (the
40/// reference arrival swell stays visible for a beat before rounding off).
41const ATTACK_TAU: f32 = 0.03;
42const RELEASE_TAU: f32 = 0.11;
43/// Direct input arrives independently from rendering. Preserve the last
44/// observed finger velocity across short gaps instead of interpreting every
45/// intervening render as an instantaneous stop.
46const POINTER_VELOCITY_TAU: f32 = 0.045;
47const POINTER_STOP_HORIZON_NANOS: u64 = 40_000_000;
48const POINTER_COAST_TAU: f32 = 0.10;
49/// Below this speed (dp/s) the motion axis holds its last direction, so a
50/// settling bubble relaxes in place instead of flipping its axis on noise.
51const AXIS_MIN_SPEED: f32 = 60.0;
52/// Frame deltas are clamped here (s): a paused scene must not integrate one
53/// giant step, and a double-pumped frame must not divide by ~zero.
54const DT_MIN: f32 = 1.0 / 1000.0;
55const DT_MAX: f32 = 1.0 / 15.0;
56/// A position step implying more than this speed (dp/s) is a teleport
57/// (window relayout, snap), not motion: state re-anchors without energy.
58const TELEPORT_SPEED: f32 = 30_000.0;
59
60/// Motion-derived deformation of a liquid lens for one frame.
61#[derive(Clone, Copy, Debug, PartialEq)]
62pub struct LiquidPose {
63    /// Scale along the motion axis (>1 = elongated, <1 = compressed).
64    pub stretch: f32,
65    /// Scale across the motion axis; always `1/stretch` (area conserved).
66    pub ortho: f32,
67    /// Unit motion direction (math convention, +x right / +y up in caller
68    /// space — callers feed whatever space they draw in).
69    pub axis: (f32, f32),
70    /// Leading-edge swell amplitude (dp) toward `bulge_direction` (radians).
71    pub bulge_amplitude: f32,
72    pub bulge_direction: f32,
73    /// Smoothed travel speed (dp/s) for auxiliary channels (surface depth,
74    /// wobble, chromatic fringe).
75    pub speed: f32,
76}
77
78impl Default for LiquidPose {
79    fn default() -> Self {
80        Self {
81            stretch: 1.0,
82            ortho: 1.0,
83            axis: (1.0, 0.0),
84            bulge_amplitude: 0.0,
85            bulge_direction: 0.0,
86            speed: 0.0,
87        }
88    }
89}
90
91impl LiquidPose {
92    /// The full rotating strain tensor for the shader. Applying this to the
93    /// signed-distance field preserves area for horizontal, vertical and
94    /// diagonal travel; projecting it into an axis-aligned width/height pair
95    /// cannot preserve that invariant away from the cardinal axes.
96    pub fn deformation(&self) -> GlassDeformation {
97        GlassDeformation::incompressible(self.axis, self.stretch)
98    }
99
100    /// Normalized motion energy in 0..1 (`speed` against a reference fling
101    /// of ~1100 dp/s) — the shared ramp for speed-driven side channels.
102    pub fn energy(&self) -> f32 {
103        (self.speed / 1100.0).clamp(0.0, 1.0)
104    }
105}
106
107/// Per-lens frame integrator. Interior-mutable so per-frame draw closures
108/// (plain `Fn`) can advance it without a `RefCell` dance.
109pub struct LiquidDynamics {
110    runtime: RuntimeHandle,
111    last_nanos: Cell<Option<u64>>,
112    last_pos: Cell<Option<(f32, f32)>>,
113    velocity: Cell<(f32, f32)>,
114    stretch: Cell<f32>,
115    bulge_vector: Cell<(f32, f32)>,
116    speed: Cell<f32>,
117    axis: Cell<(f32, f32)>,
118    pose: Cell<LiquidPose>,
119    pointer_pose_pending: Cell<bool>,
120    pointer_active: Cell<bool>,
121    last_pointer_nanos: Cell<Option<u64>>,
122}
123
124impl LiquidDynamics {
125    pub fn new(runtime: RuntimeHandle) -> Self {
126        Self {
127            runtime,
128            last_nanos: Cell::new(None),
129            last_pos: Cell::new(None),
130            velocity: Cell::new((0.0, 0.0)),
131            stretch: Cell::new(1.0),
132            bulge_vector: Cell::new((0.0, 0.0)),
133            speed: Cell::new(0.0),
134            axis: Cell::new((1.0, 0.0)),
135            pose: Cell::new(LiquidPose::default()),
136            pointer_pose_pending: Cell::new(false),
137            pointer_active: Cell::new(false),
138            last_pointer_nanos: Cell::new(None),
139        }
140    }
141
142    /// Forget all motion state (fresh grab, teleporting relayout): the next
143    /// update re-anchors at rest.
144    pub fn reset(&self) {
145        self.last_nanos.set(None);
146        self.last_pos.set(None);
147        self.velocity.set((0.0, 0.0));
148        self.stretch.set(1.0);
149        self.bulge_vector.set((0.0, 0.0));
150        self.speed.set(0.0);
151        self.pose.set(LiquidPose {
152            axis: self.axis.get(),
153            ..LiquidPose::default()
154        });
155        self.pointer_pose_pending.set(false);
156        self.pointer_active.set(false);
157        self.last_pointer_nanos.set(None);
158    }
159
160    pub(crate) fn anchor_pointer(&self, pos: (f32, f32)) {
161        self.reset();
162        self.last_pos.set(Some(pos));
163        let now = self.runtime.last_frame_time_nanos();
164        self.last_nanos.set(now);
165        self.last_pointer_nanos.set(now);
166        self.pointer_active.set(true);
167    }
168
169    pub(crate) fn advance_pointer(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
170        let Some(last_pos) = self.last_pos.get() else {
171            self.last_pos.set(Some(pos));
172            return self.pose.get();
173        };
174        let dt = dt.clamp(DT_MIN, DT_MAX);
175        let raw_velocity = ((pos.0 - last_pos.0) / dt, (pos.1 - last_pos.1) / dt);
176        self.last_pos.set(Some(pos));
177        let previous = self.velocity.get();
178        let follow = 1.0 - (-dt / POINTER_VELOCITY_TAU).exp();
179        let filtered_velocity = (
180            previous.0 + (raw_velocity.0 - previous.0) * follow,
181            previous.1 + (raw_velocity.1 - previous.1) * follow,
182        );
183        let pose = self.advance_velocity(filtered_velocity, dt);
184        let now = self.runtime.last_frame_time_nanos();
185        self.last_nanos.set(now);
186        self.last_pointer_nanos.set(now);
187        self.pointer_active.set(true);
188        self.pointer_pose_pending.set(true);
189        pose
190    }
191
192    pub(crate) fn release_pointer(&self) {
193        self.pointer_active.set(false);
194    }
195
196    pub(crate) fn update_pointer(&self, pos: (f32, f32)) -> LiquidPose {
197        if self.pointer_pose_pending.replace(false) {
198            self.last_pos.set(Some(pos));
199            self.last_nanos.set(self.runtime.last_frame_time_nanos());
200            return self.pose.get();
201        }
202        let Some(now) = self.runtime.last_frame_time_nanos() else {
203            self.last_pos.set(Some(pos));
204            return self.pose.get();
205        };
206        let Some(last) = self.last_nanos.get() else {
207            self.last_nanos.set(Some(now));
208            self.last_pos.set(Some(pos));
209            return self.pose.get();
210        };
211        if last == now {
212            return self.pose.get();
213        }
214        let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
215        self.last_nanos.set(Some(now));
216        let stationary = self.last_pos.get().is_some_and(|last_pos| {
217            (last_pos.0 - pos.0).abs() < 0.001 && (last_pos.1 - pos.1).abs() < 0.001
218        });
219        if !stationary {
220            return self.advance(pos, dt);
221        }
222        let within_pointer_horizon = self.pointer_active.get()
223            && self
224                .last_pointer_nanos
225                .get()
226                .is_some_and(|sample| now.saturating_sub(sample) <= POINTER_STOP_HORIZON_NANOS);
227        if within_pointer_horizon {
228            return self.pose.get();
229        }
230        let dt = dt.clamp(DT_MIN, DT_MAX);
231        let decay = (-dt / POINTER_COAST_TAU).exp();
232        let velocity = self.velocity.get();
233        self.advance_velocity((velocity.0 * decay, velocity.1 * decay), dt)
234    }
235
236    /// Advance with the lens ride position using the runtime's animation
237    /// clock. Multiple reads within one frame return the same pose.
238    pub fn update(&self, pos: (f32, f32)) -> LiquidPose {
239        let Some(now) = self.runtime.last_frame_time_nanos() else {
240            self.last_pos.set(Some(pos));
241            return self.pose.get();
242        };
243        match self.last_nanos.get() {
244            Some(last) if last == now => {
245                // Same frame (second glass closure read): no time passed.
246                self.last_pos.set(Some(pos));
247                self.pose.get()
248            }
249            Some(last) => {
250                let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
251                self.last_nanos.set(Some(now));
252                self.advance(pos, dt)
253            }
254            None => {
255                self.last_nanos.set(Some(now));
256                self.last_pos.set(Some(pos));
257                self.pose.get()
258            }
259        }
260    }
261
262    /// Pure integration step (exposed for tests and custom clocks): advance
263    /// by `dt` seconds toward `pos`.
264    pub fn advance(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
265        let Some(last_pos) = self.last_pos.get() else {
266            self.last_pos.set(Some(pos));
267            return self.pose.get();
268        };
269        let dt = dt.clamp(DT_MIN, DT_MAX);
270        let delta = (pos.0 - last_pos.0, pos.1 - last_pos.1);
271        self.last_pos.set(Some(pos));
272
273        let velocity = (delta.0 / dt, delta.1 / dt);
274        let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
275        if raw_speed > TELEPORT_SPEED {
276            self.velocity.set((0.0, 0.0));
277            return self.pose.get();
278        }
279
280        self.advance_velocity(velocity, dt)
281    }
282
283    fn advance_velocity(&self, velocity: (f32, f32), dt: f32) -> LiquidPose {
284        let dt = dt.clamp(DT_MIN, DT_MAX);
285        let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
286        let previous_velocity = self.velocity.get();
287        self.velocity.set(velocity);
288        let accel = (
289            (velocity.0 - previous_velocity.0) / dt,
290            (velocity.1 - previous_velocity.1) / dt,
291        );
292
293        let mut axis = self.axis.get();
294        if raw_speed > AXIS_MIN_SPEED {
295            axis = (velocity.0 / raw_speed, velocity.1 / raw_speed);
296            self.axis.set(axis);
297        }
298        // Signed acceleration along travel: positive while gaining speed.
299        let accel_along = accel.0 * axis.0 + accel.1 * axis.1;
300
301        let target_stretch = (1.0 + STRETCH_PER_SPEED * raw_speed
302            - STRETCH_PER_ACCEL * accel_along)
303            .clamp(STRETCH_MIN, STRETCH_MAX);
304        let signed_bulge = (-BULGE_PER_ACCELERATION * accel_along).clamp(-BULGE_MAX, BULGE_MAX);
305        let target_bulge = (axis.0 * signed_bulge, axis.1 * signed_bulge);
306
307        // Excitation (moving away from neutral) is fast; relaxation back is
308        // viscous, so a brake swell lingers a beat like the reference.
309        let follow = |current: f32, target: f32, neutral: f32| {
310            let tau = if (target - neutral).abs() > (current - neutral).abs() {
311                ATTACK_TAU
312            } else {
313                RELEASE_TAU
314            };
315            current + (target - current) * (1.0 - (-dt / tau).exp())
316        };
317        let stretch = follow(self.stretch.get(), target_stretch, 1.0);
318        let current_bulge = self.bulge_vector.get();
319        let current_bulge_length = current_bulge.0.hypot(current_bulge.1);
320        let target_bulge_length = target_bulge.0.hypot(target_bulge.1);
321        let bulge_tau = if target_bulge_length > current_bulge_length {
322            ATTACK_TAU
323        } else {
324            RELEASE_TAU
325        };
326        let bulge_follow = 1.0 - (-dt / bulge_tau).exp();
327        let bulge_vector = (
328            current_bulge.0 + (target_bulge.0 - current_bulge.0) * bulge_follow,
329            current_bulge.1 + (target_bulge.1 - current_bulge.1) * bulge_follow,
330        );
331        let bulge = bulge_vector.0.hypot(bulge_vector.1);
332        let speed = follow(self.speed.get(), raw_speed, 0.0);
333        self.stretch.set(stretch);
334        self.bulge_vector.set(bulge_vector);
335        self.speed.set(speed);
336
337        let bulge_direction = if bulge > 1.0e-4 {
338            bulge_vector.1.atan2(bulge_vector.0)
339        } else {
340            axis.1.atan2(axis.0)
341        };
342
343        let pose = LiquidPose {
344            stretch,
345            ortho: 1.0 / stretch,
346            axis,
347            bulge_amplitude: bulge,
348            bulge_direction,
349            speed,
350        };
351        self.pose.set(pose);
352        pose
353    }
354
355    /// Latest pose without advancing.
356    pub fn pose(&self) -> LiquidPose {
357        self.pose.get()
358    }
359}
360
361/// Remember one [`LiquidDynamics`] for the calling composition site.
362#[composable]
363#[track_caller]
364pub fn rememberLiquidDynamics() -> Rc<LiquidDynamics> {
365    let caller = cranpose_core::caller_location_key();
366    with_current_composer(|composer| {
367        let runtime = composer.runtime_handle();
368        composer
369            .remember_at(caller, move || Rc::new(LiquidDynamics::new(runtime)))
370            .with(Rc::clone)
371    })
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn dynamics() -> LiquidDynamics {
379        let runtime =
380            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
381        LiquidDynamics::new(runtime.handle())
382    }
383
384    fn settle(d: &LiquidDynamics, pos: (f32, f32), frames: usize) -> LiquidPose {
385        let mut pose = d.pose();
386        for _ in 0..frames {
387            pose = d.advance(pos, 1.0 / 60.0);
388        }
389        pose
390    }
391
392    /// Drive at constant velocity and return the steady pose.
393    fn cruise(d: &LiquidDynamics, speed_dp_s: f32, frames: usize) -> LiquidPose {
394        let dt = 1.0 / 60.0;
395        let mut x = 0.0;
396        let mut pose = d.pose();
397        for _ in 0..frames {
398            x += speed_dp_s * dt;
399            pose = d.advance((x, 0.0), dt);
400        }
401        pose
402    }
403
404    #[test]
405    fn constant_speed_elongates_along_axis_and_conserves_area() {
406        let d = dynamics();
407        let pose = cruise(&d, 1200.0, 40);
408        assert!((1.36..1.40).contains(&pose.stretch));
409        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
410        assert!((pose.axis.0 - 1.0).abs() < 1e-4);
411    }
412
413    #[test]
414    fn subpixel_pointer_jitter_cannot_invent_extreme_strain() {
415        let d = dynamics();
416        d.anchor_pointer((0.0, 0.0));
417        let mut pose = d.pose();
418        for sample in 1..=12 {
419            pose = d.advance_pointer((sample as f32 * 0.20, 0.0), 1.0 / 60.0);
420        }
421        assert!(
422            (pose.stretch - 1.0).abs() < 0.025,
423            "subpixel travel must stay near equilibrium: {pose:?}"
424        );
425        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
426    }
427
428    #[test]
429    fn launch_compresses_before_cruise_stretch_wins() {
430        let d = dynamics();
431        d.advance((0.0, 0.0), 1.0 / 60.0);
432        // Hard launch: 0 → 1500 dp/s in one frame (a = 90k dp/s²) — the
433        // instantaneous target is compression-dominated.
434        let dt = 1.0 / 60.0;
435        let pose = d.advance((1500.0 * dt, 0.0), dt);
436        assert!(pose.stretch < 1.0, "launch stretch {}", pose.stretch);
437        assert!(pose.ortho > 1.0, "launch ortho {}", pose.ortho);
438    }
439
440    #[test]
441    fn launch_acceleration_leaves_a_persistent_trailing_material_wake() {
442        let d = dynamics();
443        let dt = 1.0 / 60.0;
444        d.advance((0.0, 0.0), dt);
445        let launch = d.advance((1200.0 * dt, 0.0), dt);
446        assert!(
447            launch.bulge_amplitude > 0.5,
448            "launch must displace liquid toward the trailing edge: {launch:?}"
449        );
450        assert!(
451            (launch.bulge_direction.abs() - std::f32::consts::PI).abs() < 0.1,
452            "rightward acceleration must trail to the left: {launch:?}"
453        );
454
455        let cruise = d.advance((2400.0 * dt, 0.0), dt);
456        assert!(
457            cruise.bulge_amplitude > 0.25,
458            "the material wake must survive beyond one pointer sample: {launch:?} -> {cruise:?}"
459        );
460        assert!(
461            (cruise.bulge_direction.abs() - std::f32::consts::PI).abs() < 0.2,
462            "the remembered wake cannot flip on the next sample: {cruise:?}"
463        );
464    }
465
466    #[test]
467    fn direct_drag_cadence_remains_launch_compressed() {
468        let d = dynamics();
469        d.anchor_pointer((0.0, 0.0));
470        d.advance_pointer((-20.0, 0.0), 0.08);
471        let pose = d.advance_pointer((-40.0, 0.0), 0.03);
472        assert!(
473            pose.stretch <= 0.92,
474            "the target's two-event launch must compress along travel: {pose:?}"
475        );
476        assert!(
477            pose.ortho >= 1.08,
478            "launch must expand across travel: {pose:?}"
479        );
480        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
481    }
482
483    #[test]
484    fn braking_decompresses_past_cruise_and_swells_leading_edge() {
485        let d = dynamics();
486        let cruise_pose = cruise(&d, 1200.0, 40);
487        // Brake to a stop over two frames.
488        let dt = 1.0 / 60.0;
489        let x = d.last_pos.get().unwrap().0;
490        let brake = d.advance((x + 300.0 * dt, 0.0), dt);
491        assert!(
492            brake.stretch > cruise_pose.stretch + 0.04,
493            "brake {} vs cruise {}",
494            brake.stretch,
495            cruise_pose.stretch
496        );
497        assert!(
498            brake.bulge_amplitude > 0.5,
499            "bulge {}",
500            brake.bulge_amplitude
501        );
502        assert!(
503            brake.ortho < cruise_pose.ortho,
504            "brake ortho {}",
505            brake.ortho
506        );
507        // Leading edge = travel direction (+x).
508        assert!(brake.bulge_direction.abs() < 1e-3);
509    }
510
511    #[test]
512    fn rest_decays_to_identity() {
513        let d = dynamics();
514        cruise(&d, 1200.0, 40);
515        let pose = settle(&d, d.last_pos.get().unwrap(), 60);
516        assert!(
517            (pose.stretch - 1.0).abs() < 0.02,
518            "stretch {}",
519            pose.stretch
520        );
521        assert!(pose.bulge_amplitude < 0.2);
522        assert!(pose.speed < 15.0);
523    }
524
525    #[test]
526    fn axis_follows_motion_direction_and_holds_at_rest() {
527        let d = dynamics();
528        let dt = 1.0 / 60.0;
529        d.advance((0.0, 0.0), dt);
530        let mut x = 0.0;
531        let mut pose = d.pose();
532        for _ in 0..10 {
533            x -= 900.0 * dt;
534            pose = d.advance((x, 0.0), dt);
535        }
536        assert!((pose.axis.0 + 1.0).abs() < 1e-4, "axis {:?}", pose.axis);
537        assert!(
538            pose.bulge_direction.abs() < 0.1,
539            "leftward launch inertia must trail toward +x: {pose:?}"
540        );
541        // Stopping keeps the last axis instead of flipping on noise.
542        let held = settle(&d, (x, 0.0), 30);
543        assert!((held.axis.0 + 1.0).abs() < 1e-4);
544    }
545
546    #[test]
547    fn frame_rate_independent_cruise() {
548        let d60 = dynamics();
549        let d120 = dynamics();
550        let cruise60 = cruise(&d60, 1000.0, 30);
551        let dt = 1.0 / 120.0;
552        let mut x = 0.0;
553        let mut cruise120 = d120.pose();
554        for _ in 0..60 {
555            x += 1000.0 * dt;
556            cruise120 = d120.advance((x, 0.0), dt);
557        }
558        assert!(
559            (cruise60.stretch - cruise120.stretch).abs() < 0.02,
560            "60Hz {} vs 120Hz {}",
561            cruise60.stretch,
562            cruise120.stretch
563        );
564    }
565
566    #[test]
567    fn teleport_re_anchors_without_energy() {
568        let d = dynamics();
569        d.advance((0.0, 0.0), 1.0 / 60.0);
570        let pose = d.advance((4000.0, 0.0), 1.0 / 60.0);
571        assert_eq!(pose, d.pose());
572        assert!(
573            (pose.stretch - 1.0).abs() < 1e-4,
574            "teleport {}",
575            pose.stretch
576        );
577        // Motion resumes cleanly from the new anchor.
578        let resumed = cruise(&d, 800.0, 30);
579        assert!(resumed.stretch > 1.01);
580    }
581
582    #[test]
583    fn vertical_travel_stretches_height_not_width() {
584        let d = dynamics();
585        let dt = 1.0 / 60.0;
586        let mut y = 0.0;
587        d.advance((0.0, 0.0), dt);
588        let mut pose = d.pose();
589        for _ in 0..30 {
590            y += 1000.0 * dt;
591            pose = d.advance((0.0, y), dt);
592        }
593        assert!(
594            (1.30..1.34).contains(&pose.stretch),
595            "stretch {}",
596            pose.stretch
597        );
598        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
599    }
600
601    #[test]
602    fn reset_forgets_motion() {
603        let d = dynamics();
604        cruise(&d, 1200.0, 40);
605        d.reset();
606        assert_eq!(d.pose().stretch, 1.0);
607        let pose = d.advance((500.0, 0.0), 1.0 / 60.0);
608        assert!((pose.stretch - 1.0).abs() < 1e-4);
609    }
610}