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//!
17//! Every channel relaxes exponentially, so none of them ever reaches
18//! neutral on its own. Once travel, strain and swell are all within a
19//! fraction of a device pixel of rest, the integrator snaps them to exactly
20//! [`LiquidPose::default`] and stops producing new poses, so a settled lens
21//! costs nothing per frame.
22
23use std::{cell::Cell, rc::Rc};
24
25use cranpose_core::{RuntimeHandle, with_current_composer};
26use cranpose_macros::composable;
27
28use crate::material::GlassDeformation;
29
30const STRETCH_PER_SPEED: f32 = 3.2e-4;
31const STRETCH_PER_ACCEL: f32 = 3.5e-5;
32/// The droplet never deforms past these bounds, however violent the fling.
33/// Public so hosting nodes can budget layout headroom for the extremes.
34pub const STRETCH_MIN: f32 = 0.78;
35pub const STRETCH_MAX: f32 = 1.50;
36const BULGE_PER_ACCELERATION: f32 = 4.5e-4;
37pub const BULGE_MAX: f32 = 8.0;
38const ATTACK_TAU: f32 = 0.03;
39const RELEASE_TAU: f32 = 0.11;
40const POINTER_VELOCITY_TAU: f32 = 0.045;
41const POINTER_STOP_HORIZON_NANOS: u64 = 40_000_000;
42const POINTER_COAST_TAU: f32 = 0.10;
43const AXIS_MIN_SPEED: f32 = 60.0;
44const REST_SPEED: f32 = 1.0;
45const REST_STRETCH: f32 = 5.0e-4;
46const REST_BULGE: f32 = 1.0e-2;
47const DT_MIN: f32 = 1.0 / 1000.0;
48const DT_MAX: f32 = 1.0 / 15.0;
49const TELEPORT_SPEED: f32 = 30_000.0;
50
51/// Motion-derived deformation of a liquid lens for one frame.
52#[derive(Clone, Copy, Debug, PartialEq)]
53pub struct LiquidPose {
54    /// Scale along the motion axis (>1 = elongated, <1 = compressed).
55    pub stretch: f32,
56    /// Scale across the motion axis; always `1/stretch` (area conserved).
57    pub ortho: f32,
58    /// Unit motion direction (math convention, +x right / +y up in caller
59    /// space — callers feed whatever space they draw in).
60    pub axis: (f32, f32),
61    /// Leading-edge swell amplitude (dp) toward `bulge_direction` (radians).
62    pub bulge_amplitude: f32,
63    pub bulge_direction: f32,
64    /// Smoothed travel speed (dp/s) for auxiliary channels (surface depth,
65    /// wobble, chromatic fringe).
66    pub speed: f32,
67}
68
69impl Default for LiquidPose {
70    fn default() -> Self {
71        Self {
72            stretch: 1.0,
73            ortho: 1.0,
74            axis: (1.0, 0.0),
75            bulge_amplitude: 0.0,
76            bulge_direction: 0.0,
77            speed: 0.0,
78        }
79    }
80}
81
82impl LiquidPose {
83    /// The full rotating strain tensor for the shader. Applying this to the
84    /// signed-distance field preserves area for horizontal, vertical and
85    /// diagonal travel; projecting it into an axis-aligned width/height pair
86    /// cannot preserve that invariant away from the cardinal axes.
87    pub fn deformation(&self) -> GlassDeformation {
88        GlassDeformation::incompressible(self.axis, self.stretch)
89    }
90
91    /// Normalized motion energy in 0..1 (`speed` against a reference fling
92    /// of ~1100 dp/s) — the shared ramp for speed-driven side channels.
93    pub fn energy(&self) -> f32 {
94        (self.speed / 1100.0).clamp(0.0, 1.0)
95    }
96}
97
98/// Per-lens frame integrator. Interior-mutable so per-frame draw closures
99/// (plain `Fn`) can advance it without a `RefCell` dance.
100pub struct LiquidDynamics {
101    runtime: RuntimeHandle,
102    last_nanos: Cell<Option<u64>>,
103    last_pos: Cell<Option<(f32, f32)>>,
104    velocity: Cell<(f32, f32)>,
105    stretch: Cell<f32>,
106    bulge_vector: Cell<(f32, f32)>,
107    speed: Cell<f32>,
108    axis: Cell<(f32, f32)>,
109    pose: Cell<LiquidPose>,
110    pointer_pose_pending: Cell<bool>,
111    pointer_active: Cell<bool>,
112    last_pointer_nanos: Cell<Option<u64>>,
113}
114
115impl LiquidDynamics {
116    pub fn new(runtime: RuntimeHandle) -> Self {
117        Self {
118            runtime,
119            last_nanos: Cell::new(None),
120            last_pos: Cell::new(None),
121            velocity: Cell::new((0.0, 0.0)),
122            stretch: Cell::new(1.0),
123            bulge_vector: Cell::new((0.0, 0.0)),
124            speed: Cell::new(0.0),
125            axis: Cell::new((1.0, 0.0)),
126            pose: Cell::new(LiquidPose::default()),
127            pointer_pose_pending: Cell::new(false),
128            pointer_active: Cell::new(false),
129            last_pointer_nanos: Cell::new(None),
130        }
131    }
132
133    /// Forget all motion state (fresh grab, teleporting relayout): the next
134    /// update re-anchors at rest.
135    pub fn reset(&self) {
136        self.last_nanos.set(None);
137        self.last_pos.set(None);
138        self.velocity.set((0.0, 0.0));
139        self.stretch.set(1.0);
140        self.bulge_vector.set((0.0, 0.0));
141        self.speed.set(0.0);
142        self.pose.set(LiquidPose {
143            axis: self.axis.get(),
144            ..LiquidPose::default()
145        });
146        self.pointer_pose_pending.set(false);
147        self.pointer_active.set(false);
148        self.last_pointer_nanos.set(None);
149    }
150
151    pub(crate) fn anchor_pointer(&self, pos: (f32, f32)) {
152        self.reset();
153        self.last_pos.set(Some(pos));
154        let now = self.runtime.last_frame_time_nanos();
155        self.last_nanos.set(now);
156        self.last_pointer_nanos.set(now);
157        self.pointer_active.set(true);
158    }
159
160    pub(crate) fn advance_pointer(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
161        let Some(last_pos) = self.last_pos.get() else {
162            self.last_pos.set(Some(pos));
163            return self.pose.get();
164        };
165        let dt = dt.clamp(DT_MIN, DT_MAX);
166        let raw_velocity = ((pos.0 - last_pos.0) / dt, (pos.1 - last_pos.1) / dt);
167        self.last_pos.set(Some(pos));
168        let previous = self.velocity.get();
169        let follow = 1.0 - (-dt / POINTER_VELOCITY_TAU).exp();
170        let filtered_velocity = (
171            previous.0 + (raw_velocity.0 - previous.0) * follow,
172            previous.1 + (raw_velocity.1 - previous.1) * follow,
173        );
174        let pose = self.advance_velocity(filtered_velocity, dt);
175        let now = self.runtime.last_frame_time_nanos();
176        self.last_nanos.set(now);
177        self.last_pointer_nanos.set(now);
178        self.pointer_active.set(true);
179        self.pointer_pose_pending.set(true);
180        pose
181    }
182
183    pub(crate) fn release_pointer(&self) {
184        self.pointer_active.set(false);
185    }
186
187    pub(crate) fn update_pointer(&self, pos: (f32, f32)) -> LiquidPose {
188        if self.pointer_pose_pending.replace(false) {
189            self.last_pos.set(Some(pos));
190            self.last_nanos.set(self.runtime.last_frame_time_nanos());
191            return self.pose.get();
192        }
193        let Some(now) = self.runtime.last_frame_time_nanos() else {
194            self.last_pos.set(Some(pos));
195            return self.pose.get();
196        };
197        let Some(last) = self.last_nanos.get() else {
198            self.last_nanos.set(Some(now));
199            self.last_pos.set(Some(pos));
200            return self.pose.get();
201        };
202        if last == now {
203            return self.pose.get();
204        }
205        let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
206        self.last_nanos.set(Some(now));
207        let stationary = self.last_pos.get().is_some_and(|last_pos| {
208            (last_pos.0 - pos.0).abs() < 0.001 && (last_pos.1 - pos.1).abs() < 0.001
209        });
210        if !stationary {
211            return self.advance(pos, dt);
212        }
213        let within_pointer_horizon = self.pointer_active.get()
214            && self
215                .last_pointer_nanos
216                .get()
217                .is_some_and(|sample| now.saturating_sub(sample) <= POINTER_STOP_HORIZON_NANOS);
218        if within_pointer_horizon {
219            return self.pose.get();
220        }
221        let dt = dt.clamp(DT_MIN, DT_MAX);
222        let decay = (-dt / POINTER_COAST_TAU).exp();
223        let velocity = self.velocity.get();
224        self.advance_velocity((velocity.0 * decay, velocity.1 * decay), dt)
225    }
226
227    /// Advance with the lens ride position using the runtime's animation
228    /// clock. Multiple reads within one frame return the same pose.
229    pub fn update(&self, pos: (f32, f32)) -> LiquidPose {
230        let Some(now) = self.runtime.last_frame_time_nanos() else {
231            self.last_pos.set(Some(pos));
232            return self.pose.get();
233        };
234        match self.last_nanos.get() {
235            Some(last) if last == now => {
236                self.last_pos.set(Some(pos));
237                self.pose.get()
238            }
239            Some(last) => {
240                let dt = (now.saturating_sub(last)) as f32 / 1_000_000_000.0;
241                self.last_nanos.set(Some(now));
242                self.advance(pos, dt)
243            }
244            None => {
245                self.last_nanos.set(Some(now));
246                self.last_pos.set(Some(pos));
247                self.pose.get()
248            }
249        }
250    }
251
252    /// Pure integration step (exposed for tests and custom clocks): advance
253    /// by `dt` seconds toward `pos`.
254    pub fn advance(&self, pos: (f32, f32), dt: f32) -> LiquidPose {
255        let Some(last_pos) = self.last_pos.get() else {
256            self.last_pos.set(Some(pos));
257            return self.pose.get();
258        };
259        let dt = dt.clamp(DT_MIN, DT_MAX);
260        let delta = (pos.0 - last_pos.0, pos.1 - last_pos.1);
261        self.last_pos.set(Some(pos));
262
263        let velocity = (delta.0 / dt, delta.1 / dt);
264        let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
265        if raw_speed > TELEPORT_SPEED {
266            self.velocity.set((0.0, 0.0));
267            return self.pose.get();
268        }
269
270        self.advance_velocity(velocity, dt)
271    }
272
273    fn advance_velocity(&self, velocity: (f32, f32), dt: f32) -> LiquidPose {
274        let dt = dt.clamp(DT_MIN, DT_MAX);
275        let raw_speed = (velocity.0 * velocity.0 + velocity.1 * velocity.1).sqrt();
276        let previous_velocity = self.velocity.get();
277        self.velocity.set(velocity);
278        let accel = (
279            (velocity.0 - previous_velocity.0) / dt,
280            (velocity.1 - previous_velocity.1) / dt,
281        );
282
283        let mut axis = self.axis.get();
284        if raw_speed > AXIS_MIN_SPEED {
285            axis = (velocity.0 / raw_speed, velocity.1 / raw_speed);
286            self.axis.set(axis);
287        }
288        let accel_along = accel.0 * axis.0 + accel.1 * axis.1;
289
290        let target_stretch = (1.0 + STRETCH_PER_SPEED * raw_speed
291            - STRETCH_PER_ACCEL * accel_along)
292            .clamp(STRETCH_MIN, STRETCH_MAX);
293        let signed_bulge = (-BULGE_PER_ACCELERATION * accel_along).clamp(-BULGE_MAX, BULGE_MAX);
294        let target_bulge = (axis.0 * signed_bulge, axis.1 * signed_bulge);
295
296        let follow = |current: f32, target: f32, neutral: f32| {
297            let tau = if (target - neutral).abs() > (current - neutral).abs() {
298                ATTACK_TAU
299            } else {
300                RELEASE_TAU
301            };
302            current + (target - current) * (1.0 - (-dt / tau).exp())
303        };
304        let stretch = follow(self.stretch.get(), target_stretch, 1.0);
305        let current_bulge = self.bulge_vector.get();
306        let current_bulge_length = current_bulge.0.hypot(current_bulge.1);
307        let target_bulge_length = target_bulge.0.hypot(target_bulge.1);
308        let bulge_tau = if target_bulge_length > current_bulge_length {
309            ATTACK_TAU
310        } else {
311            RELEASE_TAU
312        };
313        let bulge_follow = 1.0 - (-dt / bulge_tau).exp();
314        let bulge_vector = (
315            current_bulge.0 + (target_bulge.0 - current_bulge.0) * bulge_follow,
316            current_bulge.1 + (target_bulge.1 - current_bulge.1) * bulge_follow,
317        );
318        let speed = follow(self.speed.get(), raw_speed, 0.0);
319        let at_rest = raw_speed <= REST_SPEED
320            && speed <= REST_SPEED
321            && (stretch - 1.0).abs() <= REST_STRETCH
322            && bulge_vector.0.hypot(bulge_vector.1) <= REST_BULGE;
323        let (stretch, bulge_vector, speed) = if at_rest {
324            (1.0, (0.0, 0.0), 0.0)
325        } else {
326            (stretch, bulge_vector, speed)
327        };
328        let bulge = bulge_vector.0.hypot(bulge_vector.1);
329        self.stretch.set(stretch);
330        self.bulge_vector.set(bulge_vector);
331        self.speed.set(speed);
332
333        let bulge_direction = if bulge > 1.0e-4 {
334            bulge_vector.1.atan2(bulge_vector.0)
335        } else {
336            axis.1.atan2(axis.0)
337        };
338
339        let pose = LiquidPose {
340            stretch,
341            ortho: 1.0 / stretch,
342            axis,
343            bulge_amplitude: bulge,
344            bulge_direction,
345            speed,
346        };
347        self.pose.set(pose);
348        pose
349    }
350
351    /// Latest pose without advancing.
352    pub fn pose(&self) -> LiquidPose {
353        self.pose.get()
354    }
355}
356
357/// Remember one [`LiquidDynamics`] for the calling composition site.
358#[composable]
359#[track_caller]
360pub fn rememberLiquidDynamics() -> Rc<LiquidDynamics> {
361    let caller = cranpose_core::caller_location_key();
362    with_current_composer(|composer| {
363        let runtime = composer.runtime_handle();
364        composer
365            .remember_at(caller, move || Rc::new(LiquidDynamics::new(runtime)))
366            .with(Rc::clone)
367    })
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn dynamics() -> LiquidDynamics {
375        let runtime =
376            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
377        LiquidDynamics::new(runtime.handle())
378    }
379
380    fn settle(d: &LiquidDynamics, pos: (f32, f32), frames: usize) -> LiquidPose {
381        let mut pose = d.pose();
382        for _ in 0..frames {
383            pose = d.advance(pos, 1.0 / 60.0);
384        }
385        pose
386    }
387
388    fn cruise(d: &LiquidDynamics, speed_dp_s: f32, frames: usize) -> LiquidPose {
389        let dt = 1.0 / 60.0;
390        let mut x = 0.0;
391        let mut pose = d.pose();
392        for _ in 0..frames {
393            x += speed_dp_s * dt;
394            pose = d.advance((x, 0.0), dt);
395        }
396        pose
397    }
398
399    #[test]
400    fn constant_speed_elongates_along_axis_and_conserves_area() {
401        let d = dynamics();
402        let pose = cruise(&d, 1200.0, 40);
403        assert!((1.36..1.40).contains(&pose.stretch));
404        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
405        assert!((pose.axis.0 - 1.0).abs() < 1e-4);
406    }
407
408    #[test]
409    fn subpixel_pointer_jitter_cannot_invent_extreme_strain() {
410        let d = dynamics();
411        d.anchor_pointer((0.0, 0.0));
412        let mut pose = d.pose();
413        for sample in 1..=12 {
414            pose = d.advance_pointer((sample as f32 * 0.20, 0.0), 1.0 / 60.0);
415        }
416        assert!(
417            (pose.stretch - 1.0).abs() < 0.025,
418            "subpixel travel must stay near equilibrium: {pose:?}"
419        );
420        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
421    }
422
423    #[test]
424    fn launch_compresses_before_cruise_stretch_wins() {
425        let d = dynamics();
426        d.advance((0.0, 0.0), 1.0 / 60.0);
427        let dt = 1.0 / 60.0;
428        let pose = d.advance((1500.0 * dt, 0.0), dt);
429        assert!(pose.stretch < 1.0, "launch stretch {}", pose.stretch);
430        assert!(pose.ortho > 1.0, "launch ortho {}", pose.ortho);
431    }
432
433    #[test]
434    fn launch_acceleration_leaves_a_persistent_trailing_material_wake() {
435        let d = dynamics();
436        let dt = 1.0 / 60.0;
437        d.advance((0.0, 0.0), dt);
438        let launch = d.advance((1200.0 * dt, 0.0), dt);
439        assert!(
440            launch.bulge_amplitude > 0.5,
441            "launch must displace liquid toward the trailing edge: {launch:?}"
442        );
443        assert!(
444            (launch.bulge_direction.abs() - std::f32::consts::PI).abs() < 0.1,
445            "rightward acceleration must trail to the left: {launch:?}"
446        );
447
448        let cruise = d.advance((2400.0 * dt, 0.0), dt);
449        assert!(
450            cruise.bulge_amplitude > 0.25,
451            "the material wake must survive beyond one pointer sample: {launch:?} -> {cruise:?}"
452        );
453        assert!(
454            (cruise.bulge_direction.abs() - std::f32::consts::PI).abs() < 0.2,
455            "the remembered wake cannot flip on the next sample: {cruise:?}"
456        );
457    }
458
459    #[test]
460    fn direct_drag_cadence_remains_launch_compressed() {
461        let d = dynamics();
462        d.anchor_pointer((0.0, 0.0));
463        d.advance_pointer((-20.0, 0.0), 0.08);
464        let pose = d.advance_pointer((-40.0, 0.0), 0.03);
465        assert!(
466            pose.stretch <= 0.92,
467            "the target's two-event launch must compress along travel: {pose:?}"
468        );
469        assert!(
470            pose.ortho >= 1.08,
471            "launch must expand across travel: {pose:?}"
472        );
473        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
474    }
475
476    #[test]
477    fn braking_decompresses_past_cruise_and_swells_leading_edge() {
478        let d = dynamics();
479        let cruise_pose = cruise(&d, 1200.0, 40);
480        let dt = 1.0 / 60.0;
481        let x = d.last_pos.get().unwrap().0;
482        let brake = d.advance((x + 300.0 * dt, 0.0), dt);
483        assert!(
484            brake.stretch > cruise_pose.stretch + 0.04,
485            "brake {} vs cruise {}",
486            brake.stretch,
487            cruise_pose.stretch
488        );
489        assert!(
490            brake.bulge_amplitude > 0.5,
491            "bulge {}",
492            brake.bulge_amplitude
493        );
494        assert!(
495            brake.ortho < cruise_pose.ortho,
496            "brake ortho {}",
497            brake.ortho
498        );
499        assert!(brake.bulge_direction.abs() < 1e-3);
500    }
501
502    #[test]
503    fn rest_decays_to_identity() {
504        let d = dynamics();
505        cruise(&d, 1200.0, 40);
506        let pose = settle(&d, d.last_pos.get().unwrap(), 60);
507        assert!(
508            (pose.stretch - 1.0).abs() < 0.02,
509            "stretch {}",
510            pose.stretch
511        );
512        assert!(pose.bulge_amplitude < 0.2);
513        assert!(pose.speed < 15.0);
514    }
515
516    #[test]
517    fn rest_reaches_an_exact_fixed_point_and_stops_producing_poses() {
518        let d = dynamics();
519        cruise(&d, 1200.0, 40);
520        let resting = d.last_pos.get().unwrap();
521
522        let mut frames_to_rest = None;
523        for frame in 1..=600 {
524            let pose = d.advance(resting, 1.0 / 60.0);
525            if pose == d.advance(resting, 1.0 / 60.0) {
526                frames_to_rest = Some(frame);
527                break;
528            }
529        }
530        let frames_to_rest =
531            frames_to_rest.expect("a stationary lens must stop producing new poses");
532        assert!(
533            frames_to_rest <= 120,
534            "rest took {frames_to_rest} frames of stationary travel"
535        );
536
537        let pose = d.pose();
538        assert_eq!(pose.stretch, 1.0, "resting stretch {pose:?}");
539        assert_eq!(pose.ortho, 1.0, "resting ortho {pose:?}");
540        assert_eq!(pose.bulge_amplitude, 0.0, "resting bulge {pose:?}");
541        assert_eq!(pose.speed, 0.0, "resting speed {pose:?}");
542
543        for _ in 0..600 {
544            assert_eq!(
545                d.advance(resting, 1.0 / 60.0),
546                pose,
547                "a lens at rest must keep returning the identical pose"
548            );
549        }
550    }
551
552    #[test]
553    fn rest_snap_is_invisible_next_to_the_pose_it_replaces() {
554        let d = dynamics();
555        cruise(&d, 1200.0, 40);
556        let resting = d.last_pos.get().unwrap();
557        let dt = 1.0f32 / 60.0;
558        let undecay = 1.0 / (-dt / RELEASE_TAU).exp();
559        let mut previous = d.pose();
560        for _ in 0..600 {
561            let pose = d.advance(resting, dt);
562            if pose.stretch == 1.0 && pose.bulge_amplitude == 0.0 && pose.speed == 0.0 {
563                assert!(
564                    (previous.stretch - 1.0).abs() <= REST_STRETCH * undecay,
565                    "snap jumped stretch from {previous:?}"
566                );
567                assert!(
568                    previous.bulge_amplitude <= REST_BULGE * undecay,
569                    "snap jumped bulge from {previous:?}"
570                );
571                assert!(
572                    previous.speed <= REST_SPEED * undecay,
573                    "snap jumped speed from {previous:?}"
574                );
575                return;
576            }
577            previous = pose;
578        }
579        panic!("a stationary lens never reached rest: {previous:?}");
580    }
581
582    #[test]
583    fn cruising_never_snaps_to_rest() {
584        let d = dynamics();
585        let pose = cruise(&d, 1200.0, 40);
586        assert!(pose.speed > REST_SPEED, "cruise speed {pose:?}");
587        assert!(
588            (pose.stretch - 1.0).abs() > REST_STRETCH,
589            "cruise stretch {pose:?}"
590        );
591
592        let slow = dynamics();
593        let pose = cruise(&slow, 40.0, 40);
594        assert!(
595            pose.speed > REST_SPEED,
596            "a slow but real drag must keep its motion: {pose:?}"
597        );
598    }
599
600    #[test]
601    fn axis_follows_motion_direction_and_holds_at_rest() {
602        let d = dynamics();
603        let dt = 1.0 / 60.0;
604        d.advance((0.0, 0.0), dt);
605        let mut x = 0.0;
606        let mut pose = d.pose();
607        for _ in 0..10 {
608            x -= 900.0 * dt;
609            pose = d.advance((x, 0.0), dt);
610        }
611        assert!((pose.axis.0 + 1.0).abs() < 1e-4, "axis {:?}", pose.axis);
612        assert!(
613            pose.bulge_direction.abs() < 0.1,
614            "leftward launch inertia must trail toward +x: {pose:?}"
615        );
616        let held = settle(&d, (x, 0.0), 30);
617        assert!((held.axis.0 + 1.0).abs() < 1e-4);
618    }
619
620    #[test]
621    fn frame_rate_independent_cruise() {
622        let d60 = dynamics();
623        let d120 = dynamics();
624        let cruise60 = cruise(&d60, 1000.0, 30);
625        let dt = 1.0 / 120.0;
626        let mut x = 0.0;
627        let mut cruise120 = d120.pose();
628        for _ in 0..60 {
629            x += 1000.0 * dt;
630            cruise120 = d120.advance((x, 0.0), dt);
631        }
632        assert!(
633            (cruise60.stretch - cruise120.stretch).abs() < 0.02,
634            "60Hz {} vs 120Hz {}",
635            cruise60.stretch,
636            cruise120.stretch
637        );
638    }
639
640    #[test]
641    fn teleport_re_anchors_without_energy() {
642        let d = dynamics();
643        d.advance((0.0, 0.0), 1.0 / 60.0);
644        let pose = d.advance((4000.0, 0.0), 1.0 / 60.0);
645        assert_eq!(pose, d.pose());
646        assert!(
647            (pose.stretch - 1.0).abs() < 1e-4,
648            "teleport {}",
649            pose.stretch
650        );
651        let resumed = cruise(&d, 800.0, 30);
652        assert!(resumed.stretch > 1.01);
653    }
654
655    #[test]
656    fn vertical_travel_stretches_height_not_width() {
657        let d = dynamics();
658        let dt = 1.0 / 60.0;
659        let mut y = 0.0;
660        d.advance((0.0, 0.0), dt);
661        let mut pose = d.pose();
662        for _ in 0..30 {
663            y += 1000.0 * dt;
664            pose = d.advance((0.0, y), dt);
665        }
666        assert!(
667            (1.30..1.34).contains(&pose.stretch),
668            "stretch {}",
669            pose.stretch
670        );
671        assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
672    }
673
674    #[test]
675    fn reset_forgets_motion() {
676        let d = dynamics();
677        cruise(&d, 1200.0, 40);
678        d.reset();
679        assert_eq!(d.pose().stretch, 1.0);
680        let pose = d.advance((500.0, 0.0), 1.0 / 60.0);
681        assert!((pose.stretch - 1.0).abs() < 1e-4);
682    }
683}