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)
}
pub fn shadow_set(holders: &[(f32, bool)], budget: usize, hysteresis: f32) -> Vec<bool> {
let mut out: Vec<bool> = holders.iter().map(|(_, shadowed)| *shadowed).collect();
let mut count = out.iter().filter(|s| **s).count();
for shadowed in out.iter_mut().rev() {
if count <= budget {
break;
}
if *shadowed {
*shadowed = false;
count -= 1;
}
}
for shadowed in out.iter_mut() {
if count >= budget {
break;
}
if !*shadowed {
*shadowed = true;
count += 1;
}
}
loop {
let best_without = out.iter().position(|s| !*s);
let worst_with = out.iter().rposition(|s| *s);
let (Some(challenger), Some(held)) = (best_without, worst_with) else { break };
if challenger > held || !preferred(holders[held].0, holders[challenger].0, hysteresis) {
break;
}
out[challenger] = true;
out[held] = false;
}
out
}
#[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");
}
#[test]
fn the_shadow_budget_goes_to_the_best_ranked_and_holds_against_a_near_tie() {
let unshadowed = [(1.0, false), (2.0, false), (3.0, false), (4.0, false)];
assert_eq!(shadow_set(&unshadowed, 2, HYSTERESIS), [true, true, false, false], "fill best first");
assert_eq!(shadow_set(&unshadowed, 0, HYSTERESIS), [false; 4], "a zero budget shadows nothing");
assert_eq!(shadow_set(&unshadowed, 9, HYSTERESIS), [true; 4], "a budget past the pool shadows all");
let held = [(1.0, false), (2.0, true), (3.6, false), (4.0, true)];
assert_eq!(shadow_set(&held, 2, HYSTERESIS), [true, true, false, false]);
let near_tie = [(2.0, true), (3.6, false), (4.0, true)];
assert_eq!(shadow_set(&near_tie, 2, HYSTERESIS), [true, false, true], "a 10 % challenger waits");
let clear = [(2.0, true), (3.3, false), (4.0, true)];
assert_eq!(shadow_set(&clear, 2, HYSTERESIS), [true, true, false], "a 21 % challenger wins");
assert_eq!(shadow_set(&[(1.0, true), (2.0, true), (3.0, true)], 1, HYSTERESIS), [true, false, false]);
assert_eq!(shadow_set(&[(1.0, true), (5.0, false)], 1, 1.0), [true, false]);
}
}