Skip to main content

scirs2_spatial/geo/
bbox.rs

1//! Geographic bounding box and polygon operations.
2//!
3//! Provides:
4//! - `BoundingBox` for axis-aligned geographic rectangles
5//! - `polygon_area` using the Shoelace formula (result in m²)
6//! - `point_in_polygon` using ray-casting (works for (lat, lon) pairs)
7
8use super::coordinates::EARTH_RADIUS_M;
9use crate::error::{SpatialError, SpatialResult};
10use std::f64::consts::PI;
11
12/// An axis-aligned geographic bounding box defined by min/max lat and lon.
13///
14/// Coordinates are in degrees (latitude: [-90, 90], longitude: [-180, 180]).
15/// This struct does **not** handle anti-meridian crossing (bounding boxes
16/// that wrap around longitude ±180°). For those use cases, split into two boxes.
17///
18/// # Examples
19///
20/// ```
21/// use scirs2_spatial::geo::BoundingBox;
22///
23/// let bbox = BoundingBox::new(48.0, 52.0, -1.0, 3.0).unwrap();
24/// assert!(bbox.contains(50.0, 1.0));
25/// assert!(!bbox.contains(53.0, 1.0));
26/// ```
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct BoundingBox {
29    /// Minimum latitude in degrees
30    pub min_lat: f64,
31    /// Maximum latitude in degrees
32    pub max_lat: f64,
33    /// Minimum longitude in degrees
34    pub min_lon: f64,
35    /// Maximum longitude in degrees
36    pub max_lon: f64,
37}
38
39impl BoundingBox {
40    /// Create a new `BoundingBox`.
41    ///
42    /// # Arguments
43    ///
44    /// * `min_lat` - Minimum latitude in degrees
45    /// * `max_lat` - Maximum latitude in degrees
46    /// * `min_lon` - Minimum longitude in degrees
47    /// * `max_lon` - Maximum longitude in degrees
48    ///
49    /// # Errors
50    ///
51    /// Returns `SpatialError::ValueError` if:
52    /// - `min_lat > max_lat`
53    /// - `min_lon > max_lon`
54    /// - Any value is out of range (lat ∉ [-90, 90], lon ∉ [-180, 180])
55    pub fn new(min_lat: f64, max_lat: f64, min_lon: f64, max_lon: f64) -> SpatialResult<Self> {
56        if min_lat > max_lat {
57            return Err(SpatialError::ValueError(format!(
58                "min_lat ({min_lat}) > max_lat ({max_lat})"
59            )));
60        }
61        if min_lon > max_lon {
62            return Err(SpatialError::ValueError(format!(
63                "min_lon ({min_lon}) > max_lon ({max_lon})"
64            )));
65        }
66        if !(-90.0..=90.0).contains(&min_lat) || !(-90.0..=90.0).contains(&max_lat) {
67            return Err(SpatialError::ValueError(
68                "Latitude values must be in range [-90, 90]".to_string(),
69            ));
70        }
71        if !(-180.0..=180.0).contains(&min_lon) || !(-180.0..=180.0).contains(&max_lon) {
72            return Err(SpatialError::ValueError(
73                "Longitude values must be in range [-180, 180]".to_string(),
74            ));
75        }
76        Ok(BoundingBox {
77            min_lat,
78            max_lat,
79            min_lon,
80            max_lon,
81        })
82    }
83
84    /// Create a `BoundingBox` from a set of geographic points.
85    ///
86    /// # Arguments
87    ///
88    /// * `points` - Slice of `(lat, lon)` pairs in degrees
89    ///
90    /// # Errors
91    ///
92    /// Returns `SpatialError::ValueError` if the slice is empty.
93    pub fn from_points(points: &[(f64, f64)]) -> SpatialResult<Self> {
94        if points.is_empty() {
95            return Err(SpatialError::ValueError(
96                "Cannot create BoundingBox from empty point set".to_string(),
97            ));
98        }
99        let mut min_lat = f64::INFINITY;
100        let mut max_lat = f64::NEG_INFINITY;
101        let mut min_lon = f64::INFINITY;
102        let mut max_lon = f64::NEG_INFINITY;
103
104        for &(lat, lon) in points {
105            if lat < min_lat {
106                min_lat = lat;
107            }
108            if lat > max_lat {
109                max_lat = lat;
110            }
111            if lon < min_lon {
112                min_lon = lon;
113            }
114            if lon > max_lon {
115                max_lon = lon;
116            }
117        }
118        Ok(BoundingBox {
119            min_lat,
120            max_lat,
121            min_lon,
122            max_lon,
123        })
124    }
125
126    /// Test whether a geographic point is inside (or on the boundary of) this box.
127    ///
128    /// # Arguments
129    ///
130    /// * `lat` - Latitude in degrees
131    /// * `lon` - Longitude in degrees
132    pub fn contains(&self, lat: f64, lon: f64) -> bool {
133        lat >= self.min_lat && lat <= self.max_lat && lon >= self.min_lon && lon <= self.max_lon
134    }
135
136    /// Test whether two bounding boxes overlap (share any area or boundary point).
137    pub fn intersects(&self, other: &BoundingBox) -> bool {
138        self.min_lat <= other.max_lat
139            && self.max_lat >= other.min_lat
140            && self.min_lon <= other.max_lon
141            && self.max_lon >= other.min_lon
142    }
143
144    /// Return a new `BoundingBox` expanded by `margin_deg` degrees in all four directions.
145    ///
146    /// Clamps the result to valid lat/lon ranges so the returned box is always valid.
147    ///
148    /// # Arguments
149    ///
150    /// * `margin_deg` - Expansion amount in degrees (must be ≥ 0)
151    ///
152    /// # Errors
153    ///
154    /// Returns `SpatialError::ValueError` if `margin_deg` is negative.
155    pub fn expand(&self, margin_deg: f64) -> SpatialResult<BoundingBox> {
156        if margin_deg < 0.0 {
157            return Err(SpatialError::ValueError(format!(
158                "margin_deg ({margin_deg}) must be non-negative"
159            )));
160        }
161        Ok(BoundingBox {
162            min_lat: (self.min_lat - margin_deg).max(-90.0),
163            max_lat: (self.max_lat + margin_deg).min(90.0),
164            min_lon: (self.min_lon - margin_deg).max(-180.0),
165            max_lon: (self.max_lon + margin_deg).min(180.0),
166        })
167    }
168
169    /// Return the center `(lat, lon)` of the bounding box.
170    pub fn center(&self) -> (f64, f64) {
171        (
172            (self.min_lat + self.max_lat) / 2.0,
173            (self.min_lon + self.max_lon) / 2.0,
174        )
175    }
176
177    /// Return the width of the bounding box in degrees (longitude span).
178    pub fn width_deg(&self) -> f64 {
179        self.max_lon - self.min_lon
180    }
181
182    /// Return the height of the bounding box in degrees (latitude span).
183    pub fn height_deg(&self) -> f64 {
184        self.max_lat - self.min_lat
185    }
186
187    /// Approximate area of the bounding box in square metres.
188    ///
189    /// Uses the spherical Earth model. Suitable for boxes up to a few hundred km wide.
190    pub fn area_m2(&self) -> f64 {
191        let lat_center = (self.min_lat + self.max_lat) / 2.0;
192        let m_per_deg_lat = PI / 180.0 * EARTH_RADIUS_M;
193        let m_per_deg_lon = PI / 180.0 * EARTH_RADIUS_M * lat_center.to_radians().cos();
194        self.height_deg() * m_per_deg_lat * self.width_deg() * m_per_deg_lon
195    }
196}
197
198/// Compute the area of a planar polygon given as `(lat, lon)` pairs using the
199/// Shoelace (Gauss's area) formula projected onto the Earth's surface.
200///
201/// This function treats coordinates as planar (i.e. uses a flat-Earth approximation
202/// at the centroid latitude) and returns a result in square metres. It is accurate
203/// for polygons up to ~100 km across. For larger polygons, use spherical methods.
204///
205/// The polygon can be open (first ≠ last) or closed (first == last); the algorithm
206/// handles both.
207///
208/// # Arguments
209///
210/// * `coords` - Slice of `(lat, lon)` pairs in degrees. Must have at least 3 points.
211///
212/// # Returns
213///
214/// Area in square metres (always positive regardless of vertex winding).
215///
216/// # Errors
217///
218/// Returns `SpatialError::ValueError` if fewer than 3 coordinate pairs are given.
219///
220/// # Examples
221///
222/// ```
223/// use scirs2_spatial::geo::polygon_area;
224///
225/// // Approximately 1° × 1° square near the equator (~12,321 km²)
226/// let square = [(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)];
227/// let area = polygon_area(&square).unwrap();
228/// assert!((area - 12_321_000_000.0_f64).abs() / 12_321_000_000.0 < 0.01);
229/// ```
230pub fn polygon_area(coords: &[(f64, f64)]) -> SpatialResult<f64> {
231    if coords.len() < 3 {
232        return Err(SpatialError::ValueError(
233            "Polygon must have at least 3 vertices".to_string(),
234        ));
235    }
236
237    // Compute the centroid latitude for the projection
238    let centroid_lat = coords.iter().map(|(lat, _)| lat).sum::<f64>() / coords.len() as f64;
239    let m_per_deg_lat = PI / 180.0 * EARTH_RADIUS_M;
240    let m_per_deg_lon = PI / 180.0 * EARTH_RADIUS_M * centroid_lat.to_radians().cos();
241
242    // Shoelace formula in projected (metre) space
243    let n = coords.len();
244    let mut area2 = 0.0_f64;
245    for i in 0..n {
246        let j = (i + 1) % n;
247        let xi = coords[i].1 * m_per_deg_lon; // lon → x (easting)
248        let yi = coords[i].0 * m_per_deg_lat; // lat → y (northing)
249        let xj = coords[j].1 * m_per_deg_lon;
250        let yj = coords[j].0 * m_per_deg_lat;
251        area2 += xi * yj - xj * yi;
252    }
253
254    Ok(area2.abs() / 2.0)
255}
256
257/// Test whether a point is inside a polygon using the ray-casting algorithm.
258///
259/// Works for general (non-convex) polygons in geographic coordinates. The polygon
260/// can be open or closed (first vertex need not equal last). This uses a planar
261/// algorithm which is appropriate for polygons smaller than a continent.
262///
263/// Points exactly on an edge are considered inside.
264///
265/// # Arguments
266///
267/// * `lat` - Latitude of the test point in degrees
268/// * `lon` - Longitude of the test point in degrees
269/// * `polygon` - Slice of `(lat, lon)` pairs defining the polygon boundary
270///
271/// # Returns
272///
273/// `true` if the point is inside (or on the boundary of) the polygon.
274///
275/// # Examples
276///
277/// ```
278/// use scirs2_spatial::geo::point_in_polygon;
279///
280/// let square = [(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0)];
281///
282/// assert!(point_in_polygon(5.0, 5.0, &square));   // inside
283/// assert!(!point_in_polygon(11.0, 5.0, &square)); // outside
284/// assert!(point_in_polygon(0.0, 0.0, &square));   // corner
285/// ```
286pub fn point_in_polygon(lat: f64, lon: f64, polygon: &[(f64, f64)]) -> bool {
287    let n = polygon.len();
288    if n < 3 {
289        return false;
290    }
291
292    // Check corners explicitly for boundary detection
293    for &(vlat, vlon) in polygon {
294        if (vlat - lat).abs() < f64::EPSILON && (vlon - lon).abs() < f64::EPSILON {
295            return true;
296        }
297    }
298
299    let mut inside = false;
300    let mut j = n - 1;
301
302    for i in 0..n {
303        let (yi, xi) = polygon[i];
304        let (yj, xj) = polygon[j];
305
306        // Check if point is on this edge
307        if is_on_segment(lat, lon, yi, xi, yj, xj) {
308            return true;
309        }
310
311        // Standard ray-casting
312        if ((yi > lat) != (yj > lat)) && (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi) {
313            inside = !inside;
314        }
315        j = i;
316    }
317
318    inside
319}
320
321/// Test whether `(py, px)` lies on the line segment `(y0, x0) – (y1, x1)`.
322fn is_on_segment(py: f64, px: f64, y0: f64, x0: f64, y1: f64, x1: f64) -> bool {
323    // Cross-product zero means collinear
324    let cross = (x1 - x0) * (py - y0) - (y1 - y0) * (px - x0);
325    if cross.abs() > 1e-10 {
326        return false;
327    }
328    // Check that point is within the bounding box of the segment
329    let min_x = x0.min(x1);
330    let max_x = x0.max(x1);
331    let min_y = y0.min(y1);
332    let max_y = y0.max(y1);
333    px >= min_x && px <= max_x && py >= min_y && py <= max_y
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    // --- BoundingBox ---
341
342    #[test]
343    fn test_bbox_contains() {
344        let bbox = BoundingBox::new(48.0, 52.0, -1.0, 3.0).expect("bbox");
345        assert!(bbox.contains(50.0, 1.0));
346        assert!(bbox.contains(48.0, -1.0)); // corner
347        assert!(!bbox.contains(53.0, 1.0));
348        assert!(!bbox.contains(50.0, 4.0));
349    }
350
351    #[test]
352    fn test_bbox_intersects() {
353        let a = BoundingBox::new(0.0, 2.0, 0.0, 2.0).expect("a");
354        let b = BoundingBox::new(1.0, 3.0, 1.0, 3.0).expect("b");
355        let c = BoundingBox::new(3.0, 5.0, 3.0, 5.0).expect("c");
356        assert!(a.intersects(&b));
357        assert!(b.intersects(&a));
358        assert!(!a.intersects(&c));
359    }
360
361    #[test]
362    fn test_bbox_expand() {
363        let bbox = BoundingBox::new(10.0, 20.0, 10.0, 20.0).expect("bbox");
364        let expanded = bbox.expand(5.0).expect("expand");
365        assert!((expanded.min_lat - 5.0).abs() < 1e-10);
366        assert!((expanded.max_lat - 25.0).abs() < 1e-10);
367        assert!((expanded.min_lon - 5.0).abs() < 1e-10);
368        assert!((expanded.max_lon - 25.0).abs() < 1e-10);
369    }
370
371    #[test]
372    fn test_bbox_expand_clamped() {
373        let bbox = BoundingBox::new(-89.0, 89.0, -179.0, 179.0).expect("bbox");
374        let expanded = bbox.expand(5.0).expect("expand");
375        assert!(expanded.min_lat >= -90.0);
376        assert!(expanded.max_lat <= 90.0);
377        assert!(expanded.min_lon >= -180.0);
378        assert!(expanded.max_lon <= 180.0);
379    }
380
381    #[test]
382    fn test_bbox_from_points() {
383        let points = [(1.0, 2.0), (3.0, 4.0), (2.0, 1.0)];
384        let bbox = BoundingBox::from_points(&points).expect("from_points");
385        assert!((bbox.min_lat - 1.0).abs() < 1e-10);
386        assert!((bbox.max_lat - 3.0).abs() < 1e-10);
387        assert!((bbox.min_lon - 1.0).abs() < 1e-10);
388        assert!((bbox.max_lon - 4.0).abs() < 1e-10);
389    }
390
391    #[test]
392    fn test_bbox_center() {
393        let bbox = BoundingBox::new(0.0, 10.0, 0.0, 20.0).expect("bbox");
394        let (lat, lon) = bbox.center();
395        assert!((lat - 5.0).abs() < 1e-10);
396        assert!((lon - 10.0).abs() < 1e-10);
397    }
398
399    #[test]
400    fn test_bbox_invalid() {
401        assert!(BoundingBox::new(10.0, 5.0, 0.0, 10.0).is_err());
402        assert!(BoundingBox::new(0.0, 10.0, 10.0, 5.0).is_err());
403        assert!(BoundingBox::new(-91.0, 10.0, 0.0, 10.0).is_err());
404        assert!(BoundingBox::new(0.0, 10.0, 0.0, 181.0).is_err());
405    }
406
407    // --- polygon_area ---
408
409    #[test]
410    fn test_polygon_area_unit_degree_square() {
411        // 1°×1° square near equator
412        let square = [(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)];
413        let area = polygon_area(&square).expect("area");
414        // Expected ~12,321 km² = 1.2321e10 m²
415        let expected = 12_321_000_000.0;
416        assert!(
417            (area - expected).abs() / expected < 0.01,
418            "area={area}, expected≈{expected}"
419        );
420    }
421
422    #[test]
423    fn test_polygon_area_winding_independent() {
424        // CW and CCW should give same area
425        let cw = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)];
426        let ccw = [(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)];
427        let a1 = polygon_area(&cw).expect("area cw");
428        let a2 = polygon_area(&ccw).expect("area ccw");
429        assert!((a1 - a2).abs() < 1.0, "areas differ: {a1} vs {a2}");
430    }
431
432    #[test]
433    fn test_polygon_area_too_few_points() {
434        assert!(polygon_area(&[(0.0, 0.0), (1.0, 0.0)]).is_err());
435    }
436
437    // --- point_in_polygon ---
438
439    #[test]
440    fn test_point_in_polygon_inside() {
441        let square = [(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0)];
442        assert!(point_in_polygon(5.0, 5.0, &square));
443    }
444
445    #[test]
446    fn test_point_in_polygon_outside() {
447        let square = [(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0)];
448        assert!(!point_in_polygon(11.0, 5.0, &square));
449        assert!(!point_in_polygon(5.0, 11.0, &square));
450    }
451
452    #[test]
453    fn test_point_in_polygon_corner() {
454        let square = [(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0)];
455        assert!(point_in_polygon(0.0, 0.0, &square));
456        assert!(point_in_polygon(10.0, 10.0, &square));
457    }
458
459    #[test]
460    fn test_point_in_polygon_nonconvex() {
461        // L-shaped polygon
462        let poly = [
463            (0.0, 0.0),
464            (0.0, 6.0),
465            (3.0, 6.0),
466            (3.0, 3.0),
467            (6.0, 3.0),
468            (6.0, 0.0),
469        ];
470        assert!(point_in_polygon(1.0, 1.0, &poly)); // in bottom-left
471        assert!(point_in_polygon(1.0, 5.0, &poly)); // in top-left
472        assert!(!point_in_polygon(4.0, 5.0, &poly)); // in notch (outside)
473    }
474}