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
43/// A validated WGS84 longitude in the range [-180.0, 180.0].
44#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
45pub struct Longitude(f32);
46
47impl Longitude {
48    /// Constructs a `Longitude` from a raw `f32`, rejecting non-finite values
49    /// and values outside [-180.0, 180.0].
50    ///
51    /// # Errors
52    ///
53    /// | Variant | Condition |
54    /// |---|---|
55    /// | [`GeoError::NonFiniteCoordinate`] | `raw` is NaN or infinite |
56    /// | [`GeoError::LongitudeOutOfRange`] | `raw` is outside [-180.0, 180.0] |
57    pub fn new(raw: f32) -> Result<Self, GeoError> {
58        if !raw.is_finite() {
59            return Err(GeoError::NonFiniteCoordinate { value: raw });
60        }
61        if !(-180.0..=180.0).contains(&raw) {
62            return Err(GeoError::LongitudeOutOfRange { value: raw });
63        }
64        Ok(Self(raw))
65    }
66
67    /// Returns the raw longitude value.
68    pub fn get(self) -> f32 {
69        self.0
70    }
71}
72
73/// A validated WGS84 latitude in the range [-90.0, 90.0].
74#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
75pub struct Latitude(f32);
76
77impl Latitude {
78    /// Constructs a `Latitude` from a raw `f32`, rejecting non-finite values
79    /// and values outside [-90.0, 90.0].
80    ///
81    /// # Errors
82    ///
83    /// | Variant | Condition |
84    /// |---|---|
85    /// | [`GeoError::NonFiniteCoordinate`] | `raw` is NaN or infinite |
86    /// | [`GeoError::LatitudeOutOfRange`] | `raw` is outside [-90.0, 90.0] |
87    pub fn new(raw: f32) -> Result<Self, GeoError> {
88        if !raw.is_finite() {
89            return Err(GeoError::NonFiniteCoordinate { value: raw });
90        }
91        if !(-90.0..=90.0).contains(&raw) {
92            return Err(GeoError::LatitudeOutOfRange { value: raw });
93        }
94        Ok(Self(raw))
95    }
96
97    /// Returns the raw latitude value.
98    pub fn get(self) -> f32 {
99        self.0
100    }
101}
102
103/// An axis-aligned bounding box in WGS84 coordinates.
104#[derive(Debug, Clone, Copy, PartialEq)]
105pub struct BoundingBox {
106    min_x: Longitude,
107    min_y: Latitude,
108    max_x: Longitude,
109    max_y: Latitude,
110}
111
112impl BoundingBox {
113    /// Constructs a `BoundingBox` from raw coordinate values.
114    ///
115    /// Validates each coordinate individually, then checks that the box is
116    /// non-degenerate (`minx < maxx` and `miny < maxy`).
117    ///
118    /// # Errors
119    ///
120    /// | Variant | Condition |
121    /// |---|---|
122    /// | [`GeoError::NonFiniteCoordinate`] | Any value is NaN or infinite |
123    /// | [`GeoError::LongitudeOutOfRange`] | `minx` or `maxx` outside [-180, 180] |
124    /// | [`GeoError::LatitudeOutOfRange`] | `miny` or `maxy` outside [-90, 90] |
125    /// | [`GeoError::DegenerateBbox`] | `minx >= maxx` or `miny >= maxy` |
126    pub fn new(minx: f32, miny: f32, maxx: f32, maxy: f32) -> Result<Self, GeoError> {
127        let min_x = Longitude::new(minx)?;
128        let max_x = Longitude::new(maxx)?;
129        let min_y = Latitude::new(miny)?;
130        let max_y = Latitude::new(maxy)?;
131
132        if minx >= maxx {
133            return Err(GeoError::DegenerateBbox {
134                axis: "x",
135                min: minx,
136                max: maxx,
137            });
138        }
139        if miny >= maxy {
140            return Err(GeoError::DegenerateBbox {
141                axis: "y",
142                min: miny,
143                max: maxy,
144            });
145        }
146
147        Ok(Self { min_x, min_y, max_x, max_y })
148    }
149
150    /// Returns the western boundary longitude.
151    pub fn min_x(&self) -> Longitude {
152        self.min_x
153    }
154
155    /// Returns the southern boundary latitude.
156    pub fn min_y(&self) -> Latitude {
157        self.min_y
158    }
159
160    /// Returns the eastern boundary longitude.
161    pub fn max_x(&self) -> Longitude {
162        self.max_x
163    }
164
165    /// Returns the northern boundary latitude.
166    pub fn max_y(&self) -> Latitude {
167        self.max_y
168    }
169
170    /// Returns `true` if the given coordinate falls within or on the boundary
171    /// of this bounding box.
172    pub fn contains(&self, lon: Longitude, lat: Latitude) -> bool {
173        lon.get() >= self.min_x.get()
174            && lon.get() <= self.max_x.get()
175            && lat.get() >= self.min_y.get()
176            && lat.get() <= self.max_y.get()
177    }
178
179    /// Returns `true` if this bounding box overlaps with `other` (including
180    /// edge-touching).
181    pub fn intersects(&self, other: &BoundingBox) -> bool {
182        self.min_x.get() <= other.max_x.get()
183            && self.max_x.get() >= other.min_x.get()
184            && self.min_y.get() <= other.max_y.get()
185            && self.max_y.get() >= other.min_y.get()
186    }
187}
188
189/// Opaque WKB (Well-Known Binary) geometry bytes.
190///
191/// `hfx-core` treats WKB as a raw byte buffer and does not parse its internal
192/// structure. Callers that need geometry operations should use a dedicated
193/// geometry library (e.g. `geo`, `geos`).
194#[derive(Debug, Clone, PartialEq)]
195pub struct WkbGeometry(Vec<u8>);
196
197impl WkbGeometry {
198    /// Wraps a raw WKB byte vector, rejecting empty inputs.
199    ///
200    /// # Errors
201    ///
202    /// | Variant | Condition |
203    /// |---|---|
204    /// | [`GeoError::EmptyGeometry`] | `raw` is empty |
205    pub fn new(raw: Vec<u8>) -> Result<Self, GeoError> {
206        if raw.is_empty() {
207            return Err(GeoError::EmptyGeometry);
208        }
209        Ok(Self(raw))
210    }
211
212    /// Returns a byte slice of the raw WKB data.
213    pub fn as_bytes(&self) -> &[u8] {
214        &self.0
215    }
216
217    /// Consumes the wrapper and returns the raw WKB byte vector.
218    pub fn into_bytes(self) -> Vec<u8> {
219        self.0
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    // --- Longitude ---
228
229    #[test]
230    fn longitude_valid_boundaries() {
231        assert!(Longitude::new(-180.0).is_ok());
232        assert!(Longitude::new(180.0).is_ok());
233        assert!(Longitude::new(0.0).is_ok());
234    }
235
236    #[test]
237    fn longitude_out_of_range() {
238        assert!(matches!(
239            Longitude::new(180.1),
240            Err(GeoError::LongitudeOutOfRange { value }) if (value - 180.1).abs() < 0.001
241        ));
242        assert!(matches!(
243            Longitude::new(-180.1),
244            Err(GeoError::LongitudeOutOfRange { .. })
245        ));
246    }
247
248    #[test]
249    fn longitude_non_finite() {
250        assert!(matches!(
251            Longitude::new(f32::NAN),
252            Err(GeoError::NonFiniteCoordinate { .. })
253        ));
254        assert!(matches!(
255            Longitude::new(f32::INFINITY),
256            Err(GeoError::NonFiniteCoordinate { .. })
257        ));
258    }
259
260    #[test]
261    fn longitude_get_roundtrips() {
262        let lon = Longitude::new(42.5).unwrap();
263        assert!((lon.get() - 42.5).abs() < f32::EPSILON);
264    }
265
266    // --- Latitude ---
267
268    #[test]
269    fn latitude_valid_boundaries() {
270        assert!(Latitude::new(-90.0).is_ok());
271        assert!(Latitude::new(90.0).is_ok());
272        assert!(Latitude::new(0.0).is_ok());
273    }
274
275    #[test]
276    fn latitude_out_of_range() {
277        assert!(matches!(
278            Latitude::new(90.1),
279            Err(GeoError::LatitudeOutOfRange { .. })
280        ));
281        assert!(matches!(
282            Latitude::new(-90.1),
283            Err(GeoError::LatitudeOutOfRange { .. })
284        ));
285    }
286
287    #[test]
288    fn latitude_non_finite() {
289        assert!(matches!(
290            Latitude::new(f32::NEG_INFINITY),
291            Err(GeoError::NonFiniteCoordinate { .. })
292        ));
293    }
294
295    // --- BoundingBox ---
296
297    #[test]
298    fn bbox_valid() {
299        let bb = BoundingBox::new(-10.0, -5.0, 10.0, 5.0);
300        assert!(bb.is_ok());
301    }
302
303    #[test]
304    fn bbox_degenerate_x() {
305        assert!(matches!(
306            BoundingBox::new(5.0, -5.0, 5.0, 5.0),
307            Err(GeoError::DegenerateBbox { axis: "x", .. })
308        ));
309    }
310
311    #[test]
312    fn bbox_degenerate_y() {
313        assert!(matches!(
314            BoundingBox::new(-5.0, 5.0, 5.0, 5.0),
315            Err(GeoError::DegenerateBbox { axis: "y", .. })
316        ));
317    }
318
319    #[test]
320    fn bbox_non_finite_propagates() {
321        assert!(matches!(
322            BoundingBox::new(f32::NAN, 0.0, 10.0, 5.0),
323            Err(GeoError::NonFiniteCoordinate { .. })
324        ));
325    }
326
327    #[test]
328    fn bbox_contains() {
329        let bb = BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap();
330        let inside_lon = Longitude::new(0.0).unwrap();
331        let inside_lat = Latitude::new(0.0).unwrap();
332        assert!(bb.contains(inside_lon, inside_lat));
333
334        let outside_lon = Longitude::new(15.0).unwrap();
335        assert!(!bb.contains(outside_lon, inside_lat));
336    }
337
338    #[test]
339    fn bbox_contains_on_boundary() {
340        let bb = BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap();
341        let edge_lon = Longitude::new(-10.0).unwrap();
342        let edge_lat = Latitude::new(5.0).unwrap();
343        assert!(bb.contains(edge_lon, edge_lat));
344    }
345
346    #[test]
347    fn bbox_intersects() {
348        let a = BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap();
349        let b = BoundingBox::new(5.0, 0.0, 20.0, 10.0).unwrap();
350        assert!(a.intersects(&b));
351        assert!(b.intersects(&a));
352    }
353
354    #[test]
355    fn bbox_no_intersect() {
356        let a = BoundingBox::new(-10.0, -5.0, 0.0, 5.0).unwrap();
357        let b = BoundingBox::new(5.0, -5.0, 10.0, 5.0).unwrap();
358        assert!(!a.intersects(&b));
359    }
360
361    #[test]
362    fn bbox_getters() {
363        let bb = BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap();
364        assert!((bb.min_x().get() - (-10.0)).abs() < f32::EPSILON);
365        assert!((bb.min_y().get() - (-5.0)).abs() < f32::EPSILON);
366        assert!((bb.max_x().get() - 10.0).abs() < f32::EPSILON);
367        assert!((bb.max_y().get() - 5.0).abs() < f32::EPSILON);
368    }
369
370    // --- WkbGeometry ---
371
372    #[test]
373    fn wkb_valid() {
374        let geom = WkbGeometry::new(vec![0x01, 0x02, 0x03]);
375        assert!(geom.is_ok());
376    }
377
378    #[test]
379    fn wkb_empty_rejected() {
380        assert!(matches!(WkbGeometry::new(vec![]), Err(GeoError::EmptyGeometry)));
381    }
382
383    #[test]
384    fn wkb_as_bytes() {
385        let geom = WkbGeometry::new(vec![0xDE, 0xAD]).unwrap();
386        assert_eq!(geom.as_bytes(), &[0xDE, 0xAD]);
387    }
388
389    #[test]
390    fn wkb_into_bytes() {
391        let raw = vec![0xBE, 0xEF];
392        let geom = WkbGeometry::new(raw.clone()).unwrap();
393        assert_eq!(geom.into_bytes(), raw);
394    }
395
396    #[test]
397    fn bbox_reversed_x_fails_with_degenerate_bbox() {
398        // maxx < minx: a clearly reversed x-axis should be rejected.
399        assert!(matches!(
400            BoundingBox::new(10.0, -5.0, -10.0, 5.0),
401            Err(GeoError::DegenerateBbox { axis: "x", .. })
402        ));
403    }
404
405    #[test]
406    fn bbox_longitude_out_of_range_propagates() {
407        assert!(matches!(
408            BoundingBox::new(-200.0, -5.0, 10.0, 5.0),
409            Err(GeoError::LongitudeOutOfRange { .. })
410        ));
411    }
412
413    #[test]
414    fn bbox_near_antimeridian_succeeds() {
415        // f32 precision near 180.0: both 179.0 and 180.0 are representable
416        // exactly as f32, so this box must construct without error.
417        assert!(BoundingBox::new(179.0, -5.0, 180.0, 5.0).is_ok());
418    }
419
420    #[test]
421    fn wkb_clone_produces_equal_value() {
422        let geom = WkbGeometry::new(vec![0x01, 0x02, 0x03]).unwrap();
423        let cloned = geom.clone();
424        assert_eq!(geom, cloned);
425    }
426
427    #[test]
428    fn bbox_edge_touching_intersects() {
429        // The two boxes share only the vertical edge at x = 0.
430        let a = BoundingBox::new(-10.0, -5.0, 0.0, 5.0).unwrap();
431        let b = BoundingBox::new(0.0, -5.0, 10.0, 5.0).unwrap();
432        assert!(a.intersects(&b));
433        assert!(b.intersects(&a));
434    }
435}