Skip to main content

rustial_engine/math/
bounds.rs

1//! Geographic and projected bounding boxes.
2//!
3//! # Overview
4//!
5//! This module defines two bounding-box types used throughout the rustial
6//! workspace:
7//!
8//! - [`GeoBounds`] -- a geographic bounding box defined by its southwest and
9//!   northeast corners in WGS-84 degrees.  This is the Rust equivalent of
10//!   MapLibre GL JS's `LngLatBounds`.
11//! - [`WorldBounds`] -- an axis-aligned bounding box in projected world space
12//!   (meters, typically EPSG:3857 / Web Mercator).
13//!
14//! # Coordinate conventions
15//!
16//! | Type | SW corner | NE corner |
17//! |------|-----------|-----------|
18//! | `GeoBounds` | min lon, min lat | max lon, max lat |
19//! | `WorldBounds` | min x, min y, min z | max x, max y, max z |
20//!
21//! # Antimeridian handling
22//!
23//! [`GeoBounds`] supports bounding boxes that cross the 180th meridian.
24//! When `west > east` (i.e. `sw.lon > ne.lon`), the box is understood to
25//! wrap around the antimeridian.  The [`contains_coord`](GeoBounds::contains_coord),
26//! [`intersects`](GeoBounds::intersects), and
27//! [`adjust_antimeridian`](GeoBounds::adjust_antimeridian) methods handle
28//! this correctly.
29
30use crate::coord::{GeoCoord, WorldCoord};
31use std::fmt;
32
33/// Wrap a value into the range `[min, max)`.
34///
35/// Equivalent to MapLibre's `wrap(value, min, max)` utility.
36#[inline]
37fn wrap(value: f64, min: f64, max: f64) -> f64 {
38    let range = max - min;
39    if range == 0.0 {
40        return min;
41    }
42    ((value - min) % range + range) % range + min
43}
44
45// ---------------------------------------------------------------------------
46// GeoBounds
47// ---------------------------------------------------------------------------
48
49/// A geographic bounding box defined by its southwest and northeast corners
50/// in WGS-84 degrees.
51///
52/// This is the Rust equivalent of MapLibre GL JS's `LngLatBounds` and
53/// Mapbox GL JS's `LngLatBounds`.
54///
55/// # Construction
56///
57/// | Method | Description |
58/// |--------|-------------|
59/// | [`new`](Self::new) | From explicit sw/ne corners |
60/// | [`from_coords`](Self::from_coords) | From west, south, east, north |
61/// | [`from_center_radius`](Self::from_center_radius) | Expand a center point by a radius in meters |
62/// | `From<[f64; 4]>` | `[west, south, east, north]` |
63///
64/// # Antimeridian
65///
66/// When `sw.lon > ne.lon`, the bounds wrap across the 180th meridian.
67/// Use [`adjust_antimeridian`](Self::adjust_antimeridian) to unwrap
68/// into a contiguous longitude range (ne.lon may exceed 180).
69///
70/// # Display
71///
72/// Formats as `"GeoBounds(sw: (lat, lon), ne: (lat, lon))"`.
73#[derive(Debug, Clone, Copy, PartialEq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct GeoBounds {
76    /// Southwest corner (minimum latitude, minimum longitude).
77    sw: GeoCoord,
78    /// Northeast corner (maximum latitude, maximum longitude).
79    ne: GeoCoord,
80}
81
82impl GeoBounds {
83    /// Create a geographic bounding box from explicit southwest and
84    /// northeast corners.
85    ///
86    /// The caller should ensure `sw.lat <= ne.lat`.  Longitude may
87    /// have `sw.lon > ne.lon` to represent antimeridian-crossing bounds.
88    #[inline]
89    pub fn new(sw: GeoCoord, ne: GeoCoord) -> Self {
90        Self { sw, ne }
91    }
92
93    /// Create a geographic bounding box from edge values.
94    ///
95    /// Equivalent to MapLibre's `new LngLatBounds([west, south, east, north])`.
96    #[inline]
97    pub fn from_coords(west: f64, south: f64, east: f64, north: f64) -> Self {
98        Self {
99            sw: GeoCoord::from_lat_lon(south, west),
100            ne: GeoCoord::from_lat_lon(north, east),
101        }
102    }
103
104    /// Create a bounding box by expanding a center point by a radius
105    /// in meters.
106    ///
107    /// Uses the same approximation as MapLibre's `LngLatBounds.fromLngLat`:
108    /// latitude accuracy is computed from the Earth's equatorial
109    /// circumference, and longitude accuracy is scaled by `cos(lat)`.
110    ///
111    /// # Arguments
112    ///
113    /// * `center` - The center geographic coordinate.
114    /// * `radius_m` - Distance in meters to expand in all directions.
115    pub fn from_center_radius(center: GeoCoord, radius_m: f64) -> Self {
116        const EARTH_CIRCUMFERENCE_M: f64 = 40_075_017.0;
117        let lat_accuracy = 360.0 * radius_m / EARTH_CIRCUMFERENCE_M;
118        let lon_accuracy =
119            lat_accuracy / (std::f64::consts::PI / 180.0 * center.lat).cos();
120
121        Self {
122            sw: GeoCoord::from_lat_lon(
123                center.lat - lat_accuracy,
124                center.lon - lon_accuracy,
125            ),
126            ne: GeoCoord::from_lat_lon(
127                center.lat + lat_accuracy,
128                center.lon + lon_accuracy,
129            ),
130        }
131    }
132
133    // -- Accessors --------------------------------------------------------
134
135    /// Southwest corner.
136    #[inline]
137    pub fn sw(&self) -> GeoCoord {
138        self.sw
139    }
140
141    /// Northeast corner.
142    #[inline]
143    pub fn ne(&self) -> GeoCoord {
144        self.ne
145    }
146
147    /// Northwest corner.
148    #[inline]
149    pub fn nw(&self) -> GeoCoord {
150        GeoCoord::from_lat_lon(self.ne.lat, self.sw.lon)
151    }
152
153    /// Southeast corner.
154    #[inline]
155    pub fn se(&self) -> GeoCoord {
156        GeoCoord::from_lat_lon(self.sw.lat, self.ne.lon)
157    }
158
159    /// West edge (longitude).
160    #[inline]
161    pub fn west(&self) -> f64 {
162        self.sw.lon
163    }
164
165    /// South edge (latitude).
166    #[inline]
167    pub fn south(&self) -> f64 {
168        self.sw.lat
169    }
170
171    /// East edge (longitude).
172    #[inline]
173    pub fn east(&self) -> f64 {
174        self.ne.lon
175    }
176
177    /// North edge (latitude).
178    #[inline]
179    pub fn north(&self) -> f64 {
180        self.ne.lat
181    }
182
183    /// Geographic center of the bounding box.
184    ///
185    /// Equivalent to MapLibre's `LngLatBounds.getCenter()`.
186    #[inline]
187    pub fn center(&self) -> GeoCoord {
188        GeoCoord::from_lat_lon(
189            (self.sw.lat + self.ne.lat) / 2.0,
190            (self.sw.lon + self.ne.lon) / 2.0,
191        )
192    }
193
194    // -- Mutation / extension ---------------------------------------------
195
196    /// Extend the bounds to include a single geographic coordinate.
197    ///
198    /// Equivalent to MapLibre's `LngLatBounds.extend(LngLat)`.
199    pub fn extend_coord(&mut self, coord: GeoCoord) {
200        self.sw.lat = self.sw.lat.min(coord.lat);
201        self.sw.lon = self.sw.lon.min(coord.lon);
202        self.ne.lat = self.ne.lat.max(coord.lat);
203        self.ne.lon = self.ne.lon.max(coord.lon);
204    }
205
206    /// Extend the bounds to include another bounding box.
207    ///
208    /// Equivalent to MapLibre's `LngLatBounds.extend(LngLatBounds)`.
209    pub fn extend_bounds(&mut self, other: &GeoBounds) {
210        self.sw.lat = self.sw.lat.min(other.sw.lat);
211        self.sw.lon = self.sw.lon.min(other.sw.lon);
212        self.ne.lat = self.ne.lat.max(other.ne.lat);
213        self.ne.lon = self.ne.lon.max(other.ne.lon);
214    }
215
216    // -- Queries ----------------------------------------------------------
217
218    /// Check if a geographic coordinate is within the bounding box.
219    ///
220    /// Handles antimeridian-crossing bounds (where `west > east`).
221    ///
222    /// Equivalent to MapLibre's `LngLatBounds.contains()`.
223    pub fn contains_coord(&self, coord: &GeoCoord) -> bool {
224        let lat_ok = self.sw.lat <= coord.lat && coord.lat <= self.ne.lat;
225
226        let lon_ok = if self.sw.lon > self.ne.lon {
227            // Antimeridian-crossing: longitude is inside when it is NOT
228            // in the gap between ne.lon and sw.lon.
229            self.sw.lon <= coord.lon || coord.lon <= self.ne.lon
230        } else {
231            self.sw.lon <= coord.lon && coord.lon <= self.ne.lon
232        };
233
234        lat_ok && lon_ok
235    }
236
237    /// Check if this bounding box intersects another.
238    ///
239    /// Returns `true` if the bounding boxes share any area, including
240    /// cases where they only touch along an edge or at a corner.
241    ///
242    /// Properly handles cases where either or both bounding boxes cross
243    /// the antimeridian.
244    ///
245    /// Equivalent to MapLibre's `LngLatBounds.intersects()`.
246    pub fn intersects(&self, other: &GeoBounds) -> bool {
247        // Latitude check (simple range overlap).
248        let lat_ok =
249            other.north() >= self.south() && other.south() <= self.north();
250        if !lat_ok {
251            return false;
252        }
253
254        // Check if either bound covers the full world (span >= 360 deg).
255        let this_span = (self.east() - self.west()).abs();
256        let other_span = (other.east() - other.west()).abs();
257        if this_span >= 360.0 || other_span >= 360.0 {
258            return true;
259        }
260
261        // Normalise longitudes to [-180, 180].
262        let this_west = wrap(self.west(), -180.0, 180.0);
263        let this_east = wrap(self.east(), -180.0, 180.0);
264        let other_west = wrap(other.west(), -180.0, 180.0);
265        let other_east = wrap(other.east(), -180.0, 180.0);
266
267        // Strict inequality: equal values indicate zero-width (point),
268        // not wrapping.
269        let this_wraps = this_west > this_east;
270        let other_wraps = other_west > other_east;
271
272        if this_wraps && other_wraps {
273            return true;
274        }
275
276        if this_wraps {
277            return other_east >= this_west || other_west <= this_east;
278        }
279
280        if other_wraps {
281            return this_east >= other_west || this_west <= other_east;
282        }
283
284        // Neither wraps: standard interval overlap.
285        other_west <= this_east && other_east >= this_west
286    }
287
288    // -- Antimeridian adjustment ------------------------------------------
289
290    /// Adjust bounds that cross the antimeridian so that
291    /// `ne.lon >= sw.lon`.
292    ///
293    /// When `sw.lon > ne.lon` (crossing the 180th meridian), this adds
294    /// 360 to `ne.lon` so the bounds form a contiguous longitude range.
295    /// The resulting `ne.lon` may exceed 180.
296    ///
297    /// Equivalent to MapLibre's `LngLatBounds.adjustAntiMeridian()`.
298    pub fn adjust_antimeridian(&self) -> Self {
299        if self.sw.lon > self.ne.lon {
300            Self {
301                sw: self.sw,
302                ne: GeoCoord {
303                    lat: self.ne.lat,
304                    lon: self.ne.lon + 360.0,
305                    alt: self.ne.alt,
306                },
307            }
308        } else {
309            *self
310        }
311    }
312
313    /// Return the bounding box as `[west, south, east, north]`.
314    #[inline]
315    pub fn to_array(&self) -> [f64; 4] {
316        [self.sw.lon, self.sw.lat, self.ne.lon, self.ne.lat]
317    }
318}
319
320impl fmt::Display for GeoBounds {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        write!(
323            f,
324            "GeoBounds(sw: ({:.6}, {:.6}), ne: ({:.6}, {:.6}))",
325            self.sw.lat, self.sw.lon, self.ne.lat, self.ne.lon
326        )
327    }
328}
329
330// -- From conversions for GeoBounds ----------------------------------------
331
332impl From<[f64; 4]> for GeoBounds {
333    /// Create from `[west, south, east, north]`.
334    #[inline]
335    fn from(arr: [f64; 4]) -> Self {
336        Self::from_coords(arr[0], arr[1], arr[2], arr[3])
337    }
338}
339
340impl From<GeoBounds> for [f64; 4] {
341    /// Convert to `[west, south, east, north]`.
342    #[inline]
343    fn from(b: GeoBounds) -> Self {
344        b.to_array()
345    }
346}
347
348// ---------------------------------------------------------------------------
349// WorldBounds
350// ---------------------------------------------------------------------------
351
352/// An axis-aligned bounding box in projected world space (meters).
353///
354/// The coordinate system matches [`WorldCoord`]: right-handed Z-up,
355/// X east, Y north, Z up.
356///
357/// `min` contains the component-wise minimum and `max` the component-wise
358/// maximum.  For 2D tile operations `min.z` and `max.z` are typically 0.
359///
360/// # Construction
361///
362/// | Method | Description |
363/// |--------|-------------|
364/// | [`new`](Self::new) | From explicit min/max corners |
365/// | [`from_min_max`](Self::from_min_max) | Alias for `new` |
366///
367/// # Display
368///
369/// Formats as `"WorldBounds(min: ..., max: ...)"`.
370#[derive(Debug, Clone, Copy, PartialEq)]
371#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
372pub struct WorldBounds {
373    /// Component-wise minimum corner (south-west-bottom).
374    pub min: WorldCoord,
375    /// Component-wise maximum corner (north-east-top).
376    pub max: WorldCoord,
377}
378
379impl WorldBounds {
380    /// Create a world-space bounding box from explicit min/max corners.
381    #[inline]
382    pub fn new(min: WorldCoord, max: WorldCoord) -> Self {
383        Self { min, max }
384    }
385
386    /// Alias for [`new`](Self::new).
387    #[inline]
388    pub fn from_min_max(min: WorldCoord, max: WorldCoord) -> Self {
389        Self { min, max }
390    }
391
392    /// Center point of the bounding box.
393    #[inline]
394    pub fn center(&self) -> WorldCoord {
395        WorldCoord::new(
396            (self.min.position.x + self.max.position.x) * 0.5,
397            (self.min.position.y + self.max.position.y) * 0.5,
398            (self.min.position.z + self.max.position.z) * 0.5,
399        )
400    }
401
402    /// Size of the bounding box along each axis (meters).
403    #[inline]
404    pub fn size(&self) -> (f64, f64, f64) {
405        (
406            self.max.position.x - self.min.position.x,
407            self.max.position.y - self.min.position.y,
408            self.max.position.z - self.min.position.z,
409        )
410    }
411
412    /// Whether a world-space point is inside the bounding box (inclusive).
413    #[inline]
414    pub fn contains_point(&self, point: &WorldCoord) -> bool {
415        point.position.x >= self.min.position.x
416            && point.position.x <= self.max.position.x
417            && point.position.y >= self.min.position.y
418            && point.position.y <= self.max.position.y
419            && point.position.z >= self.min.position.z
420            && point.position.z <= self.max.position.z
421    }
422
423    /// Whether two world-space bounding boxes overlap (inclusive).
424    #[inline]
425    pub fn intersects(&self, other: &WorldBounds) -> bool {
426        self.min.position.x <= other.max.position.x
427            && self.max.position.x >= other.min.position.x
428            && self.min.position.y <= other.max.position.y
429            && self.max.position.y >= other.min.position.y
430            && self.min.position.z <= other.max.position.z
431            && self.max.position.z >= other.min.position.z
432    }
433
434    /// Extend the bounds to include another bounding box.
435    pub fn extend(&mut self, other: &WorldBounds) {
436        self.min = WorldCoord::new(
437            self.min.position.x.min(other.min.position.x),
438            self.min.position.y.min(other.min.position.y),
439            self.min.position.z.min(other.min.position.z),
440        );
441        self.max = WorldCoord::new(
442            self.max.position.x.max(other.max.position.x),
443            self.max.position.y.max(other.max.position.y),
444            self.max.position.z.max(other.max.position.z),
445        );
446    }
447
448    /// Extend the bounds to include a single world-space point.
449    pub fn extend_point(&mut self, point: &WorldCoord) {
450        self.min = WorldCoord::new(
451            self.min.position.x.min(point.position.x),
452            self.min.position.y.min(point.position.y),
453            self.min.position.z.min(point.position.z),
454        );
455        self.max = WorldCoord::new(
456            self.max.position.x.max(point.position.x),
457            self.max.position.y.max(point.position.y),
458            self.max.position.z.max(point.position.z),
459        );
460    }
461}
462
463impl fmt::Display for WorldBounds {
464    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
465        write!(
466            f,
467            "WorldBounds(min: {}, max: {})",
468            self.min, self.max
469        )
470    }
471}
472
473// ---------------------------------------------------------------------------
474// Tests
475// ---------------------------------------------------------------------------
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    // -- GeoBounds construction -------------------------------------------
482
483    #[test]
484    fn geo_bounds_new() {
485        let sw = GeoCoord::from_lat_lon(40.7661, -73.9876);
486        let ne = GeoCoord::from_lat_lon(40.8002, -73.9397);
487        let b = GeoBounds::new(sw, ne);
488        assert_eq!(b.sw(), sw);
489        assert_eq!(b.ne(), ne);
490    }
491
492    #[test]
493    fn geo_bounds_from_coords() {
494        let b = GeoBounds::from_coords(-73.9876, 40.7661, -73.9397, 40.8002);
495        assert!((b.west() - (-73.9876)).abs() < 1e-10);
496        assert!((b.south() - 40.7661).abs() < 1e-10);
497        assert!((b.east() - (-73.9397)).abs() < 1e-10);
498        assert!((b.north() - 40.8002).abs() < 1e-10);
499    }
500
501    #[test]
502    fn geo_bounds_from_array() {
503        let b: GeoBounds = [-73.9876, 40.7661, -73.9397, 40.8002].into();
504        assert!((b.west() - (-73.9876)).abs() < 1e-10);
505        assert!((b.north() - 40.8002).abs() < 1e-10);
506    }
507
508    #[test]
509    fn geo_bounds_to_array_roundtrip() {
510        let b = GeoBounds::from_coords(-73.9876, 40.7661, -73.9397, 40.8002);
511        let arr: [f64; 4] = b.into();
512        assert_eq!(arr, b.to_array());
513    }
514
515    // -- GeoBounds corner/edge accessors ----------------------------------
516
517    #[test]
518    fn geo_bounds_corners() {
519        let b = GeoBounds::from_coords(-73.9876, 40.7661, -73.9397, 40.8002);
520        let nw = b.nw();
521        assert!((nw.lat - 40.8002).abs() < 1e-10);
522        assert!((nw.lon - (-73.9876)).abs() < 1e-10);
523        let se = b.se();
524        assert!((se.lat - 40.7661).abs() < 1e-10);
525        assert!((se.lon - (-73.9397)).abs() < 1e-10);
526    }
527
528    // -- GeoBounds center -------------------------------------------------
529
530    #[test]
531    fn geo_bounds_center() {
532        let b = GeoBounds::from_coords(-73.9876, 40.7661, -73.9397, 40.8002);
533        let c = b.center();
534        assert!((c.lon - (-73.96365)).abs() < 1e-4);
535        assert!((c.lat - 40.78315).abs() < 1e-4);
536    }
537
538    // -- GeoBounds extend -------------------------------------------------
539
540    #[test]
541    fn geo_bounds_extend_coord() {
542        let mut b =
543            GeoBounds::from_coords(-73.9876, 40.7661, -73.9397, 40.8002);
544        b.extend_coord(GeoCoord::from_lat_lon(41.0, -74.0));
545        assert!((b.north() - 41.0).abs() < 1e-10);
546        assert!((b.west() - (-74.0)).abs() < 1e-10);
547    }
548
549    #[test]
550    fn geo_bounds_extend_bounds() {
551        let mut a =
552            GeoBounds::from_coords(-73.9876, 40.7661, -73.9397, 40.8002);
553        let b = GeoBounds::from_coords(-74.0, 40.5, -73.5, 41.0);
554        a.extend_bounds(&b);
555        assert!((a.west() - (-74.0)).abs() < 1e-10);
556        assert!((a.south() - 40.5).abs() < 1e-10);
557        assert!((a.east() - (-73.5)).abs() < 1e-10);
558        assert!((a.north() - 41.0).abs() < 1e-10);
559    }
560
561    // -- GeoBounds contains -----------------------------------------------
562
563    #[test]
564    fn geo_bounds_contains_inside() {
565        let b = GeoBounds::from_coords(-73.9876, 40.7661, -73.9397, 40.8002);
566        let p = GeoCoord::from_lat_lon(40.7789, -73.9567);
567        assert!(b.contains_coord(&p));
568    }
569
570    #[test]
571    fn geo_bounds_contains_outside() {
572        let b = GeoBounds::from_coords(-73.9876, 40.7661, -73.9397, 40.8002);
573        let p = GeoCoord::from_lat_lon(41.0, -73.9567);
574        assert!(!b.contains_coord(&p));
575    }
576
577    #[test]
578    fn geo_bounds_contains_antimeridian() {
579        // Bounds crossing the antimeridian: from 170 E to 170 W (= -170)
580        let b = GeoBounds::from_coords(170.0, -20.0, -170.0, -10.0);
581        // A point at 175 E should be inside.
582        let inside = GeoCoord::from_lat_lon(-15.0, 175.0);
583        assert!(b.contains_coord(&inside));
584        // A point at 0 should be outside.
585        let outside = GeoCoord::from_lat_lon(-15.0, 0.0);
586        assert!(!b.contains_coord(&outside));
587    }
588
589    // -- GeoBounds intersects ---------------------------------------------
590
591    #[test]
592    fn geo_bounds_intersects_overlapping() {
593        let a = GeoBounds::from_coords(-74.0, 40.0, -73.0, 41.0);
594        let b = GeoBounds::from_coords(-73.5, 40.5, -72.5, 41.5);
595        assert!(a.intersects(&b));
596        assert!(b.intersects(&a));
597    }
598
599    #[test]
600    fn geo_bounds_intersects_disjoint() {
601        let a = GeoBounds::from_coords(-74.0, 40.0, -73.0, 41.0);
602        let b = GeoBounds::from_coords(10.0, 50.0, 11.0, 51.0);
603        assert!(!a.intersects(&b));
604    }
605
606    #[test]
607    fn geo_bounds_intersects_touching_edge() {
608        let a = GeoBounds::from_coords(-74.0, 40.0, -73.0, 41.0);
609        let b = GeoBounds::from_coords(-73.0, 41.0, -72.0, 42.0);
610        assert!(a.intersects(&b));
611    }
612
613    #[test]
614    fn geo_bounds_intersects_antimeridian_both_wrap() {
615        let a = GeoBounds::from_coords(170.0, -20.0, -170.0, -10.0);
616        let b = GeoBounds::from_coords(160.0, -25.0, -160.0, -5.0);
617        assert!(a.intersects(&b));
618    }
619
620    #[test]
621    fn geo_bounds_intersects_full_world() {
622        let full = GeoBounds::from_coords(-180.0, -90.0, 180.0, 90.0);
623        let small = GeoBounds::from_coords(10.0, 10.0, 11.0, 11.0);
624        assert!(full.intersects(&small));
625        assert!(small.intersects(&full));
626    }
627
628    // -- GeoBounds from_center_radius -------------------------------------
629
630    #[test]
631    fn geo_bounds_from_center_radius_zero() {
632        let center = GeoCoord::from_lat_lon(40.7736, -73.9749);
633        let b = GeoBounds::from_center_radius(center, 0.0);
634        assert!((b.sw().lat - center.lat).abs() < 1e-10);
635        assert!((b.ne().lat - center.lat).abs() < 1e-10);
636    }
637
638    #[test]
639    fn geo_bounds_from_center_radius_100m() {
640        let center = GeoCoord::from_lat_lon(40.7736, -73.9749);
641        let b = GeoBounds::from_center_radius(center, 100.0);
642        assert!(b.sw().lat < center.lat);
643        assert!(b.ne().lat > center.lat);
644        assert!(b.sw().lon < center.lon);
645        assert!(b.ne().lon > center.lon);
646        // Lat span: 2 * 360 * 100 / 40075017 ~ 0.001796 deg.
647        let lat_span = b.ne().lat - b.sw().lat;
648        assert!((lat_span - 0.001796).abs() < 0.0001);
649    }
650
651    // -- GeoBounds adjust_antimeridian ------------------------------------
652
653    #[test]
654    fn geo_bounds_adjust_antimeridian_no_wrap() {
655        let b = GeoBounds::from_coords(-73.9876, 40.7661, -73.9397, 40.8002);
656        let adjusted = b.adjust_antimeridian();
657        assert_eq!(b, adjusted);
658    }
659
660    #[test]
661    fn geo_bounds_adjust_antimeridian_wrap() {
662        let b = GeoBounds::from_coords(175.0, -20.0, -178.0, -15.0);
663        let adjusted = b.adjust_antimeridian();
664        assert!((adjusted.sw().lon - 175.0).abs() < 1e-10);
665        assert!((adjusted.ne().lon - 182.0).abs() < 1e-10);
666    }
667
668    // -- GeoBounds Display ------------------------------------------------
669
670    #[test]
671    fn geo_bounds_display() {
672        let b = GeoBounds::from_coords(-73.9876, 40.7661, -73.9397, 40.8002);
673        let s = format!("{b}");
674        assert!(s.contains("GeoBounds"));
675        assert!(s.contains("sw:"));
676        assert!(s.contains("ne:"));
677    }
678
679    // -- WorldBounds construction -----------------------------------------
680
681    #[test]
682    fn world_bounds_new() {
683        let b = WorldBounds::new(
684            WorldCoord::new(-100.0, -200.0, 0.0),
685            WorldCoord::new(100.0, 200.0, 50.0),
686        );
687        assert_eq!(b.min.position.x, -100.0);
688        assert_eq!(b.max.position.y, 200.0);
689    }
690
691    // -- WorldBounds center -----------------------------------------------
692
693    #[test]
694    fn world_bounds_center() {
695        let b = WorldBounds::new(
696            WorldCoord::new(-100.0, -200.0, 0.0),
697            WorldCoord::new(100.0, 200.0, 50.0),
698        );
699        let c = b.center();
700        assert!((c.position.x).abs() < 1e-10);
701        assert!((c.position.y).abs() < 1e-10);
702        assert!((c.position.z - 25.0).abs() < 1e-10);
703    }
704
705    // -- WorldBounds size -------------------------------------------------
706
707    #[test]
708    fn world_bounds_size() {
709        let b = WorldBounds::new(
710            WorldCoord::new(-100.0, -200.0, 0.0),
711            WorldCoord::new(100.0, 200.0, 50.0),
712        );
713        let (sx, sy, sz) = b.size();
714        assert!((sx - 200.0).abs() < 1e-10);
715        assert!((sy - 400.0).abs() < 1e-10);
716        assert!((sz - 50.0).abs() < 1e-10);
717    }
718
719    // -- WorldBounds contains_point ---------------------------------------
720
721    #[test]
722    fn world_bounds_contains_point() {
723        let b = WorldBounds::new(
724            WorldCoord::new(-100.0, -200.0, 0.0),
725            WorldCoord::new(100.0, 200.0, 50.0),
726        );
727        assert!(b.contains_point(&WorldCoord::new(0.0, 0.0, 25.0)));
728        assert!(!b.contains_point(&WorldCoord::new(200.0, 0.0, 0.0)));
729    }
730
731    // -- WorldBounds intersects -------------------------------------------
732
733    #[test]
734    fn world_bounds_intersects() {
735        let a = WorldBounds::new(
736            WorldCoord::new(-100.0, -100.0, 0.0),
737            WorldCoord::new(100.0, 100.0, 0.0),
738        );
739        let b = WorldBounds::new(
740            WorldCoord::new(50.0, 50.0, 0.0),
741            WorldCoord::new(200.0, 200.0, 0.0),
742        );
743        assert!(a.intersects(&b));
744    }
745
746    #[test]
747    fn world_bounds_disjoint() {
748        let a = WorldBounds::new(
749            WorldCoord::new(-100.0, -100.0, 0.0),
750            WorldCoord::new(-50.0, -50.0, 0.0),
751        );
752        let b = WorldBounds::new(
753            WorldCoord::new(50.0, 50.0, 0.0),
754            WorldCoord::new(200.0, 200.0, 0.0),
755        );
756        assert!(!a.intersects(&b));
757    }
758
759    // -- WorldBounds extend -----------------------------------------------
760
761    #[test]
762    fn world_bounds_extend() {
763        let mut a = WorldBounds::new(
764            WorldCoord::new(-100.0, -100.0, 0.0),
765            WorldCoord::new(100.0, 100.0, 0.0),
766        );
767        let b = WorldBounds::new(
768            WorldCoord::new(-200.0, 50.0, -10.0),
769            WorldCoord::new(50.0, 300.0, 10.0),
770        );
771        a.extend(&b);
772        assert!((a.min.position.x - (-200.0)).abs() < 1e-10);
773        assert!((a.min.position.y - (-100.0)).abs() < 1e-10);
774        assert!((a.max.position.y - 300.0).abs() < 1e-10);
775    }
776
777    #[test]
778    fn world_bounds_extend_point() {
779        let mut a = WorldBounds::new(
780            WorldCoord::new(0.0, 0.0, 0.0),
781            WorldCoord::new(10.0, 10.0, 0.0),
782        );
783        a.extend_point(&WorldCoord::new(-5.0, 15.0, 3.0));
784        assert!((a.min.position.x - (-5.0)).abs() < 1e-10);
785        assert!((a.max.position.y - 15.0).abs() < 1e-10);
786        assert!((a.max.position.z - 3.0).abs() < 1e-10);
787    }
788
789    // -- WorldBounds Display ----------------------------------------------
790
791    #[test]
792    fn world_bounds_display() {
793        let b = WorldBounds::new(
794            WorldCoord::new(1.0, 2.0, 3.0),
795            WorldCoord::new(4.0, 5.0, 6.0),
796        );
797        let s = format!("{b}");
798        assert!(s.contains("WorldBounds"));
799    }
800}