retroglyph_ui/animate/
oscillate.rs1use core::time::Duration;
2
3#[must_use]
23pub fn oscillate(elapsed: Duration, period: Duration) -> f32 {
24 oscillate_with_phase(elapsed, period, 0.0)
25}
26
27#[must_use]
46pub fn oscillate_with_phase(elapsed: Duration, period: Duration, phase: f32) -> f32 {
47 if period.is_zero() {
48 return 0.5;
49 }
50 let cycles = elapsed.as_secs_f32() / period.as_secs_f32() + phase; let radians = cycles * 2.0 * core::f32::consts::PI;
52 retroglyph_core::math::mul_add(0.5, retroglyph_core::math::sin(radians), 0.5)
53}
54
55#[cfg(test)]
56#[allow(clippy::float_cmp)]
57mod tests {
58 use super::*;
59
60 #[test]
61 fn oscillate_at_zero_elapsed_is_the_midpoint() {
62 assert_eq!(oscillate(Duration::ZERO, Duration::from_secs(1)), 0.5);
64 }
65
66 #[test]
67 fn oscillate_completes_a_full_cycle_after_one_period() {
68 let period = Duration::from_secs(4);
69 let start = oscillate(Duration::ZERO, period);
70 let one_cycle_later = oscillate(period, period);
71 assert!((start - one_cycle_later).abs() < 1e-5);
72 }
73
74 #[test]
75 fn oscillate_stays_within_0_and_1() {
76 let period = Duration::from_secs(1);
77 for ms in 0..2000u64 {
78 let v = oscillate(Duration::from_millis(ms), period);
79 assert!((0.0..=1.0).contains(&v), "{v} out of range at {ms}ms");
80 }
81 }
82
83 #[test]
84 fn zero_period_is_a_defined_constant_not_a_panic() {
85 assert_eq!(oscillate(Duration::from_secs(1), Duration::ZERO), 0.5);
86 }
87
88 #[test]
89 fn zero_phase_matches_plain_oscillate() {
90 let period = Duration::from_secs(1);
91 for ms in 0..2000u64 {
92 let elapsed = Duration::from_millis(ms);
93 assert_eq!(
94 oscillate_with_phase(elapsed, period, 0.0),
95 oscillate(elapsed, period)
96 );
97 }
98 }
99
100 #[test]
101 fn phase_offset_shifts_the_sampled_point_in_the_cycle() {
102 let period = Duration::from_secs(2);
103 let a = oscillate_with_phase(Duration::ZERO, period, 0.25);
104 let b = oscillate_with_phase(Duration::from_millis(500), period, 0.0);
105 assert!((a - b).abs() < 1e-6);
106 }
107
108 #[test]
109 fn phase_offset_by_a_whole_number_of_cycles_is_no_offset() {
110 let period = Duration::from_secs(1);
111 let elapsed = Duration::from_millis(137);
112 let a = oscillate_with_phase(elapsed, period, 0.0);
113 let b = oscillate_with_phase(elapsed, period, 3.0);
114 assert!((a - b).abs() < 1e-5);
115 }
116
117 #[test]
118 fn negative_phase_is_defined() {
119 let period = Duration::from_secs(2);
120 let a = oscillate_with_phase(Duration::from_millis(500), period, -0.25);
121 let b = oscillate_with_phase(Duration::ZERO, period, 0.0);
122 assert!((a - b).abs() < 1e-6);
123 }
124
125 #[test]
126 fn phase_offset_stays_within_0_and_1() {
127 let period = Duration::from_secs(1);
128 for ms in 0..2000u64 {
129 let v = oscillate_with_phase(Duration::from_millis(ms), period, 0.37);
130 assert!((0.0..=1.0).contains(&v), "{v} out of range at {ms}ms");
131 }
132 }
133
134 #[test]
135 fn zero_period_with_phase_is_a_defined_constant_not_a_panic() {
136 assert_eq!(
137 oscillate_with_phase(Duration::from_secs(1), Duration::ZERO, 0.25),
138 0.5
139 );
140 }
141}