use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "(f32, f32)", into = "(f32, f32)")]
pub struct Bounds {
lo: f32,
hi: f32,
}
impl Bounds {
#[must_use]
pub const fn new(lo: f32, hi: f32) -> Self {
assert!(
lo <= hi,
"Bounds::new: invalid range (lo > hi or NaN endpoint)"
);
Self { lo, hi }
}
pub fn try_new(lo: f32, hi: f32) -> Result<Self, BoundsError> {
if lo <= hi {
Ok(Self { lo, hi })
} else {
Err(BoundsError { lo, hi })
}
}
#[must_use]
pub const fn lo(&self) -> f32 {
self.lo
}
#[must_use]
pub const fn hi(&self) -> f32 {
self.hi
}
#[must_use]
pub const fn span(&self) -> f32 {
self.hi - self.lo
}
#[must_use]
pub fn clamp(&self, x: f32) -> f32 {
x.clamp(self.lo, self.hi)
}
pub fn clamp_slice(&self, xs: &mut [f32]) {
for x in xs.iter_mut() {
*x = self.clamp(*x);
}
}
}
impl TryFrom<(f32, f32)> for Bounds {
type Error = BoundsError;
fn try_from((lo, hi): (f32, f32)) -> Result<Self, Self::Error> {
Self::try_new(lo, hi)
}
}
impl From<Bounds> for (f32, f32) {
fn from(b: Bounds) -> Self {
(b.lo, b.hi)
}
}
#[derive(Debug, Clone, Copy, PartialEq, thiserror::Error)]
#[error("invalid bounds: lo {lo} must not exceed hi {hi} (and neither may be NaN)")]
pub struct BoundsError {
pub lo: f32,
pub hi: f32,
}
#[cfg(test)]
mod tests {
use super::{Bounds, BoundsError};
#[test]
fn new_accepts_ordered_and_single_point() {
assert_eq!(Bounds::new(-5.12, 5.12).lo(), -5.12);
assert_eq!(Bounds::new(-5.12, 5.12).hi(), 5.12);
let point = Bounds::new(3.0, 3.0);
assert_eq!(point.lo(), point.hi());
assert_eq!(point.span(), 0.0);
}
#[test]
#[should_panic(expected = "invalid range")]
fn new_panics_on_inverted() {
let _ = Bounds::new(1.0, -1.0);
}
#[test]
#[should_panic(expected = "invalid range")]
fn new_panics_on_nan() {
let _ = Bounds::new(0.0, f32::NAN);
}
#[test]
fn try_new_accepts_valid_including_one_sided_infinite() {
assert!(Bounds::try_new(-1.0, 1.0).is_ok());
assert!(Bounds::try_new(2.0, 2.0).is_ok()); assert!(Bounds::try_new(0.7, f32::INFINITY).is_ok());
assert!(Bounds::try_new(f32::NEG_INFINITY, 0.0).is_ok());
}
#[test]
fn try_new_rejects_inverted_and_nan() {
assert_eq!(
Bounds::try_new(1.0, -1.0),
Err(BoundsError { lo: 1.0, hi: -1.0 })
);
assert!(Bounds::try_new(f32::NAN, 1.0).is_err());
assert!(Bounds::try_new(0.0, f32::NAN).is_err());
assert!(Bounds::try_new(f32::NAN, f32::NAN).is_err());
}
#[test]
fn span_is_nonnegative_width() {
assert_eq!(Bounds::new(-2.0, 3.0).span(), 5.0);
assert_eq!(Bounds::new(4.0, 4.0).span(), 0.0);
}
#[test]
fn clamp_below_within_and_above() {
let b = Bounds::new(-1.0, 1.0);
assert_eq!(b.clamp(-9.0), -1.0);
assert_eq!(b.clamp(0.25), 0.25);
assert_eq!(b.clamp(2.5), 1.0);
}
#[test]
fn clamp_on_single_point_pins_to_constant() {
let b = Bounds::new(3.0, 3.0);
assert_eq!(b.clamp(0.0), 3.0);
assert_eq!(b.clamp(9.0), 3.0);
}
#[test]
fn clamp_slice_clamps_in_place() {
let b = Bounds::new(0.0, 10.0);
let mut xs = [-5.0, 3.0, 42.0, 10.0];
b.clamp_slice(&mut xs);
assert_eq!(xs, [0.0, 3.0, 10.0, 10.0]);
}
#[test]
fn tuple_round_trip_via_try_from_and_into() {
let b = Bounds::try_from((-2.0, 6.0)).unwrap();
let back: (f32, f32) = b.into();
assert_eq!(back, (-2.0, 6.0));
assert!(Bounds::try_from((6.0, -2.0)).is_err());
}
#[test]
fn error_display_names_both_endpoints() {
let s = BoundsError { lo: 5.0, hi: 1.0 }.to_string();
assert!(s.contains('5'));
assert!(s.contains('1'));
assert!(s.contains("must not exceed"));
}
}