Skip to main content

async_geotiff/
bounds.rs

1use raffine::Affine;
2
3/// An axis-aligned rectangle in the coordinate units of its associated CRS.
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct Bbox {
6    /// Minimum x coordinate (west in longitude/latitude order).
7    pub xmin: f64,
8    /// Minimum y coordinate (south in longitude/latitude order).
9    pub ymin: f64,
10    /// Maximum x coordinate (east in longitude/latitude order).
11    pub xmax: f64,
12    /// Maximum y coordinate (north in longitude/latitude order).
13    pub ymax: f64,
14}
15
16impl Bbox {
17    /// Store rectangle coordinates without reordering or validating them.
18    pub fn new(xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> Self {
19        Self {
20            xmin,
21            ymin,
22            xmax,
23            ymax,
24        }
25    }
26    /// Return `xmax - xmin` in CRS units.
27    pub fn width(&self) -> f64 {
28        self.xmax - self.xmin
29    }
30    /// Return `ymax - ymin` in CRS units.
31    pub fn height(&self) -> f64 {
32        self.ymax - self.ymin
33    }
34
35    /// Bbox enclosing all four transformed corners.
36    pub fn from_corners(corners: &[(f64, f64); 4]) -> Self {
37        let mut xmin = f64::INFINITY;
38        let mut ymin = f64::INFINITY;
39        let mut xmax = f64::NEG_INFINITY;
40        let mut ymax = f64::NEG_INFINITY;
41        for &(x, y) in corners {
42            if x < xmin {
43                xmin = x;
44            }
45            if y < ymin {
46                ymin = y;
47            }
48            if x > xmax {
49                xmax = x;
50            }
51            if y > ymax {
52                ymax = y;
53            }
54        }
55        Self {
56            xmin,
57            ymin,
58            xmax,
59            ymax,
60        }
61    }
62}
63
64/// Newtype wrapping `raffine::Affine` as a GeoTransform.
65///
66/// `from_gdal(&[c, a, b, f, d, e])` accepts the same 6-tuple format used by GDAL
67/// and rasterio. Forward maps pixel → world; `inverse()` maps world → pixel.
68#[derive(Debug, Clone, Copy)]
69pub struct GeoTransform(pub Affine);
70
71impl GeoTransform {
72    /// Construct from GDAL coefficients `[x_origin, x_scale, x_skew, y_origin, y_skew, y_scale]`.
73    pub fn from_gdal(gt: &[f64; 6]) -> Self {
74        Self(Affine::from_gdal(gt))
75    }
76
77    /// Transform pixel coordinates, with integer coordinates at pixel corners.
78    #[inline]
79    pub fn forward(&self, col: f64, row: f64) -> (f64, f64) {
80        self.0 * (col, row)
81    }
82
83    /// Shift the pixel-space origin by the given column and row offsets.
84    pub fn translated(&self, col_off: f64, row_off: f64) -> Self {
85        Self(self.0 * Affine::translation(col_off, row_off))
86    }
87
88    /// Scale pixel-space column and row coordinates before mapping to the CRS.
89    pub fn scaled(&self, sx: f64, sy: f64) -> Self {
90        Self(self.0 * Affine::scale(sx, Some(sy)))
91    }
92
93    /// Return the world-to-pixel transform, or `None` for a singular transform.
94    pub fn inverse(&self) -> Option<GeoTransform> {
95        (!self.0).ok().map(GeoTransform)
96    }
97
98    /// Pixel window covering the bbox.
99    /// Returns `(col_off, row_off, width, height)`, or `None` when the transform
100    /// is not invertible or the pixel endpoints cannot be represented as `i64`.
101    pub fn window_for_bbox(&self, bbox: &Bbox) -> Option<(i64, i64, u64, u64)> {
102        let inv = self.inverse()?;
103        let (c0, r0) = inv.forward(bbox.xmin, bbox.ymax);
104        let (c1, r1) = inv.forward(bbox.xmax, bbox.ymin);
105        let (c2, r2) = inv.forward(bbox.xmin, bbox.ymin);
106        let (c3, r3) = inv.forward(bbox.xmax, bbox.ymax);
107        if [c0, r0, c1, r1, c2, r2, c3, r3]
108            .iter()
109            .any(|v| !v.is_finite())
110        {
111            return None;
112        }
113        // Integer pixel cover of the projected footprint is
114        // [floor(min), ceil(max)). Using ceil(max-min) (the raw span)
115        // undercounts by 1 px whenever both endpoints are fractional —
116        // the normal post-reprojection case — leaving the tile's
117        // right/bottom edge unread → transparent seams between tiles,
118        // amplified at coarse overviews by the upsample factor.
119        let cmin = c0.min(c1).min(c2).min(c3);
120        let cmax = c0.max(c1).max(c2).max(c3);
121        let rmin = r0.min(r1).min(r2).min(r3);
122        let rmax = r0.max(r1).max(r2).max(r3);
123        let endpoints = [cmin.floor(), rmin.floor(), cmax.ceil(), rmax.ceil()];
124        // i64::MAX rounds up to 2^63 as f64, so the upper bound is exclusive.
125        if endpoints
126            .iter()
127            .any(|&v| v < i64::MIN as f64 || v >= i64::MAX as f64)
128        {
129            return None;
130        }
131        let [col_off, row_off, col_end, row_end] = endpoints.map(|v| v as i64);
132        let width = col_end.abs_diff(col_off);
133        let height = row_end.abs_diff(row_off);
134        Some((col_off, row_off, width, height))
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    #[test]
141    fn sheared_window_covers_all_four_corners() {
142        let transform = super::GeoTransform::from_gdal(&[0.0, 1.0, 0.25, 0.0, 0.0, -1.0]);
143        assert_eq!(
144            transform.window_for_bbox(&super::Bbox::new(10.0, -20.0, 20.0, -10.0)),
145            Some((5, 10, 13, 10))
146        );
147    }
148    use super::*;
149    use approx::assert_relative_eq;
150
151    #[test]
152    fn window_rejects_non_finite_and_unrepresentable_endpoints() {
153        let gt = GeoTransform::from_gdal(&[0.0, 1e-18, 0.0, 0.0, 0.0, -1e-18]);
154        assert!(gt
155            .window_for_bbox(&Bbox::new(-180.0, -90.0, 0.0, 90.0))
156            .is_none());
157        let identity = GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
158        for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, i64::MAX as f64] {
159            assert!(identity
160                .window_for_bbox(&Bbox::new(0.0, 0.0, value, 1.0))
161                .is_none());
162        }
163    }
164
165    #[test]
166    fn bbox_from_corners_encloses_all() {
167        let b = Bbox::from_corners(&[(0.0, 0.0), (10.0, 5.0), (-1.0, 3.0), (4.0, 8.0)]);
168        assert_eq!(b, Bbox::new(-1.0, 0.0, 10.0, 8.0));
169    }
170
171    #[test]
172    fn geotransform_forward_inverse_roundtrip() {
173        // GDAL: origin (100, 200), pixel size (1, -1)
174        let gt = GeoTransform::from_gdal(&[100.0, 1.0, 0.0, 200.0, 0.0, -1.0]);
175        let (x, y) = gt.forward(0.0, 0.0);
176        assert_relative_eq!(x, 100.0);
177        assert_relative_eq!(y, 200.0);
178
179        let inv = gt.inverse().unwrap();
180        let (c, r) = inv.forward(100.0, 200.0);
181        assert_relative_eq!(c, 0.0, epsilon = 1e-9);
182        assert_relative_eq!(r, 0.0, epsilon = 1e-9);
183    }
184
185    #[test]
186    fn window_for_bbox_clamps_correctly() {
187        let gt = GeoTransform::from_gdal(&[0.0, 1.0, 0.0, 100.0, 0.0, -1.0]);
188        let bbox = Bbox::new(10.0, 50.0, 30.0, 90.0);
189        let (c, r, w, h) = gt.window_for_bbox(&bbox).unwrap();
190        assert_eq!((c, r, w, h), (10, 10, 20, 40));
191    }
192}