Skip to main content

hfx_core/
geo.rs

1//! Spatial primitives for WGS84 coordinates, bounding boxes, and WKB geometry.
2
3/// Errors from constructing spatial primitives.
4#[derive(Debug, thiserror::Error)]
5pub enum GeoError {
6    /// Returned when a longitude is outside [-180, 180].
7    #[error("longitude out of range [-180, 180]: {value}")]
8    LongitudeOutOfRange {
9        /// The invalid longitude value.
10        value: f32,
11    },
12
13    /// Returned when a latitude is outside [-90, 90].
14    #[error("latitude out of range [-90, 90]: {value}")]
15    LatitudeOutOfRange {
16        /// The invalid latitude value.
17        value: f32,
18    },
19
20    /// Returned when a bounding box has min >= max on an axis.
21    #[error("degenerate bounding box: {axis} min ({min}) >= max ({max})")]
22    DegenerateBbox {
23        /// Which axis is degenerate ("x" or "y").
24        axis: &'static str,
25        /// The minimum value.
26        min: f32,
27        /// The maximum value.
28        max: f32,
29    },
30
31    /// Returned when WKB geometry bytes are empty.
32    #[error("geometry must not be empty")]
33    EmptyGeometry,
34
35    /// Returned when a coordinate is NaN or infinite.
36    #[error("coordinate must be finite, got {value}")]
37    NonFiniteCoordinate {
38        /// The non-finite value.
39        value: f32,
40    },
41
42    /// Returned when an outlet longitude is NaN or infinite.
43    #[error("outlet longitude must be finite, got {value}")]
44    NonFiniteOutletLongitude {
45        /// The non-finite value.
46        value: f64,
47    },
48
49    /// Returned when an outlet latitude is NaN or infinite.
50    #[error("outlet latitude must be finite, got {value}")]
51    NonFiniteOutletLatitude {
52        /// The non-finite value.
53        value: f64,
54    },
55
56    /// Returned when an outlet longitude is outside [-180, 180].
57    #[error("outlet longitude out of range [-180, 180]: {value}")]
58    OutletLongitudeOutOfRange {
59        /// The invalid longitude value.
60        value: f64,
61    },
62
63    /// Returned when an outlet latitude is outside [-90, 90].
64    #[error("outlet latitude out of range [-90, 90]: {value}")]
65    OutletLatitudeOutOfRange {
66        /// The invalid latitude value.
67        value: f64,
68    },
69}
70
71/// A validated WGS84 longitude in the range [-180.0, 180.0].
72#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
73pub struct Longitude(f32);
74
75impl Longitude {
76    /// Constructs a `Longitude` from a raw `f32`, rejecting non-finite values
77    /// and values outside [-180.0, 180.0].
78    ///
79    /// # Errors
80    ///
81    /// | Variant | Condition |
82    /// |---|---|
83    /// | [`GeoError::NonFiniteCoordinate`] | `raw` is NaN or infinite |
84    /// | [`GeoError::LongitudeOutOfRange`] | `raw` is outside [-180.0, 180.0] |
85    pub fn new(raw: f32) -> Result<Self, GeoError> {
86        if !raw.is_finite() {
87            return Err(GeoError::NonFiniteCoordinate { value: raw });
88        }
89        if !(-180.0..=180.0).contains(&raw) {
90            return Err(GeoError::LongitudeOutOfRange { value: raw });
91        }
92        Ok(Self(raw))
93    }
94
95    /// Returns the raw longitude value.
96    pub fn get(self) -> f32 {
97        self.0
98    }
99}
100
101/// A validated WGS84 latitude in the range [-90.0, 90.0].
102#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
103pub struct Latitude(f32);
104
105impl Latitude {
106    /// Constructs a `Latitude` from a raw `f32`, rejecting non-finite values
107    /// and values outside [-90.0, 90.0].
108    ///
109    /// # Errors
110    ///
111    /// | Variant | Condition |
112    /// |---|---|
113    /// | [`GeoError::NonFiniteCoordinate`] | `raw` is NaN or infinite |
114    /// | [`GeoError::LatitudeOutOfRange`] | `raw` is outside [-90.0, 90.0] |
115    pub fn new(raw: f32) -> Result<Self, GeoError> {
116        if !raw.is_finite() {
117            return Err(GeoError::NonFiniteCoordinate { value: raw });
118        }
119        if !(-90.0..=90.0).contains(&raw) {
120            return Err(GeoError::LatitudeOutOfRange { value: raw });
121        }
122        Ok(Self(raw))
123    }
124
125    /// Returns the raw latitude value.
126    pub fn get(self) -> f32 {
127        self.0
128    }
129}
130
131/// An axis-aligned bounding box in WGS84 coordinates.
132#[derive(Debug, Clone, Copy, PartialEq)]
133pub struct BoundingBox {
134    min_x: Longitude,
135    min_y: Latitude,
136    max_x: Longitude,
137    max_y: Latitude,
138}
139
140impl BoundingBox {
141    /// Constructs a `BoundingBox` from raw coordinate values.
142    ///
143    /// Validates each coordinate individually, then checks that the box is
144    /// non-degenerate (`minx < maxx` and `miny < maxy`).
145    ///
146    /// # Errors
147    ///
148    /// | Variant | Condition |
149    /// |---|---|
150    /// | [`GeoError::NonFiniteCoordinate`] | Any value is NaN or infinite |
151    /// | [`GeoError::LongitudeOutOfRange`] | `minx` or `maxx` outside [-180, 180] |
152    /// | [`GeoError::LatitudeOutOfRange`] | `miny` or `maxy` outside [-90, 90] |
153    /// | [`GeoError::DegenerateBbox`] | `minx >= maxx` or `miny >= maxy` |
154    pub fn new(minx: f32, miny: f32, maxx: f32, maxy: f32) -> Result<Self, GeoError> {
155        let min_x = Longitude::new(minx)?;
156        let max_x = Longitude::new(maxx)?;
157        let min_y = Latitude::new(miny)?;
158        let max_y = Latitude::new(maxy)?;
159
160        if minx >= maxx {
161            return Err(GeoError::DegenerateBbox {
162                axis: "x",
163                min: minx,
164                max: maxx,
165            });
166        }
167        if miny >= maxy {
168            return Err(GeoError::DegenerateBbox {
169                axis: "y",
170                min: miny,
171                max: maxy,
172            });
173        }
174
175        Ok(Self {
176            min_x,
177            min_y,
178            max_x,
179            max_y,
180        })
181    }
182
183    /// Returns the western boundary longitude.
184    pub fn min_x(&self) -> Longitude {
185        self.min_x
186    }
187
188    /// Returns the southern boundary latitude.
189    pub fn min_y(&self) -> Latitude {
190        self.min_y
191    }
192
193    /// Returns the eastern boundary longitude.
194    pub fn max_x(&self) -> Longitude {
195        self.max_x
196    }
197
198    /// Returns the northern boundary latitude.
199    pub fn max_y(&self) -> Latitude {
200        self.max_y
201    }
202
203    /// Returns `true` if the given coordinate falls within or on the boundary
204    /// of this bounding box.
205    pub fn contains(&self, lon: Longitude, lat: Latitude) -> bool {
206        lon.get() >= self.min_x.get()
207            && lon.get() <= self.max_x.get()
208            && lat.get() >= self.min_y.get()
209            && lat.get() <= self.max_y.get()
210    }
211
212    /// Returns `true` if this bounding box overlaps with `other` (including
213    /// edge-touching).
214    pub fn intersects(&self, other: &BoundingBox) -> bool {
215        self.min_x.get() <= other.max_x.get()
216            && self.max_x.get() >= other.min_x.get()
217            && self.min_y.get() <= other.max_y.get()
218            && self.max_y.get() >= other.min_y.get()
219    }
220}
221
222/// A unit outlet coordinate in EPSG:4326.
223#[derive(Debug, Clone, Copy, PartialEq)]
224pub struct OutletCoord {
225    lon: f64,
226    lat: f64,
227}
228
229impl OutletCoord {
230    /// Construct an [`OutletCoord`] from longitude and latitude.
231    ///
232    /// # Errors
233    ///
234    /// | Condition | Error variant |
235    /// |---|---|
236    /// | `lon` is NaN or infinite | [`GeoError::NonFiniteOutletLongitude`] |
237    /// | `lat` is NaN or infinite | [`GeoError::NonFiniteOutletLatitude`] |
238    /// | `lon` is outside [-180, 180] | [`GeoError::OutletLongitudeOutOfRange`] |
239    /// | `lat` is outside [-90, 90] | [`GeoError::OutletLatitudeOutOfRange`] |
240    pub fn new(lon: f64, lat: f64) -> Result<Self, GeoError> {
241        if !lon.is_finite() {
242            return Err(GeoError::NonFiniteOutletLongitude { value: lon });
243        }
244        if !lat.is_finite() {
245            return Err(GeoError::NonFiniteOutletLatitude { value: lat });
246        }
247        if !(-180.0..=180.0).contains(&lon) {
248            return Err(GeoError::OutletLongitudeOutOfRange { value: lon });
249        }
250        if !(-90.0..=90.0).contains(&lat) {
251            return Err(GeoError::OutletLatitudeOutOfRange { value: lat });
252        }
253        Ok(Self { lon, lat })
254    }
255
256    /// Return the outlet longitude.
257    pub fn lon(self) -> f64 {
258        self.lon
259    }
260
261    /// Return the outlet latitude.
262    pub fn lat(self) -> f64 {
263        self.lat
264    }
265}
266
267/// Opaque WKB (Well-Known Binary) geometry bytes.
268///
269/// `hfx-core` treats WKB as a raw byte buffer and does not parse its internal
270/// structure. Callers that need geometry operations should use a dedicated
271/// geometry library (e.g. `geo`, `geos`).
272#[derive(Debug, Clone, PartialEq)]
273pub struct WkbGeometry(Vec<u8>);
274
275impl WkbGeometry {
276    /// Wraps a raw WKB byte vector, rejecting empty inputs.
277    ///
278    /// # Errors
279    ///
280    /// | Variant | Condition |
281    /// |---|---|
282    /// | [`GeoError::EmptyGeometry`] | `raw` is empty |
283    pub fn new(raw: Vec<u8>) -> Result<Self, GeoError> {
284        if raw.is_empty() {
285            return Err(GeoError::EmptyGeometry);
286        }
287        Ok(Self(raw))
288    }
289
290    /// Returns a byte slice of the raw WKB data.
291    pub fn as_bytes(&self) -> &[u8] {
292        &self.0
293    }
294
295    /// Consumes the wrapper and returns the raw WKB byte vector.
296    pub fn into_bytes(self) -> Vec<u8> {
297        self.0
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    // --- Longitude ---
306
307    #[test]
308    fn longitude_valid_boundaries() {
309        assert!(Longitude::new(-180.0).is_ok());
310        assert!(Longitude::new(180.0).is_ok());
311        assert!(Longitude::new(0.0).is_ok());
312    }
313
314    #[test]
315    fn longitude_out_of_range() {
316        assert!(matches!(
317            Longitude::new(180.1),
318            Err(GeoError::LongitudeOutOfRange { value }) if (value - 180.1).abs() < 0.001
319        ));
320        assert!(matches!(
321            Longitude::new(-180.1),
322            Err(GeoError::LongitudeOutOfRange { .. })
323        ));
324    }
325
326    #[test]
327    fn longitude_non_finite() {
328        assert!(matches!(
329            Longitude::new(f32::NAN),
330            Err(GeoError::NonFiniteCoordinate { .. })
331        ));
332        assert!(matches!(
333            Longitude::new(f32::INFINITY),
334            Err(GeoError::NonFiniteCoordinate { .. })
335        ));
336    }
337
338    #[test]
339    fn longitude_get_roundtrips() {
340        let lon = Longitude::new(42.5).unwrap();
341        assert!((lon.get() - 42.5).abs() < f32::EPSILON);
342    }
343
344    // --- Latitude ---
345
346    #[test]
347    fn latitude_valid_boundaries() {
348        assert!(Latitude::new(-90.0).is_ok());
349        assert!(Latitude::new(90.0).is_ok());
350        assert!(Latitude::new(0.0).is_ok());
351    }
352
353    #[test]
354    fn latitude_out_of_range() {
355        assert!(matches!(
356            Latitude::new(90.1),
357            Err(GeoError::LatitudeOutOfRange { .. })
358        ));
359        assert!(matches!(
360            Latitude::new(-90.1),
361            Err(GeoError::LatitudeOutOfRange { .. })
362        ));
363    }
364
365    #[test]
366    fn latitude_non_finite() {
367        assert!(matches!(
368            Latitude::new(f32::NEG_INFINITY),
369            Err(GeoError::NonFiniteCoordinate { .. })
370        ));
371    }
372
373    // --- BoundingBox ---
374
375    #[test]
376    fn bbox_valid() {
377        let bb = BoundingBox::new(-10.0, -5.0, 10.0, 5.0);
378        assert!(bb.is_ok());
379    }
380
381    #[test]
382    fn bbox_degenerate_x() {
383        assert!(matches!(
384            BoundingBox::new(5.0, -5.0, 5.0, 5.0),
385            Err(GeoError::DegenerateBbox { axis: "x", .. })
386        ));
387    }
388
389    #[test]
390    fn bbox_degenerate_y() {
391        assert!(matches!(
392            BoundingBox::new(-5.0, 5.0, 5.0, 5.0),
393            Err(GeoError::DegenerateBbox { axis: "y", .. })
394        ));
395    }
396
397    #[test]
398    fn bbox_non_finite_propagates() {
399        assert!(matches!(
400            BoundingBox::new(f32::NAN, 0.0, 10.0, 5.0),
401            Err(GeoError::NonFiniteCoordinate { .. })
402        ));
403    }
404
405    #[test]
406    fn bbox_contains() {
407        let bb = BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap();
408        let inside_lon = Longitude::new(0.0).unwrap();
409        let inside_lat = Latitude::new(0.0).unwrap();
410        assert!(bb.contains(inside_lon, inside_lat));
411
412        let outside_lon = Longitude::new(15.0).unwrap();
413        assert!(!bb.contains(outside_lon, inside_lat));
414    }
415
416    #[test]
417    fn bbox_contains_on_boundary() {
418        let bb = BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap();
419        let edge_lon = Longitude::new(-10.0).unwrap();
420        let edge_lat = Latitude::new(5.0).unwrap();
421        assert!(bb.contains(edge_lon, edge_lat));
422    }
423
424    #[test]
425    fn bbox_intersects() {
426        let a = BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap();
427        let b = BoundingBox::new(5.0, 0.0, 20.0, 10.0).unwrap();
428        assert!(a.intersects(&b));
429        assert!(b.intersects(&a));
430    }
431
432    #[test]
433    fn bbox_no_intersect() {
434        let a = BoundingBox::new(-10.0, -5.0, 0.0, 5.0).unwrap();
435        let b = BoundingBox::new(5.0, -5.0, 10.0, 5.0).unwrap();
436        assert!(!a.intersects(&b));
437    }
438
439    // --- OutletCoord ---
440
441    #[test]
442    fn outlet_coord_valid_boundaries() {
443        let outlet = OutletCoord::new(-180.0, 90.0).unwrap();
444        assert_eq!(outlet.lon(), -180.0);
445        assert_eq!(outlet.lat(), 90.0);
446    }
447
448    #[test]
449    fn outlet_coord_rejects_non_finite_lon() {
450        assert!(matches!(
451            OutletCoord::new(f64::NAN, 0.0),
452            Err(GeoError::NonFiniteOutletLongitude { .. })
453        ));
454    }
455
456    #[test]
457    fn outlet_coord_rejects_non_finite_lat() {
458        assert!(matches!(
459            OutletCoord::new(0.0, f64::INFINITY),
460            Err(GeoError::NonFiniteOutletLatitude { .. })
461        ));
462    }
463
464    #[test]
465    fn outlet_coord_rejects_out_of_range_lon() {
466        assert!(matches!(
467            OutletCoord::new(180.1, 0.0),
468            Err(GeoError::OutletLongitudeOutOfRange { .. })
469        ));
470    }
471
472    #[test]
473    fn outlet_coord_rejects_out_of_range_lat() {
474        assert!(matches!(
475            OutletCoord::new(0.0, -90.1),
476            Err(GeoError::OutletLatitudeOutOfRange { .. })
477        ));
478    }
479
480    #[test]
481    fn bbox_getters() {
482        let bb = BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap();
483        assert!((bb.min_x().get() - (-10.0)).abs() < f32::EPSILON);
484        assert!((bb.min_y().get() - (-5.0)).abs() < f32::EPSILON);
485        assert!((bb.max_x().get() - 10.0).abs() < f32::EPSILON);
486        assert!((bb.max_y().get() - 5.0).abs() < f32::EPSILON);
487    }
488
489    // --- WkbGeometry ---
490
491    #[test]
492    fn wkb_valid() {
493        let geom = WkbGeometry::new(vec![0x01, 0x02, 0x03]);
494        assert!(geom.is_ok());
495    }
496
497    #[test]
498    fn wkb_empty_rejected() {
499        assert!(matches!(
500            WkbGeometry::new(vec![]),
501            Err(GeoError::EmptyGeometry)
502        ));
503    }
504
505    #[test]
506    fn wkb_as_bytes() {
507        let geom = WkbGeometry::new(vec![0xDE, 0xAD]).unwrap();
508        assert_eq!(geom.as_bytes(), &[0xDE, 0xAD]);
509    }
510
511    #[test]
512    fn wkb_into_bytes() {
513        let raw = vec![0xBE, 0xEF];
514        let geom = WkbGeometry::new(raw.clone()).unwrap();
515        assert_eq!(geom.into_bytes(), raw);
516    }
517
518    #[test]
519    fn bbox_reversed_x_fails_with_degenerate_bbox() {
520        // maxx < minx: a clearly reversed x-axis should be rejected.
521        assert!(matches!(
522            BoundingBox::new(10.0, -5.0, -10.0, 5.0),
523            Err(GeoError::DegenerateBbox { axis: "x", .. })
524        ));
525    }
526
527    #[test]
528    fn bbox_longitude_out_of_range_propagates() {
529        assert!(matches!(
530            BoundingBox::new(-200.0, -5.0, 10.0, 5.0),
531            Err(GeoError::LongitudeOutOfRange { .. })
532        ));
533    }
534
535    #[test]
536    fn bbox_near_antimeridian_succeeds() {
537        // f32 precision near 180.0: both 179.0 and 180.0 are representable
538        // exactly as f32, so this box must construct without error.
539        assert!(BoundingBox::new(179.0, -5.0, 180.0, 5.0).is_ok());
540    }
541
542    #[test]
543    fn wkb_clone_produces_equal_value() {
544        let geom = WkbGeometry::new(vec![0x01, 0x02, 0x03]).unwrap();
545        let cloned = geom.clone();
546        assert_eq!(geom, cloned);
547    }
548
549    #[test]
550    fn bbox_edge_touching_intersects() {
551        // The two boxes share only the vertical edge at x = 0.
552        let a = BoundingBox::new(-10.0, -5.0, 0.0, 5.0).unwrap();
553        let b = BoundingBox::new(0.0, -5.0, 10.0, 5.0).unwrap();
554        assert!(a.intersects(&b));
555        assert!(b.intersects(&a));
556    }
557}