pub const HYSTERESIS: f32 = 1.15;
pub const FADE_SECONDS: f32 = 0.35;
pub fn hand_off(held_distance: f32, challenger_distance: f32, hysteresis: f32) -> bool {
if challenger_distance.is_nan() {
return false;
}
if held_distance.is_nan() {
return true;
}
preferred(held_distance.max(0.0).sqrt(), challenger_distance.max(0.0).sqrt(), hysteresis)
}
pub fn preferred(held: f32, challenger: f32, hysteresis: f32) -> bool {
if challenger.is_nan() {
return false;
}
if !held.is_finite() {
return true;
}
if held.is_nan() {
return true;
}
let margin = if hysteresis.is_nan() { 1.0 } else { hysteresis.max(1.0) };
challenger.max(0.0) * margin < held.max(0.0)
}
pub fn fade_out_factor(since: f32, fade_seconds: f32) -> f32 {
1.0 - fade_factor(since, fade_seconds)
}
pub fn fade_factor(since: f32, fade_seconds: f32) -> f32 {
if since.is_nan() || fade_seconds <= 0.0 {
return if since.is_nan() { 0.0 } else { 1.0 };
}
let s = (since / fade_seconds).clamp(0.0, 1.0);
s * s * (3.0 - 2.0 * s)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn a_held_light_stays_until_a_challenger_is_meaningfully_nearer() {
let held = 16.0;
assert!(!hand_off(held, 15.5, HYSTERESIS), "3.937 m does not win");
assert!(!hand_off(held, 12.25, HYSTERESIS), "3.5 m does not win");
assert!(hand_off(held, 12.0, HYSTERESIS), "3.464 m wins");
assert!(hand_off(held, 4.0, HYSTERESIS), "2 m wins");
}
#[test]
fn no_held_candidate_takes_the_nearest_at_once() {
assert!(hand_off(f32::INFINITY, 100.0, HYSTERESIS));
assert!(hand_off(f32::NAN, 100.0, HYSTERESIS));
assert!(!hand_off(1.0, f32::NAN, HYSTERESIS), "a broken distance does not displace a held light");
}
#[test]
fn a_hysteresis_of_one_is_the_old_nearest_wins() {
assert!(hand_off(16.0, 15.99, 1.0), "any nearer challenger wins at 1.0");
assert!(!hand_off(16.0, 16.01, 1.0), "a further one never does");
assert!(hand_off(16.0, 15.99, 0.5), "below 1.0 clamps to the nearest-wins rule");
}
#[test]
fn a_cross_fade_sums_to_one_at_every_point() {
let mut since = 0.0;
while since <= FADE_SECONDS {
let out = fade_out_factor(since, FADE_SECONDS);
let incoming = fade_factor(since, FADE_SECONDS);
assert!((out + incoming - 1.0).abs() < 1e-6, "at {since}: {out} + {incoming}");
assert!((0.0..=1.0).contains(&out), "{out} at {since}");
since += 0.005;
}
assert_eq!(fade_out_factor(0.0, FADE_SECONDS), 1.0, "a departing light starts whole");
assert_eq!(fade_out_factor(FADE_SECONDS, FADE_SECONDS), 0.0, "and ends gone");
}
#[test]
fn the_fade_runs_from_nothing_to_full_and_stays_there() {
assert_eq!(fade_factor(0.0, FADE_SECONDS), 0.0);
assert!((fade_factor(FADE_SECONDS / 2.0, FADE_SECONDS) - 0.5).abs() < 1e-5, "half at half time");
assert_eq!(fade_factor(FADE_SECONDS, FADE_SECONDS), 1.0);
assert_eq!(fade_factor(FADE_SECONDS * 3.0, FADE_SECONDS), 1.0);
assert_eq!(fade_factor(-1.0, FADE_SECONDS), 0.0, "a negative age is the start of the fade");
assert!(fade_factor(f32::NAN, FADE_SECONDS) == 0.0);
assert_eq!(fade_factor(0.0, 0.0), 1.0);
let mut last = 0.0;
let mut t = 0.0;
while t <= FADE_SECONDS {
let f = fade_factor(t, FADE_SECONDS);
assert!(f >= last, "the fade fell at {t}");
assert!((0.0..=1.0).contains(&f), "{f} at {t}");
last = f;
t += 0.005;
}
assert!(fade_factor(0.001, FADE_SECONDS) < 1e-4, "the start has no step");
assert!(fade_factor(FADE_SECONDS - 0.001, FADE_SECONDS) > 1.0 - 1e-4, "nor the end");
}
}