cranpose-liquid 0.9.38

Glass controls, themes and motion for Cranpose apps
Documentation
use super::*;

fn dynamics() -> LiquidDynamics {
    let runtime = cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
    LiquidDynamics::new(runtime.handle())
}

fn settle(d: &LiquidDynamics, pos: (f32, f32), frames: usize) -> LiquidPose {
    let mut pose = d.pose();
    for _ in 0..frames {
        pose = d.advance(pos, 1.0 / 60.0);
    }
    pose
}

fn cruise(d: &LiquidDynamics, speed_dp_s: f32, frames: usize) -> LiquidPose {
    let dt = 1.0 / 60.0;
    let mut x = 0.0;
    let mut pose = d.pose();
    for _ in 0..frames {
        x += speed_dp_s * dt;
        pose = d.advance((x, 0.0), dt);
    }
    pose
}

#[test]
fn constant_speed_elongates_along_axis_and_conserves_area() {
    let d = dynamics();
    let pose = cruise(&d, 1200.0, 40);
    assert!((1.36..1.40).contains(&pose.stretch));
    assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
    assert!((pose.axis.0 - 1.0).abs() < 1e-4);
}

#[test]
fn launch_compresses_before_cruise_stretch_wins() {
    let d = dynamics();
    d.advance((0.0, 0.0), 1.0 / 60.0);
    let dt = 1.0 / 60.0;
    let pose = d.advance((1500.0 * dt, 0.0), dt);
    assert!(pose.stretch < 1.0, "launch stretch {}", pose.stretch);
    assert!(pose.ortho > 1.0, "launch ortho {}", pose.ortho);
}

#[test]
fn launch_acceleration_leaves_a_persistent_trailing_material_wake() {
    let d = dynamics();
    let dt = 1.0 / 60.0;
    d.advance((0.0, 0.0), dt);
    let launch = d.advance((1200.0 * dt, 0.0), dt);
    assert!(
        launch.bulge_amplitude > 0.5,
        "launch must displace liquid toward the trailing edge: {launch:?}"
    );
    assert!(
        (launch.bulge_direction.abs() - std::f32::consts::PI).abs() < 0.1,
        "rightward acceleration must trail to the left: {launch:?}"
    );

    let cruise = d.advance((2400.0 * dt, 0.0), dt);
    assert!(
        cruise.bulge_amplitude > 0.25,
        "the material wake must survive beyond one pointer sample: {launch:?} -> {cruise:?}"
    );
    assert!(
        (cruise.bulge_direction.abs() - std::f32::consts::PI).abs() < 0.2,
        "the remembered wake cannot flip on the next sample: {cruise:?}"
    );
}

#[test]
fn braking_decompresses_past_cruise_and_swells_leading_edge() {
    let d = dynamics();
    let cruise_pose = cruise(&d, 1200.0, 40);
    let dt = 1.0 / 60.0;
    let x = d.last_pos.get().unwrap().0;
    let brake = d.advance((x + 300.0 * dt, 0.0), dt);
    assert!(
        brake.stretch > cruise_pose.stretch + 0.04,
        "brake {} vs cruise {}",
        brake.stretch,
        cruise_pose.stretch
    );
    assert!(
        brake.bulge_amplitude > 0.5,
        "bulge {}",
        brake.bulge_amplitude
    );
    assert!(
        brake.ortho < cruise_pose.ortho,
        "brake ortho {}",
        brake.ortho
    );
    assert!(brake.bulge_direction.abs() < 1e-3);
}

#[test]
fn rest_decays_to_identity() {
    let d = dynamics();
    cruise(&d, 1200.0, 40);
    let pose = settle(&d, d.last_pos.get().unwrap(), 60);
    assert!(
        (pose.stretch - 1.0).abs() < 0.02,
        "stretch {}",
        pose.stretch
    );
    assert!(pose.bulge_amplitude < 0.2);
    assert!(pose.speed < 15.0);
}

#[test]
fn rest_reaches_an_exact_fixed_point_and_stops_producing_poses() {
    let d = dynamics();
    cruise(&d, 1200.0, 40);
    let resting = d.last_pos.get().unwrap();

    let mut frames_to_rest = None;
    for frame in 1..=600 {
        let pose = d.advance(resting, 1.0 / 60.0);
        if pose == d.advance(resting, 1.0 / 60.0) {
            frames_to_rest = Some(frame);
            break;
        }
    }
    let frames_to_rest = frames_to_rest.expect("a stationary lens must stop producing new poses");
    assert!(
        frames_to_rest <= 120,
        "rest took {frames_to_rest} frames of stationary travel"
    );

    let pose = d.pose();
    assert_eq!(pose.stretch, 1.0, "resting stretch {pose:?}");
    assert_eq!(pose.ortho, 1.0, "resting ortho {pose:?}");
    assert_eq!(pose.bulge_amplitude, 0.0, "resting bulge {pose:?}");
    assert_eq!(pose.speed, 0.0, "resting speed {pose:?}");

    for _ in 0..600 {
        assert_eq!(
            d.advance(resting, 1.0 / 60.0),
            pose,
            "a lens at rest must keep returning the identical pose"
        );
    }
}

#[test]
fn rest_snap_is_invisible_next_to_the_pose_it_replaces() {
    let d = dynamics();
    cruise(&d, 1200.0, 40);
    let resting = d.last_pos.get().unwrap();
    let dt = 1.0f32 / 60.0;
    let undecay = 1.0 / (-dt / RELEASE_TAU).exp();
    let mut previous = d.pose();
    for _ in 0..600 {
        let pose = d.advance(resting, dt);
        if pose.stretch == 1.0 && pose.bulge_amplitude == 0.0 && pose.speed == 0.0 {
            assert!(
                (previous.stretch - 1.0).abs() <= REST_STRETCH * undecay,
                "snap jumped stretch from {previous:?}"
            );
            assert!(
                previous.bulge_amplitude <= REST_BULGE * undecay,
                "snap jumped bulge from {previous:?}"
            );
            assert!(
                previous.speed <= REST_SPEED * undecay,
                "snap jumped speed from {previous:?}"
            );
            return;
        }
        previous = pose;
    }
    panic!("a stationary lens never reached rest: {previous:?}");
}

#[test]
fn cruising_never_snaps_to_rest() {
    let d = dynamics();
    let pose = cruise(&d, 1200.0, 40);
    assert!(pose.speed > REST_SPEED, "cruise speed {pose:?}");
    assert!(
        (pose.stretch - 1.0).abs() > REST_STRETCH,
        "cruise stretch {pose:?}"
    );

    let slow = dynamics();
    let pose = cruise(&slow, 40.0, 40);
    assert!(
        pose.speed > REST_SPEED,
        "a slow but real drag must keep its motion: {pose:?}"
    );
}

#[test]
fn axis_follows_motion_direction_and_holds_at_rest() {
    let d = dynamics();
    let dt = 1.0 / 60.0;
    d.advance((0.0, 0.0), dt);
    let mut x = 0.0;
    let mut pose = d.pose();
    for _ in 0..10 {
        x -= 900.0 * dt;
        pose = d.advance((x, 0.0), dt);
    }
    assert!((pose.axis.0 + 1.0).abs() < 1e-4, "axis {:?}", pose.axis);
    assert!(
        pose.bulge_direction.abs() < 0.1,
        "leftward launch inertia must trail toward +x: {pose:?}"
    );
    let held = settle(&d, (x, 0.0), 30);
    assert!((held.axis.0 + 1.0).abs() < 1e-4);
}

#[test]
fn frame_rate_independent_cruise() {
    let d60 = dynamics();
    let d120 = dynamics();
    let cruise60 = cruise(&d60, 1000.0, 30);
    let dt = 1.0 / 120.0;
    let mut x = 0.0;
    let mut cruise120 = d120.pose();
    for _ in 0..60 {
        x += 1000.0 * dt;
        cruise120 = d120.advance((x, 0.0), dt);
    }
    assert!(
        (cruise60.stretch - cruise120.stretch).abs() < 0.02,
        "60Hz {} vs 120Hz {}",
        cruise60.stretch,
        cruise120.stretch
    );
}

#[test]
fn teleport_re_anchors_without_energy() {
    let d = dynamics();
    d.advance((0.0, 0.0), 1.0 / 60.0);
    let pose = d.advance((4000.0, 0.0), 1.0 / 60.0);
    assert_eq!(pose, d.pose());
    assert!(
        (pose.stretch - 1.0).abs() < 1e-4,
        "teleport {}",
        pose.stretch
    );
    let resumed = cruise(&d, 800.0, 30);
    assert!(resumed.stretch > 1.01);
}

#[test]
fn vertical_travel_stretches_height_not_width() {
    let d = dynamics();
    let dt = 1.0 / 60.0;
    let mut y = 0.0;
    d.advance((0.0, 0.0), dt);
    let mut pose = d.pose();
    for _ in 0..30 {
        y += 1000.0 * dt;
        pose = d.advance((0.0, y), dt);
    }
    assert!(
        (1.30..1.34).contains(&pose.stretch),
        "stretch {}",
        pose.stretch
    );
    assert!((pose.stretch * pose.ortho - 1.0).abs() < 1e-4);
}

#[test]
fn reset_forgets_motion() {
    let d = dynamics();
    cruise(&d, 1200.0, 40);
    d.reset();
    assert_eq!(d.pose().stretch, 1.0);
    let pose = d.advance((500.0, 0.0), 1.0 / 60.0);
    assert!((pose.stretch - 1.0).abs() < 1e-4);
}

#[test]
fn a_pose_away_from_rest_asks_for_the_next_frame_until_it_rests() {
    const FRAME_NANOS: u64 = 16_666_667;
    type UpdatePose = fn(&LiquidDynamics, (f32, f32)) -> LiquidPose;
    let updates: [UpdatePose; 1] = [LiquidDynamics::update];
    for update in updates {
        let app_context = cranpose_ui::AppContext::new();
        let _scope = app_context.enter_scope();
        let runtime =
            cranpose_core::Runtime::new(std::sync::Arc::new(cranpose_core::DefaultScheduler));
        let clock = runtime.handle();
        let d = LiquidDynamics::new(runtime.handle());
        let mut now = 0;
        let mut x = 0.0;
        for _ in 0..20 {
            now += FRAME_NANOS;
            x += 12.0;
            clock.drain_frame_callbacks(now);
            update(&d, (x, 0.0));
        }
        assert!(
            cranpose_ui::take_render_invalidation(),
            "a travelling lens must ask for the next frame"
        );

        let mut rested_at = None;
        for frame in 1..=240 {
            now += FRAME_NANOS;
            clock.drain_frame_callbacks(now);
            let pose = update(&d, (x, 0.0));
            let asked = cranpose_ui::take_render_invalidation();
            if pose.stretch == 1.0 && pose.bulge_amplitude == 0.0 && pose.speed == 0.0 {
                assert!(
                    !asked,
                    "the frame that reaches rest must not ask for another"
                );
                rested_at = Some(frame);
                break;
            }
            assert!(
                asked,
                "frame {frame}: a lens still relaxing must ask for the next frame: {pose:?}"
            );
        }
        let rested_at = rested_at.expect("a stopped lens must come to rest");
        assert!(
            rested_at > 5,
            "the droplet should relax over several frames, not {rested_at}"
        );

        for _ in 0..3 {
            now += FRAME_NANOS;
            clock.drain_frame_callbacks(now);
            update(&d, (x, 0.0));
            assert!(
                !cranpose_ui::take_render_invalidation(),
                "a lens at rest must not ask for frames"
            );
        }
    }
}