Skip to main content

use_bounds/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use use_coordinate::GeometryError;
5use use_point::Point2;
6
7/// An axis-aligned bounding box represented by inclusive minimum and maximum corners.
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub struct Aabb2 {
10    min: Point2,
11    max: Point2,
12}
13
14impl Aabb2 {
15    /// Creates a validated axis-aligned bounding box from ordered corners.
16    ///
17    /// # Errors
18    ///
19    /// Returns [`GeometryError::NonFiniteComponent`] when either corner
20    /// contains a non-finite coordinate.
21    ///
22    /// Returns [`GeometryError::InvalidBounds`] when `min` is greater than
23    /// `max` on either axis.
24    pub fn try_new(min: Point2, max: Point2) -> Result<Self, GeometryError> {
25        let min = min.validate()?;
26        let max = max.validate()?;
27
28        if min.x() > max.x() || min.y() > max.y() {
29            return Err(GeometryError::InvalidBounds {
30                min_x: min.x(),
31                min_y: min.y(),
32                max_x: max.x(),
33                max_y: max.y(),
34            });
35        }
36
37        Ok(Self { min, max })
38    }
39
40    /// Creates a bounding box from any two corners, normalizing axis order.
41    #[must_use]
42    pub const fn from_points(a: Point2, b: Point2) -> Self {
43        Self {
44            min: Point2::new(a.x().min(b.x()), a.y().min(b.y())),
45            max: Point2::new(a.x().max(b.x()), a.y().max(b.y())),
46        }
47    }
48
49    /// Creates the degenerate bounding box rooted at a single point.
50    #[must_use]
51    pub const fn from_point(point: Point2) -> Self {
52        Self::from_points(point, point)
53    }
54
55    /// Returns the inclusive minimum corner.
56    #[must_use]
57    pub const fn min(&self) -> Point2 {
58        self.min
59    }
60
61    /// Returns the inclusive maximum corner.
62    #[must_use]
63    pub const fn max(&self) -> Point2 {
64        self.max
65    }
66
67    /// Returns the box width.
68    #[must_use]
69    pub fn width(&self) -> f64 {
70        self.max.x() - self.min.x()
71    }
72
73    /// Returns the box height.
74    #[must_use]
75    pub fn height(&self) -> f64 {
76        self.max.y() - self.min.y()
77    }
78
79    /// Returns the box center.
80    #[must_use]
81    pub const fn center(&self) -> Point2 {
82        self.min.midpoint(self.max)
83    }
84
85    /// Returns the box area.
86    #[must_use]
87    pub fn area(&self) -> f64 {
88        self.width() * self.height()
89    }
90
91    /// Returns `true` when `point` lies inside or on the boundary.
92    #[must_use]
93    pub fn contains_point(&self, point: Point2) -> bool {
94        point.x() >= self.min.x()
95            && point.x() <= self.max.x()
96            && point.y() >= self.min.y()
97            && point.y() <= self.max.y()
98    }
99
100    /// Returns `true` when `point` lies inside the box expanded by `tolerance`.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`GeometryError::NonFiniteTolerance`] when `tolerance` is `NaN`
105    /// or infinite.
106    ///
107    /// Returns [`GeometryError::NegativeTolerance`] when `tolerance` is negative.
108    pub fn contains_point_with_tolerance(
109        &self,
110        point: Point2,
111        tolerance: f64,
112    ) -> Result<bool, GeometryError> {
113        let tolerance = GeometryError::validate_tolerance(tolerance)?;
114
115        Ok(point.x() >= self.min.x() - tolerance
116            && point.x() <= self.max.x() + tolerance
117            && point.y() >= self.min.y() - tolerance
118            && point.y() <= self.max.y() + tolerance)
119    }
120
121    /// Returns `true` when the box has zero width or height.
122    #[must_use]
123    pub fn is_degenerate(&self) -> bool {
124        self.width() == 0.0 || self.height() == 0.0
125    }
126
127    /// Returns `true` when the box width or height is within `tolerance` of zero.
128    ///
129    /// # Errors
130    ///
131    /// Returns [`GeometryError::NonFiniteTolerance`] when `tolerance` is `NaN`
132    /// or infinite.
133    ///
134    /// Returns [`GeometryError::NegativeTolerance`] when `tolerance` is negative.
135    pub fn is_degenerate_with_tolerance(&self, tolerance: f64) -> Result<bool, GeometryError> {
136        let tolerance = GeometryError::validate_tolerance(tolerance)?;
137
138        Ok(self.width() <= tolerance || self.height() <= tolerance)
139    }
140}
141
142impl From<Point2> for Aabb2 {
143    fn from(point: Point2) -> Self {
144        Self::from_point(point)
145    }
146}
147
148/// Creates a bounding box from any two corners.
149#[must_use]
150pub const fn aabb_from_points(a: Point2, b: Point2) -> Aabb2 {
151    Aabb2::from_points(a, b)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::{Aabb2, aabb_from_points};
157    use use_coordinate::GeometryError;
158    use use_point::Point2;
159
160    fn approx_eq(left: f64, right: f64) -> bool {
161        (left - right).abs() < 1.0e-10
162    }
163
164    #[test]
165    fn constructs_valid_aabbs() {
166        let bounds =
167            Aabb2::try_new(Point2::new(1.0, 2.0), Point2::new(4.0, 6.0)).expect("valid bounds");
168
169        assert_eq!(bounds.min(), Point2::new(1.0, 2.0));
170        assert_eq!(bounds.max(), Point2::new(4.0, 6.0));
171    }
172
173    #[test]
174    fn rejects_invalid_aabb_ordering() {
175        assert_eq!(
176            Aabb2::try_new(Point2::new(4.0, 1.0), Point2::new(1.0, 3.0)),
177            Err(GeometryError::InvalidBounds {
178                min_x: 4.0,
179                min_y: 1.0,
180                max_x: 1.0,
181                max_y: 3.0,
182            })
183        );
184    }
185
186    #[test]
187    fn normalizes_point_order() {
188        let bounds = Aabb2::from_points(Point2::new(4.0, 1.0), Point2::new(1.0, 3.0));
189
190        assert_eq!(bounds.min(), Point2::new(1.0, 1.0));
191        assert_eq!(bounds.max(), Point2::new(4.0, 3.0));
192        assert_eq!(
193            aabb_from_points(Point2::new(4.0, 1.0), Point2::new(1.0, 3.0)),
194            bounds
195        );
196    }
197
198    #[test]
199    fn computes_dimensions_center_and_area() {
200        let bounds = Aabb2::from_points(Point2::new(1.0, 1.0), Point2::new(4.0, 3.0));
201
202        assert!(approx_eq(bounds.width(), 3.0));
203        assert!(approx_eq(bounds.height(), 2.0));
204        assert_eq!(bounds.center(), Point2::new(2.5, 2.0));
205        assert!(approx_eq(bounds.area(), 6.0));
206    }
207
208    #[test]
209    fn contains_points_including_boundary() {
210        let bounds = Aabb2::from_points(Point2::new(1.0, 1.0), Point2::new(4.0, 3.0));
211
212        assert!(bounds.contains_point(Point2::new(2.0, 2.0)));
213        assert!(bounds.contains_point(Point2::new(1.0, 3.0)));
214        assert!(!bounds.contains_point(Point2::new(4.5, 3.0)));
215    }
216
217    #[test]
218    fn supports_tolerance_based_containment() {
219        let bounds = Aabb2::from_points(Point2::new(1.0, 1.0), Point2::new(4.0, 3.0));
220
221        assert_eq!(
222            bounds.contains_point_with_tolerance(Point2::new(4.25, 3.0), 0.25),
223            Ok(true)
224        );
225        assert_eq!(
226            bounds.contains_point_with_tolerance(Point2::new(4.25, 3.0), -0.25),
227            Err(GeometryError::NegativeTolerance(-0.25))
228        );
229    }
230
231    #[test]
232    fn detects_degenerate_bounds() {
233        let point_bounds = Aabb2::from_point(Point2::new(2.0, 2.0));
234        let line_bounds = Aabb2::from_points(Point2::new(2.0, 1.0), Point2::new(2.0, 3.0));
235
236        assert!(point_bounds.is_degenerate());
237        assert!(line_bounds.is_degenerate());
238        assert_eq!(line_bounds.is_degenerate_with_tolerance(0.0), Ok(true));
239        assert_eq!(
240            Aabb2::from(Point2::new(1.0, 1.0)),
241            Aabb2::from_point(Point2::new(1.0, 1.0))
242        );
243    }
244}