Skip to main content

async_geotiff/
tms.rs

1use crate::bounds::Bbox;
2use crate::crs::Crs;
3use crate::error::{AgError, AgResult};
4use std::str::FromStr;
5use tile_grid::{tms as registry, Tms as InnerTms, Xyz};
6
7/// Supported OGC TileMatrixSet identifiers.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TileMatrixSet {
10    /// Square Web Mercator tiles in EPSG:3857.
11    WebMercatorQuad,
12    /// Geographic tiles in longitude/latitude order, two columns at zoom zero.
13    WorldCrs84Quad,
14}
15
16impl TileMatrixSet {
17    /// Return the canonical OGC identifier.
18    pub fn name(self) -> &'static str {
19        match self {
20            Self::WebMercatorQuad => "WebMercatorQuad",
21            Self::WorldCrs84Quad => "WorldCRS84Quad",
22        }
23    }
24
25    /// Parse a canonical or all-lowercase name, rejecting unknown matrix sets.
26    pub fn parse_name(s: &str) -> AgResult<Self> {
27        match s {
28            "WebMercatorQuad" | "webmercatorquad" => Ok(Self::WebMercatorQuad),
29            "WorldCRS84Quad" | "worldcrs84quad" => Ok(Self::WorldCrs84Quad),
30            other => Err(AgError::UnknownTms(other.into())),
31        }
32    }
33
34    /// Return the CRS used for tile bounds and pixel coordinates.
35    pub fn crs(self) -> Crs {
36        match self {
37            Self::WebMercatorQuad => Crs::epsg(3857),
38            Self::WorldCrs84Quad => Crs::epsg(4326),
39        }
40    }
41}
42
43impl FromStr for TileMatrixSet {
44    type Err = AgError;
45
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        Self::parse_name(s)
48    }
49}
50
51/// One XYZ-slippy tile address within a [`TileMatrixSet`].
52///
53/// Groups the four values that previously travelled as positional parameters
54/// (`tile_matrix_set, z, x, y`) through every tile-rendering signature.
55/// Construct with a struct literal so `x` and `y` are named at the call site
56/// and cannot be silently swapped:
57///
58/// ```
59/// # use async_geotiff::tms::{TileCoord, TileMatrixSet};
60/// let coord = TileCoord { tms: TileMatrixSet::WebMercatorQuad, z: 12, x: 3412, y: 1670 };
61/// ```
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct TileCoord {
64    /// Tile matrix set defining the coordinate grid.
65    pub tms: TileMatrixSet,
66    /// Zoom level, validated against the matrix set when used.
67    pub z: u8,
68    /// Zero-based tile column increasing eastward.
69    pub x: u32,
70    /// Zero-based tile row increasing southward from the northern origin.
71    pub y: u32,
72}
73
74/// A resolved tile matrix set used to validate addresses and calculate bounds.
75pub struct Tms {
76    tile_matrix_set: TileMatrixSet,
77    inner: InnerTms,
78}
79
80impl Tms {
81    /// Load a supported matrix set from the bundled registry.
82    pub fn new(tile_matrix_set: TileMatrixSet) -> AgResult<Self> {
83        let inner = registry()
84            .lookup(tile_matrix_set.name())
85            .map_err(|e| AgError::UnknownTms(format!("{}: {}", tile_matrix_set.name(), e)))?;
86        Ok(Self {
87            tile_matrix_set,
88            inner,
89        })
90    }
91
92    /// A process-lifetime cached `Tms` for `tile_matrix_set`. [`Self::new`]
93    /// clones and re-sorts the whole matrix set and builds proj transformers;
94    /// the tile pipeline resolves a `Tms` on every tile (and every mosaic
95    /// asset), so cache one instance per variant. `Tms` is `Send + Sync`.
96    pub fn cached(tile_matrix_set: TileMatrixSet) -> AgResult<&'static Tms> {
97        use std::sync::OnceLock;
98        fn slot(v: TileMatrixSet) -> &'static OnceLock<Tms> {
99            static WEB_MERCATOR: OnceLock<Tms> = OnceLock::new();
100            static WORLD_CRS84: OnceLock<Tms> = OnceLock::new();
101            match v {
102                TileMatrixSet::WebMercatorQuad => &WEB_MERCATOR,
103                TileMatrixSet::WorldCrs84Quad => &WORLD_CRS84,
104            }
105        }
106        let cell = slot(tile_matrix_set);
107        if let Some(t) = cell.get() {
108            return Ok(t);
109        }
110        // Build outside the cell (Tms::new is fallible); a concurrent racer's
111        // instance is simply dropped by `get_or_init`.
112        let built = Tms::new(tile_matrix_set)?;
113        Ok(cell.get_or_init(|| built))
114    }
115
116    /// Return the identifier of this resolved matrix set.
117    pub fn tile_matrix_set(&self) -> TileMatrixSet {
118        self.tile_matrix_set
119    }
120
121    /// Return the CRS used by this matrix set.
122    pub fn crs(&self) -> Crs {
123        self.tile_matrix_set.crs()
124    }
125
126    /// Bbox of (z, x, y) in the TMS CRS. `y` is XYZ-slippy convention.
127    pub fn tile_bbox(&self, z: u8, x: u32, y: u32) -> AgResult<Bbox> {
128        self.validate_tile(z, x, y)?;
129        let bb = self.inner.xy_bounds(&Xyz::new(x.into(), y.into(), z));
130        Ok(Bbox::new(bb.left, bb.bottom, bb.right, bb.top))
131    }
132
133    /// CRS-unit resolution at zoom `z` (units per pixel of the matrix).
134    pub fn resolution(&self, z: u8) -> AgResult<f64> {
135        self.inner
136            .resolution_z(z)
137            .ok_or(AgError::TileOutOfBounds { z, x: 0, y: 0 })
138    }
139
140    pub(crate) fn validate_tile(&self, z: u8, x: u32, y: u32) -> AgResult<()> {
141        // Both supported matrix sets use a north-west origin.
142        let matrix = self
143            .inner
144            .matrix_z(z)
145            .ok_or(AgError::TileOutOfBounds { z, x, y })?;
146        let width: u64 = matrix.matrix_width.into();
147        let height: u64 = matrix.matrix_height.into();
148        if u64::from(x) >= width || u64::from(y) >= height {
149            return Err(AgError::TileOutOfBounds { z, x, y });
150        }
151        Ok(())
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use approx::assert_relative_eq;
159
160    #[test]
161    fn unknown_tms_errors() {
162        assert!("foo".parse::<TileMatrixSet>().is_err());
163    }
164
165    #[test]
166    fn webmercatorquad_tile_0_0_0_is_full_world() {
167        let tms = Tms::new(TileMatrixSet::WebMercatorQuad).unwrap();
168        let bb = tms.tile_bbox(0, 0, 0).unwrap();
169        assert_relative_eq!(bb.xmin, -20037508.342789244, epsilon = 1e-3);
170        assert_relative_eq!(bb.xmax, 20037508.342789244, epsilon = 1e-3);
171    }
172
173    #[test]
174    fn tile_bounds_validate_both_axes_and_preserve_input() {
175        for (set, width) in [
176            (TileMatrixSet::WebMercatorQuad, 1),
177            (TileMatrixSet::WorldCrs84Quad, 2),
178        ] {
179            let tms = Tms::new(set).unwrap();
180            for z in [0, 5] {
181                let width = width << z;
182                let height = 1 << z;
183                assert!(tms.tile_bbox(z, width - 1, height - 1).is_ok());
184                for (x, y) in [(width, 0), (0, height), (width, height)] {
185                    assert!(matches!(tms.tile_bbox(z, x, y),
186                        Err(AgError::TileOutOfBounds { z: ez, x: ex, y: ey })
187                        if (ez, ex, ey) == (z, x, y)));
188                }
189            }
190            for z in [33, u8::MAX] {
191                assert!(matches!(tms.tile_bbox(z, 17, 19),
192                    Err(AgError::TileOutOfBounds { z: ez, x: 17, y: 19 }) if ez == z));
193            }
194        }
195    }
196
197    #[test]
198    fn resolution_decreases_with_zoom() {
199        let tms = Tms::new(TileMatrixSet::WebMercatorQuad).unwrap();
200        let r0 = tms.resolution(0).unwrap();
201        let r10 = tms.resolution(10).unwrap();
202        assert!(r0 > r10);
203    }
204}