Skip to main content

nodedb_types/
bbox.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Axis-aligned bounding box for 2D geometries (WGS-84).
4//!
5//! Handles the anti-meridian (±180° date line) correctly: when a geometry
6//! crosses the date line, `min_lng > max_lng`. All intersection and
7//! containment logic accounts for this split case.
8
9use crate::geometry::Geometry;
10use serde::{Deserialize, Serialize};
11use zerompk::{FromMessagePack, ToMessagePack};
12
13/// An axis-aligned bounding box in WGS-84 coordinates.
14///
15/// **Anti-meridian convention:** When `min_lng > max_lng`, the bbox wraps
16/// across the ±180° date line. For example, a geometry spanning 170°E to
17/// 170°W has `min_lng = 170.0, max_lng = -170.0`.
18///
19/// `#[non_exhaustive]` — elevation (Z) bounds or a CRS tag may be added
20/// when 3D geometries are supported.
21#[non_exhaustive]
22#[derive(
23    Debug,
24    Clone,
25    Copy,
26    PartialEq,
27    Serialize,
28    Deserialize,
29    ToMessagePack,
30    FromMessagePack,
31    rkyv::Archive,
32    rkyv::Serialize,
33    rkyv::Deserialize,
34)]
35pub struct BoundingBox {
36    pub min_lng: f64,
37    pub min_lat: f64,
38    pub max_lng: f64,
39    pub max_lat: f64,
40}
41
42impl BoundingBox {
43    pub fn new(min_lng: f64, min_lat: f64, max_lng: f64, max_lat: f64) -> Self {
44        Self {
45            min_lng,
46            min_lat,
47            max_lng,
48            max_lat,
49        }
50    }
51
52    pub fn from_point(lng: f64, lat: f64) -> Self {
53        Self {
54            min_lng: lng,
55            min_lat: lat,
56            max_lng: lng,
57            max_lat: lat,
58        }
59    }
60
61    pub fn crosses_antimeridian(&self) -> bool {
62        self.min_lng > self.max_lng
63    }
64
65    /// Area in approximate square degrees (for R-tree heuristics).
66    pub fn area(&self) -> f64 {
67        let lat_span = self.max_lat - self.min_lat;
68        let lng_span = if self.crosses_antimeridian() {
69            (180.0 - self.min_lng) + (self.max_lng + 180.0)
70        } else {
71            self.max_lng - self.min_lng
72        };
73        lat_span * lng_span
74    }
75
76    /// Half-perimeter (R*-tree margin heuristic).
77    pub fn margin(&self) -> f64 {
78        let lat_span = self.max_lat - self.min_lat;
79        let lng_span = if self.crosses_antimeridian() {
80            (180.0 - self.min_lng) + (self.max_lng + 180.0)
81        } else {
82            self.max_lng - self.min_lng
83        };
84        lat_span + lng_span
85    }
86
87    pub fn contains_point(&self, lng: f64, lat: f64) -> bool {
88        if lat < self.min_lat || lat > self.max_lat {
89            return false;
90        }
91        if self.crosses_antimeridian() {
92            lng >= self.min_lng || lng <= self.max_lng
93        } else {
94            lng >= self.min_lng && lng <= self.max_lng
95        }
96    }
97
98    pub fn contains_bbox(&self, other: &BoundingBox) -> bool {
99        if other.min_lat < self.min_lat || other.max_lat > self.max_lat {
100            return false;
101        }
102        match (self.crosses_antimeridian(), other.crosses_antimeridian()) {
103            (false, false) => other.min_lng >= self.min_lng && other.max_lng <= self.max_lng,
104            (true, false) => other.min_lng >= self.min_lng || other.max_lng <= self.max_lng,
105            (false, true) => false,
106            (true, true) => other.min_lng >= self.min_lng && other.max_lng <= self.max_lng,
107        }
108    }
109
110    pub fn intersects(&self, other: &BoundingBox) -> bool {
111        if self.max_lat < other.min_lat || self.min_lat > other.max_lat {
112            return false;
113        }
114        match (self.crosses_antimeridian(), other.crosses_antimeridian()) {
115            (false, false) => self.min_lng <= other.max_lng && self.max_lng >= other.min_lng,
116            (true, false) => other.max_lng >= self.min_lng || other.min_lng <= self.max_lng,
117            (false, true) => self.max_lng >= other.min_lng || self.min_lng <= other.max_lng,
118            (true, true) => true,
119        }
120    }
121
122    pub fn union(&self, other: &BoundingBox) -> BoundingBox {
123        let min_lat = self.min_lat.min(other.min_lat);
124        let max_lat = self.max_lat.max(other.max_lat);
125        let (min_lng, max_lng) =
126            union_longitude(self.min_lng, self.max_lng, other.min_lng, other.max_lng);
127        BoundingBox {
128            min_lng,
129            min_lat,
130            max_lng,
131            max_lat,
132        }
133    }
134
135    pub fn extend_point(&mut self, lng: f64, lat: f64) {
136        self.min_lat = self.min_lat.min(lat);
137        self.max_lat = self.max_lat.max(lat);
138        if self.crosses_antimeridian() {
139            let in_east = lng >= self.min_lng;
140            let in_west = lng <= self.max_lng;
141            if !in_east && !in_west {
142                if self.min_lng - lng < lng - self.max_lng {
143                    self.min_lng = lng;
144                } else {
145                    self.max_lng = lng;
146                }
147            }
148        } else {
149            self.min_lng = self.min_lng.min(lng);
150            self.max_lng = self.max_lng.max(lng);
151        }
152    }
153
154    /// Expand by meters (equirectangular approximation, <0.5% error ±70°).
155    pub fn expand_meters(&self, meters: f64) -> BoundingBox {
156        let dlat = meters / 110_540.0;
157        let center_lat = (self.min_lat + self.max_lat) / 2.0;
158        let dlng = meters / (111_320.0 * center_lat.to_radians().cos());
159        BoundingBox {
160            min_lng: self.min_lng - dlng,
161            min_lat: self.min_lat - dlat,
162            max_lng: self.max_lng + dlng,
163            max_lat: self.max_lat + dlat,
164        }
165    }
166
167    pub fn overlap_area(&self, other: &BoundingBox) -> f64 {
168        if !self.intersects(other) {
169            return 0.0;
170        }
171        let lat_overlap = self.max_lat.min(other.max_lat) - self.min_lat.max(other.min_lat);
172        if lat_overlap <= 0.0 {
173            return 0.0;
174        }
175        let lng_overlap =
176            lng_overlap_span(self.min_lng, self.max_lng, other.min_lng, other.max_lng);
177        lat_overlap * lng_overlap
178    }
179
180    pub fn enlargement(&self, other: &BoundingBox) -> f64 {
181        self.union(other).area() - self.area()
182    }
183}
184
185fn lng_overlap_span(a_min: f64, a_max: f64, b_min: f64, b_max: f64) -> f64 {
186    let a_wraps = a_min > a_max;
187    let b_wraps = b_min > b_max;
188    match (a_wraps, b_wraps) {
189        (false, false) => (a_max.min(b_max) - a_min.max(b_min)).max(0.0),
190        (true, false) => {
191            let east = (180.0_f64.min(b_max) - a_min.max(b_min)).max(0.0);
192            let west = (a_max.min(b_max) - (-180.0_f64).max(b_min)).max(0.0);
193            east + west
194        }
195        (false, true) => lng_overlap_span(b_min, b_max, a_min, a_max),
196        (true, true) => {
197            let east = (180.0 - a_min.max(b_min)).max(0.0);
198            let west = (a_max.min(b_max) + 180.0).max(0.0);
199            east + west
200        }
201    }
202}
203
204fn union_longitude(a_min: f64, a_max: f64, b_min: f64, b_max: f64) -> (f64, f64) {
205    let a_wraps = a_min > a_max;
206    let b_wraps = b_min > b_max;
207    match (a_wraps, b_wraps) {
208        (false, false) => {
209            let simple_min = a_min.min(b_min);
210            let simple_max = a_max.max(b_max);
211            let simple_span = simple_max - simple_min;
212            let gap = (b_min - a_max).max(0.0).min((a_min - b_max).max(0.0));
213            if gap <= 0.0 {
214                return (simple_min, simple_max);
215            }
216            let wrap_min = a_min.max(b_min);
217            let wrap_max = a_max.min(b_max);
218            let wrap_span = (180.0 - wrap_min) + (wrap_max + 180.0);
219            if wrap_span < simple_span {
220                (wrap_min, wrap_max)
221            } else {
222                (simple_min, simple_max)
223            }
224        }
225        (true, false) => {
226            if b_min >= a_min || b_max <= a_max {
227                (a_min, a_max)
228            } else {
229                let extend_east = a_min - b_min;
230                let extend_west = b_max - a_max;
231                if extend_east <= extend_west {
232                    (b_min, a_max)
233                } else {
234                    (a_min, b_max)
235                }
236            }
237        }
238        (false, true) => union_longitude(b_min, b_max, a_min, a_max),
239        (true, true) => (a_min.min(b_min), a_max.max(b_max)),
240    }
241}
242
243/// Compute bounding box for any Geometry.
244pub fn geometry_bbox(geom: &Geometry) -> BoundingBox {
245    match geom {
246        Geometry::Point { coordinates } => BoundingBox::from_point(coordinates[0], coordinates[1]),
247        Geometry::LineString { coordinates } => bbox_from_coords(coordinates),
248        Geometry::Polygon { coordinates } => {
249            let all: Vec<[f64; 2]> = coordinates.iter().flat_map(|r| r.iter().copied()).collect();
250            bbox_from_coords(&all)
251        }
252        Geometry::MultiPoint { coordinates } => bbox_from_coords(coordinates),
253        Geometry::MultiLineString { coordinates } => {
254            let all: Vec<[f64; 2]> = coordinates
255                .iter()
256                .flat_map(|ls| ls.iter().copied())
257                .collect();
258            bbox_from_coords(&all)
259        }
260        Geometry::MultiPolygon { coordinates } => {
261            let all: Vec<[f64; 2]> = coordinates
262                .iter()
263                .flat_map(|poly| poly.iter().flat_map(|ring| ring.iter().copied()))
264                .collect();
265            bbox_from_coords(&all)
266        }
267        Geometry::GeometryCollection { geometries } => {
268            if geometries.is_empty() {
269                return BoundingBox::new(0.0, 0.0, 0.0, 0.0);
270            }
271            let mut result = geometry_bbox(&geometries[0]);
272            for geom in &geometries[1..] {
273                result = result.union(&geometry_bbox(geom));
274            }
275            result
276        }
277    }
278}
279
280fn bbox_from_coords(coords: &[[f64; 2]]) -> BoundingBox {
281    if coords.is_empty() {
282        return BoundingBox::new(0.0, 0.0, 0.0, 0.0);
283    }
284    let mut min_lng = coords[0][0];
285    let mut min_lat = coords[0][1];
286    let mut max_lng = coords[0][0];
287    let mut max_lat = coords[0][1];
288    for c in &coords[1..] {
289        min_lng = min_lng.min(c[0]);
290        min_lat = min_lat.min(c[1]);
291        max_lng = max_lng.max(c[0]);
292        max_lat = max_lat.max(c[1]);
293    }
294    BoundingBox {
295        min_lng,
296        min_lat,
297        max_lng,
298        max_lat,
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn point_bbox() {
308        let bb = geometry_bbox(&Geometry::point(10.0, 20.0));
309        assert_eq!(bb.min_lng, 10.0);
310        assert_eq!(bb.max_lng, 10.0);
311        assert_eq!(bb.area(), 0.0);
312    }
313
314    #[test]
315    fn polygon_bbox() {
316        let poly = Geometry::polygon(vec![vec![
317            [-10.0, -5.0],
318            [10.0, -5.0],
319            [10.0, 5.0],
320            [-10.0, 5.0],
321            [-10.0, -5.0],
322        ]]);
323        let bb = geometry_bbox(&poly);
324        assert_eq!(bb.min_lng, -10.0);
325        assert_eq!(bb.max_lng, 10.0);
326    }
327
328    #[test]
329    fn contains_point_normal() {
330        let bb = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
331        assert!(bb.contains_point(5.0, 5.0));
332        assert!(!bb.contains_point(11.0, 5.0));
333    }
334
335    #[test]
336    fn contains_point_antimeridian() {
337        let bb = BoundingBox::new(170.0, -10.0, -170.0, 10.0);
338        assert!(bb.crosses_antimeridian());
339        assert!(bb.contains_point(175.0, 0.0));
340        assert!(bb.contains_point(-175.0, 0.0));
341        assert!(!bb.contains_point(0.0, 0.0));
342    }
343
344    #[test]
345    fn intersects_normal() {
346        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
347        let b = BoundingBox::new(5.0, 5.0, 15.0, 15.0);
348        assert!(a.intersects(&b));
349        let c = BoundingBox::new(20.0, 20.0, 30.0, 30.0);
350        assert!(!a.intersects(&c));
351    }
352
353    #[test]
354    fn intersects_antimeridian() {
355        let a = BoundingBox::new(170.0, -5.0, -170.0, 5.0);
356        let b = BoundingBox::new(175.0, 0.0, -175.0, 3.0);
357        assert!(a.intersects(&b));
358        let c = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
359        assert!(!a.intersects(&c));
360    }
361
362    #[test]
363    fn union_normal() {
364        let a = BoundingBox::new(0.0, 0.0, 5.0, 5.0);
365        let b = BoundingBox::new(3.0, 3.0, 10.0, 10.0);
366        let u = a.union(&b);
367        assert_eq!(u.min_lng, 0.0);
368        assert_eq!(u.max_lng, 10.0);
369    }
370
371    #[test]
372    fn overlap_area_partial() {
373        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
374        let b = BoundingBox::new(5.0, 5.0, 15.0, 15.0);
375        assert!((a.overlap_area(&b) - 25.0).abs() < 1e-10);
376    }
377
378    #[test]
379    fn expand_meters_basic() {
380        let bb = BoundingBox::from_point(0.0, 0.0);
381        let expanded = bb.expand_meters(1000.0);
382        assert!((expanded.min_lat - (-0.00905)).abs() < 0.001);
383    }
384
385    #[test]
386    fn geometry_collection_bbox() {
387        let gc = Geometry::GeometryCollection {
388            geometries: vec![Geometry::point(0.0, 0.0), Geometry::point(10.0, 10.0)],
389        };
390        let bb = geometry_bbox(&gc);
391        assert_eq!(bb.min_lng, 0.0);
392        assert_eq!(bb.max_lng, 10.0);
393    }
394}