use super::objects::CometDirection;
use egui::{
Color32, ColorImage, Context, Id, Mesh, Painter, Pos2, Shape, Stroke, TextureFilter,
TextureHandle, TextureOptions, TextureWrapMode, Vec2,
epaint::{CircleShape, PathShape, Vertex},
pos2,
};
use std::f32::consts::TAU;
use std::time::Instant;
pub const PULSE_DURATION: f32 = 3.5;
pub const RIPPLE_DURATION: f32 = 3.5;
pub const COUNTDOWN_DURATION: f32 = 5.0;
pub const SCALE_IN_DURATION: f32 = 0.45;
pub const CROSSHAIR_DURATION: f32 = 0.6;
pub const FLASH_DECAY_DURATION: f32 = 1.0;
pub const COMET_PERIOD: f32 = 1.6;
pub const COMET_TRAVEL_DURATION: f32 = 1.2;
pub const DASH_PERIOD_PX: f32 = 24.0;
pub const DASH_SPEED: f32 = 0.6;
pub const DASH_WIDTH: f32 = 3.0;
pub const WIPE_DURATION: f32 = 0.9;
pub const GLOW_BAND_PERIOD: f32 = 2.2;
pub const GLOW_BAND_LENGTH_PX: f32 = 40.0;
pub const GLOW_BAND_THICKNESS: f32 = 5.0;
pub const CHEVRON_PERIOD_PX: f32 = 28.0;
pub const CHEVRON_SPEED: f32 = 0.5;
pub const CHEVRON_WIDTH: f32 = 10.0;
fn with_alpha(color: Color32, alpha: f32) -> Color32 {
Color32::from_rgba_unmultiplied(
color.r(),
color.g(),
color.b(),
(255.0 * alpha.clamp(0.0, 1.0)).round() as u8,
)
}
fn elapsed(initial_time: Instant) -> f32 {
Instant::now().duration_since(initial_time).as_secs_f32()
}
fn triangle_wave(time: f32, period: f32) -> f32 {
let phase = (time / period).rem_euclid(1.0);
1.0 - (2.0 * phase - 1.0).abs()
}
fn ease_out_back(x: f32) -> f32 {
const C1: f32 = 1.701_58;
const C3: f32 = C1 + 1.0;
let x1 = x - 1.0;
1.0 + C3 * x1 * x1 * x1 + C1 * x1 * x1
}
pub struct Animation {}
impl Animation {
pub fn pulse(
painter: &Painter,
center: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
let secs = elapsed(initial_time);
let radius = (4.00 + (40.00 * secs)) * zoom;
let transparency = (1.00 - (secs / PULSE_DURATION).abs()).max(0.0);
painter.add(Shape::Circle(CircleShape::filled(
center,
radius,
with_alpha(color, transparency),
)));
secs < PULSE_DURATION
}
pub fn ripple(
painter: &Painter,
center: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
const RINGS: usize = 3;
let secs = elapsed(initial_time);
let stagger = RIPPLE_DURATION / RINGS as f32;
let mut shapes = Vec::with_capacity(RINGS);
for ring in 0..RINGS {
let local = secs - ring as f32 * stagger;
if !(0.0..RIPPLE_DURATION).contains(&local) {
continue;
}
let progress = local / RIPPLE_DURATION;
shapes.push(Shape::Circle(CircleShape::stroke(
center,
(4.0 + 36.0 * progress) * zoom,
Stroke::new(2.0 * zoom, with_alpha(color, 1.0 - progress)),
)));
}
painter.extend(shapes);
secs < RIPPLE_DURATION
}
pub fn countdown_arc(
painter: &Painter,
center: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
const STEPS: usize = 48;
let secs = elapsed(initial_time);
let remaining = (1.0 - secs / COUNTDOWN_DURATION).clamp(0.0, 1.0);
let radius = 10.0 * zoom;
let count = (STEPS as f32 * remaining).round() as usize;
if count >= 1 {
let points = (0..=count)
.map(|i| {
let angle = TAU * (i as f32 / STEPS as f32) - TAU / 4.0;
Pos2::new(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
)
})
.collect();
painter.add(Shape::Path(PathShape::line(
points,
Stroke::new(2.0 * zoom, with_alpha(color, 1.0)),
)));
}
secs < COUNTDOWN_DURATION
}
pub fn scale_in(
painter: &Painter,
center: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
let secs = elapsed(initial_time);
let progress = (secs / SCALE_IN_DURATION).clamp(0.0, 1.0);
let radius = 8.0 * zoom * ease_out_back(progress).max(0.0);
painter.add(Shape::Circle(CircleShape::filled(
center,
radius,
with_alpha(color, 1.0 - progress),
)));
secs < SCALE_IN_DURATION
}
pub fn crosshair(
painter: &Painter,
center: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
let secs = elapsed(initial_time);
let progress = (secs / CROSSHAIR_DURATION).clamp(0.0, 1.0);
let far = (30.0 - 18.0 * progress) * zoom;
let near = far - 8.0 * zoom;
let alpha = if progress < 0.66 {
1.0
} else {
1.0 - (progress - 0.66) / 0.34
};
let stroke = Stroke::new(2.0 * zoom, with_alpha(color, alpha));
let mut shapes = Vec::with_capacity(4);
for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
shapes.push(Shape::line_segment(
[
Pos2::new(center.x + dx * far, center.y + dy * far),
Pos2::new(center.x + dx * near, center.y + dy * near),
],
stroke,
));
}
painter.extend(shapes);
secs < CROSSHAIR_DURATION
}
pub fn flash_decay(
painter: &Painter,
a: Pos2,
b: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
let secs = elapsed(initial_time);
let progress = (secs / FLASH_DECAY_DURATION).clamp(0.0, 1.0);
let width = (2.0 + 10.0 * (1.0 - progress)) * zoom;
painter.line_segment(
[a, b],
Stroke::new(width, with_alpha(color, 1.0 - progress)),
);
secs < FLASH_DECAY_DURATION
}
pub fn comet_once(
painter: &Painter,
a: Pos2,
b: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
direction: CometDirection,
) -> bool {
let secs = elapsed(initial_time);
let progress = (secs / COMET_TRAVEL_DURATION).clamp(0.0, 1.0);
let (from, to) = match direction {
CometDirection::Forward => (a, b),
CometDirection::Reverse => (b, a),
};
let pos = from + (to - from) * progress;
painter.add(Shape::Circle(CircleShape::filled(
pos,
(4.0 * zoom).max(2.5),
color,
)));
secs < COMET_TRAVEL_DURATION
}
pub fn wipe(
painter: &Painter,
a: Pos2,
b: Pos2,
zoom: f32,
initial_time: Instant,
color: Color32,
) -> bool {
let secs = elapsed(initial_time);
let progress = (secs / WIPE_DURATION).clamp(0.0, 1.0);
let leading_edge = a + (b - a) * progress;
painter.line_segment([a, leading_edge], Stroke::new(2.5 * zoom, color));
secs < WIPE_DURATION
}
pub fn comet(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
let t = (time / COMET_PERIOD).rem_euclid(1.0);
let pos = a + (b - a) * t;
painter.add(Shape::Circle(CircleShape::filled(
pos,
(4.0 * zoom).max(2.5),
color,
)));
}
pub fn dash(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
let delta = b - a;
let len = delta.length();
if len <= f32::EPSILON {
return;
}
let dir = delta / len;
let normal = Vec2::new(-dir.y, dir.x) * (DASH_WIDTH * zoom * 0.5);
let phase = (time * DASH_SPEED).rem_euclid(1.0);
let u0 = phase;
let u1 = phase + len / DASH_PERIOD_PX;
let texture = Self::dash_texture(painter.ctx());
let mut mesh = Mesh::with_texture(texture.id());
mesh.vertices.extend([
Vertex {
pos: a + normal,
uv: pos2(u0, 0.5),
color,
},
Vertex {
pos: a - normal,
uv: pos2(u0, 0.5),
color,
},
Vertex {
pos: b + normal,
uv: pos2(u1, 0.5),
color,
},
Vertex {
pos: b - normal,
uv: pos2(u1, 0.5),
color,
},
]);
mesh.indices.extend([0, 1, 2, 2, 1, 3]);
painter.add(mesh);
}
fn dash_texture(ctx: &Context) -> TextureHandle {
let id = Id::new("egui_map::dash_texture");
if let Some(handle) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
return handle;
}
const WIDTH: usize = 32;
const FADE: usize = 3;
let half = WIDTH / 2;
let pixels = (0..WIDTH)
.map(|i| {
let alpha = if i < half - FADE {
255
} else if i < half + FADE {
let t = (i - (half - FADE)) as f32 / (2.0 * FADE as f32);
(255.0 * (1.0 - t)).round() as u8
} else {
0
};
Color32::from_white_alpha(alpha)
})
.collect();
let image = ColorImage::new([WIDTH, 1], pixels);
let handle = ctx.load_texture(
"egui_map::dash",
image,
TextureOptions {
magnification: TextureFilter::Linear,
minification: TextureFilter::Linear,
wrap_mode: TextureWrapMode::Repeat,
mipmap_mode: None,
},
);
ctx.data_mut(|d| d.insert_temp(id, handle.clone()));
handle
}
pub fn glow_band(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
let delta = b - a;
let len = delta.length();
if len <= f32::EPSILON {
return;
}
let dir = delta / len;
let normal = Vec2::new(-dir.y, dir.x) * (GLOW_BAND_THICKNESS * zoom * 0.5);
let half_width_frac = (GLOW_BAND_LENGTH_PX * 0.5 / len).min(0.5);
let span = 1.0 + 2.0 * half_width_frac;
let t = (time / GLOW_BAND_PERIOD).rem_euclid(1.0);
let peak = -half_width_frac + t * span;
let texture_u = |frac: f32| 0.5 + (frac - peak) / (2.0 * half_width_frac);
let texture = Self::glow_band_texture(painter.ctx());
let mut mesh = Mesh::with_texture(texture.id());
let u_a = texture_u(0.0);
let u_b = texture_u(1.0);
mesh.vertices.extend([
Vertex {
pos: a + normal,
uv: pos2(u_a, 0.5),
color,
},
Vertex {
pos: a - normal,
uv: pos2(u_a, 0.5),
color,
},
Vertex {
pos: b + normal,
uv: pos2(u_b, 0.5),
color,
},
Vertex {
pos: b - normal,
uv: pos2(u_b, 0.5),
color,
},
]);
mesh.indices.extend([0, 1, 2, 2, 1, 3]);
painter.add(mesh);
}
fn glow_band_texture(ctx: &Context) -> TextureHandle {
let id = Id::new("egui_map::glow_band_texture");
if let Some(handle) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
return handle;
}
const WIDTH: usize = 64;
let pixels = (0..WIDTH)
.map(|i| {
let u = i as f32 / (WIDTH - 1) as f32;
let distance_from_center = (u - 0.5).abs() * 2.0;
let alpha = (1.0 - distance_from_center).clamp(0.0, 1.0);
let alpha = alpha * alpha * (3.0 - 2.0 * alpha); Color32::from_white_alpha((255.0 * alpha).round() as u8)
})
.collect();
let image = ColorImage::new([WIDTH, 1], pixels);
let handle = ctx.load_texture(
"egui_map::glow_band",
image,
TextureOptions {
magnification: TextureFilter::Linear,
minification: TextureFilter::Linear,
wrap_mode: TextureWrapMode::ClampToEdge,
mipmap_mode: None,
},
);
ctx.data_mut(|d| d.insert_temp(id, handle.clone()));
handle
}
pub fn chevrons(painter: &Painter, a: Pos2, b: Pos2, zoom: f32, time: f32, color: Color32) {
let delta = b - a;
let len = delta.length();
if len <= f32::EPSILON {
return;
}
let dir = delta / len;
let normal = Vec2::new(-dir.y, dir.x) * (CHEVRON_WIDTH * zoom * 0.5);
let phase = (-(time * CHEVRON_SPEED)).rem_euclid(1.0);
let u0 = phase;
let u1 = phase + len / CHEVRON_PERIOD_PX;
let texture = Self::chevrons_texture(painter.ctx());
let mut mesh = Mesh::with_texture(texture.id());
mesh.vertices.extend([
Vertex {
pos: a + normal,
uv: pos2(u0, 0.0),
color,
},
Vertex {
pos: a - normal,
uv: pos2(u0, 1.0),
color,
},
Vertex {
pos: b + normal,
uv: pos2(u1, 0.0),
color,
},
Vertex {
pos: b - normal,
uv: pos2(u1, 1.0),
color,
},
]);
mesh.indices.extend([0, 1, 2, 2, 1, 3]);
painter.add(mesh);
}
fn chevrons_texture(ctx: &Context) -> TextureHandle {
let id = Id::new("egui_map::chevrons_texture");
if let Some(handle) = ctx.data(|d| d.get_temp::<TextureHandle>(id)) {
return handle;
}
const WIDTH: usize = 32;
const HEIGHT: usize = 16;
const TIP_U: f32 = 0.75;
const LEG_SLOPE: f32 = 0.5;
const STROKE_THICKNESS: f32 = 0.12;
let mut pixels = Vec::with_capacity(WIDTH * HEIGHT);
for j in 0..HEIGHT {
let v = j as f32 / (HEIGHT - 1) as f32;
let ideal_u = TIP_U - LEG_SLOPE * (v - 0.5).abs();
for i in 0..WIDTH {
let u = i as f32 / WIDTH as f32;
let distance = (u - ideal_u).abs();
let alpha = (1.0 - distance / STROKE_THICKNESS).clamp(0.0, 1.0);
let alpha = alpha * alpha * (3.0 - 2.0 * alpha); pixels.push(Color32::from_white_alpha((255.0 * alpha).round() as u8));
}
}
let image = ColorImage::new([WIDTH, HEIGHT], pixels);
let handle = ctx.load_texture(
"egui_map::chevrons",
image,
TextureOptions {
magnification: TextureFilter::Linear,
minification: TextureFilter::Linear,
wrap_mode: TextureWrapMode::Repeat,
mipmap_mode: None,
},
);
ctx.data_mut(|d| d.insert_temp(id, handle.clone()));
handle
}
pub fn halo(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
const PERIOD: f32 = 2.0;
let alpha = 0.30 + 0.45 * triangle_wave(time, PERIOD);
let radius = (9.0 * zoom).max(5.0);
painter.add(Shape::Circle(CircleShape::stroke(
center,
radius,
Stroke::new((2.0 * zoom).max(1.5), with_alpha(color, alpha)),
)));
}
pub fn blink(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
const PERIOD: f32 = 2.55;
painter.add(Shape::Circle(CircleShape::stroke(
center,
4.0 * zoom,
Stroke::new(9.0 * zoom, with_alpha(color, triangle_wave(time, PERIOD))),
)));
}
pub fn orbit(painter: &Painter, center: Pos2, zoom: f32, time: f32, color: Color32) {
const PERIOD: f32 = 3.0;
let radius = (12.0 * zoom).max(7.0);
let angle = TAU * (time / PERIOD).rem_euclid(1.0);
let dot = Pos2::new(
center.x + radius * angle.cos(),
center.y + radius * angle.sin(),
);
painter.extend([
Shape::Circle(CircleShape::stroke(
center,
radius,
Stroke::new(1.0, with_alpha(color, 0.25)),
)),
Shape::Circle(CircleShape::filled(
dot,
(2.5 * zoom).max(2.0),
with_alpha(color, 1.0),
)),
]);
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Context, LayerId, Rect, Vec2};
use std::time::Duration;
fn headless_painter() -> Painter {
Painter::new(
Context::default(),
LayerId::background(),
Rect::from_min_size(Pos2::ZERO, Vec2::new(100.0, 100.0)),
)
}
#[allow(clippy::type_complexity)]
fn event_effects() -> Vec<(
&'static str,
fn(&Painter, Pos2, f32, Instant, Color32) -> bool,
f32,
)> {
vec![
("pulse", Animation::pulse, PULSE_DURATION),
("ripple", Animation::ripple, RIPPLE_DURATION),
(
"countdown_arc",
Animation::countdown_arc,
COUNTDOWN_DURATION,
),
("scale_in", Animation::scale_in, SCALE_IN_DURATION),
("crosshair", Animation::crosshair, CROSSHAIR_DURATION),
]
}
#[test]
fn event_effects_report_running_then_finished() {
let painter = headless_painter();
for (name, effect, duration) in event_effects() {
assert!(
effect(&painter, Pos2::ZERO, 1.0, Instant::now(), Color32::RED),
"{name} must report it is still running when it just started"
);
let long_past = Instant::now() - Duration::from_secs_f32(duration + 1.0);
assert!(
!effect(&painter, Pos2::ZERO, 1.0, long_past, Color32::RED),
"{name} must report it is finished once its duration has passed"
);
}
}
#[test]
fn every_event_effect_stays_under_the_orphan_sweep() {
for (name, _, duration) in event_effects() {
assert!(
duration < 10.0,
"{name} lasts {duration}s, which the 10s orphan sweep would truncate"
);
}
}
#[test]
fn persistent_effects_run_at_any_time() {
let painter = headless_painter();
for time in [0.0, 0.7, 1.3, 60.0] {
Animation::halo(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
Animation::blink(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
Animation::orbit(&painter, Pos2::ZERO, 1.0, time, Color32::GREEN);
}
}
#[allow(clippy::type_complexity)]
fn segment_event_effects() -> Vec<(
&'static str,
fn(&Painter, Pos2, Pos2, f32, Instant, Color32) -> bool,
f32,
)> {
vec![
("flash_decay", Animation::flash_decay, FLASH_DECAY_DURATION),
("wipe", Animation::wipe, WIPE_DURATION),
]
}
#[test]
fn segment_event_effects_report_running_then_finished() {
let painter = headless_painter();
let a = Pos2::ZERO;
let b = Pos2::new(50.0, 0.0);
for (name, effect, duration) in segment_event_effects() {
assert!(
effect(&painter, a, b, 1.0, Instant::now(), Color32::RED),
"{name} must report it is still running when it just started"
);
let long_past = Instant::now() - Duration::from_secs_f32(duration + 1.0);
assert!(
!effect(&painter, a, b, 1.0, long_past, Color32::RED),
"{name} must report it is finished once its duration has passed"
);
}
}
#[test]
fn every_segment_event_effect_stays_under_the_orphan_sweep() {
for (name, _, duration) in segment_event_effects() {
assert!(
duration < 10.0,
"{name} lasts {duration}s, which the 10s orphan sweep would truncate"
);
}
}
#[test]
fn comet_runs_at_any_time_and_stays_on_the_segment() {
let painter = headless_painter();
let a = Pos2::ZERO;
let b = Pos2::new(50.0, 0.0);
for time in [0.0, 0.4, 0.8, 60.0] {
Animation::comet(&painter, a, b, 1.0, time, Color32::GREEN);
}
}
#[test]
fn comet_loops_back_to_the_start() {
let t0 = 0.2;
let t1 = t0 + COMET_PERIOD;
let at = |t: f32| {
let frac = (t / COMET_PERIOD).rem_euclid(1.0);
Pos2::ZERO + (Pos2::new(50.0, 0.0) - Pos2::ZERO) * frac
};
assert_eq!(at(t0), at(t1));
}
#[test]
fn comet_once_reports_running_then_finished() {
let painter = headless_painter();
let a = Pos2::ZERO;
let b = Pos2::new(50.0, 0.0);
assert!(
Animation::comet_once(
&painter,
a,
b,
1.0,
Instant::now(),
Color32::RED,
CometDirection::Forward,
),
"comet_once must report it is still running when it just started"
);
let long_past = Instant::now() - Duration::from_secs_f32(COMET_TRAVEL_DURATION + 1.0);
assert!(
!Animation::comet_once(
&painter,
a,
b,
1.0,
long_past,
Color32::RED,
CometDirection::Forward,
),
"comet_once must report it is finished once its duration has passed"
);
}
#[test]
#[allow(clippy::assertions_on_constants)]
fn comet_once_stays_under_the_orphan_sweep() {
assert!(
COMET_TRAVEL_DURATION < 10.0,
"comet_once lasts {COMET_TRAVEL_DURATION}s, which the 10s orphan sweep would truncate"
);
}
#[test]
fn comet_once_direction_picks_the_starting_endpoint() {
let a = Pos2::ZERO;
let b = Pos2::new(50.0, 0.0);
let start_pos = |direction: CometDirection| {
let secs = 0.0_f32;
let progress = (secs / COMET_TRAVEL_DURATION).clamp(0.0, 1.0);
let (from, to) = match direction {
CometDirection::Forward => (a, b),
CometDirection::Reverse => (b, a),
};
from + (to - from) * progress
};
assert_eq!(start_pos(CometDirection::Forward), a);
assert_eq!(start_pos(CometDirection::Reverse), b);
}
#[test]
fn wipe_progress_interpolates_toward_the_far_endpoint() {
let a = Pos2::ZERO;
let b = Pos2::new(50.0, 0.0);
let leading_edge = |progress: f32| a + (b - a) * progress;
assert_eq!(leading_edge(0.0), a, "must start exactly at `a`");
assert_eq!(leading_edge(1.0), b, "must finish exactly at `b`");
assert_eq!(leading_edge(0.5), Pos2::new(25.0, 0.0));
}
#[test]
fn dash_runs_at_any_time_and_skips_zero_length_segments() {
let painter = headless_painter();
let a = Pos2::ZERO;
let b = Pos2::new(50.0, 0.0);
for time in [0.0, 0.4, 0.8, 60.0] {
Animation::dash(&painter, a, b, 1.0, time, Color32::GREEN);
}
Animation::dash(&painter, a, a, 1.0, 0.0, Color32::GREEN);
}
#[test]
fn dash_texture_is_registered_once_per_context() {
let ctx = Context::default();
let first = Animation::dash_texture(&ctx);
let second = Animation::dash_texture(&ctx);
assert_eq!(first.id(), second.id());
}
#[test]
fn glow_band_runs_at_any_time_and_skips_zero_length_segments() {
let painter = headless_painter();
let a = Pos2::ZERO;
let b = Pos2::new(50.0, 0.0);
for time in [0.0, 0.4, 0.8, 60.0] {
Animation::glow_band(&painter, a, b, 1.0, time, Color32::GREEN);
}
Animation::glow_band(&painter, a, a, 1.0, 0.0, Color32::GREEN);
}
#[test]
fn glow_band_texture_is_registered_once_per_context() {
let ctx = Context::default();
let first = Animation::glow_band_texture(&ctx);
let second = Animation::glow_band_texture(&ctx);
assert_eq!(first.id(), second.id());
}
#[test]
fn chevrons_runs_at_any_time_and_skips_zero_length_segments() {
let painter = headless_painter();
let a = Pos2::ZERO;
let b = Pos2::new(50.0, 0.0);
for time in [0.0, 0.4, 0.8, 60.0] {
Animation::chevrons(&painter, a, b, 1.0, time, Color32::GREEN);
}
Animation::chevrons(&painter, a, a, 1.0, 0.0, Color32::GREEN);
}
#[test]
fn chevrons_texture_is_registered_once_per_context() {
let ctx = Context::default();
let first = Animation::chevrons_texture(&ctx);
let second = Animation::chevrons_texture(&ctx);
assert_eq!(first.id(), second.id());
}
#[test]
fn triangle_wave_goes_up_and_back_down() {
assert_eq!(triangle_wave(0.0, 2.0), 0.0);
assert_eq!(triangle_wave(1.0, 2.0), 1.0);
assert!(triangle_wave(2.0, 2.0).abs() < 1e-6);
assert!((triangle_wave(3.0, 2.0) - 1.0).abs() < 1e-6);
for step in 0..200 {
let v = triangle_wave(step as f32 * 0.05, 2.55);
assert!((0.0..=1.0).contains(&v), "{v} out of range");
}
}
#[test]
fn ease_out_back_overshoots_then_settles() {
assert_eq!(ease_out_back(0.0), 0.0);
assert!((ease_out_back(1.0) - 1.0).abs() < 1e-5);
let peak = (0..=100)
.map(|i| ease_out_back(i as f32 / 100.0))
.fold(f32::MIN, f32::max);
assert!(
peak > 1.0,
"ease_out_back should overshoot, peaked at {peak}"
);
}
#[test]
fn with_alpha_clamps_out_of_range_values() {
assert_eq!(with_alpha(Color32::RED, 2.0).a(), 255);
assert_eq!(with_alpha(Color32::RED, -1.0).a(), 0);
assert_eq!(with_alpha(Color32::RED, 1.0), Color32::RED);
}
}