Skip to main content

martin_tile_utils/
lib.rs

1#![doc = include_str!("../README.md")]
2
3// This code was partially adapted from https://github.com/maplibre/mbtileserver-rs
4// project originally written by Kaveh Karimi and licensed under MIT OR Apache-2.0
5
6use std::f64::consts::PI;
7use std::fmt::{Display, Formatter};
8
9/// circumference of the earth in meters
10pub const EARTH_CIRCUMFERENCE: f64 = 40_075_016.685_578_5;
11/// circumference of the earth in degrees
12pub const EARTH_CIRCUMFERENCE_DEGREES: u32 = 360;
13
14/// radius of the earth in meters
15pub const EARTH_RADIUS: f64 = EARTH_CIRCUMFERENCE / 2.0 / PI;
16
17pub const MAX_ZOOM: u8 = 30;
18
19mod decoders;
20pub use decoders::*;
21mod rectangle;
22pub use rectangle::{TileRect, append_rect};
23
24#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
25pub struct TileCoord {
26    pub z: u8,
27    pub x: u32,
28    pub y: u32,
29}
30
31pub type TileData = Vec<u8>;
32pub type Tile = (TileCoord, Option<TileData>);
33
34impl Display for TileCoord {
35    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
36        if f.alternate() {
37            write!(f, "{}/{}/{}", self.z, self.x, self.y)
38        } else {
39            write!(f, "{},{},{}", self.z, self.x, self.y)
40        }
41    }
42}
43
44impl TileCoord {
45    /// Checks provided coordinates for validity
46    /// before constructing [`TileCoord`] instance.
47    ///
48    /// Check [`Self::new_unchecked`] if you are sure that your inputs are possible.
49    #[must_use]
50    pub fn new_checked(z: u8, x: u32, y: u32) -> Option<TileCoord> {
51        Self::is_possible_on_zoom_level(z, x, y).then_some(Self { z, x, y })
52    }
53
54    /// Constructs [`TileCoord`] instance from arguments without checking that the tiles can exist.
55    ///
56    /// Check [`Self::new_checked`] if you are unsure if your inputs are possible.
57    #[must_use]
58    pub fn new_unchecked(z: u8, x: u32, y: u32) -> TileCoord {
59        Self { z, x, y }
60    }
61
62    /// Checks that zoom `z` is plausibily small and `x`/`y` is possible on said zoom level
63    #[must_use]
64    pub fn is_possible_on_zoom_level(z: u8, x: u32, y: u32) -> bool {
65        if z > MAX_ZOOM {
66            return false;
67        }
68
69        let side_len = 1_u32 << z;
70        x < side_len && y < side_len
71    }
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub enum Format {
76    Gif,
77    Jpeg,
78    Json,
79    Mvt,
80    Mlt,
81    Png,
82    Webp,
83    Avif,
84}
85
86impl Format {
87    #[must_use]
88    pub fn parse(value: &str) -> Option<Self> {
89        Some(match value.to_ascii_lowercase().as_str() {
90            "gif" => Self::Gif,
91            "jpg" | "jpeg" => Self::Jpeg,
92            "json" => Self::Json,
93            "pbf" | "mvt" => Self::Mvt,
94            "mlt" => Self::Mlt,
95            "png" => Self::Png,
96            "webp" => Self::Webp,
97            "avif" => Self::Avif,
98            _ => None?,
99        })
100    }
101
102    /// Get the `format` value as it should be stored in the `MBTiles` metadata table
103    #[must_use]
104    pub fn metadata_format_value(self) -> &'static str {
105        match self {
106            Self::Gif => "gif",
107            Self::Jpeg => "jpeg",
108            Self::Json => "json",
109            // QGIS uses `pbf` instead of `mvt` for some reason
110            Self::Mvt => "pbf",
111            Self::Mlt => "mlt",
112            Self::Png => "png",
113            Self::Webp => "webp",
114            Self::Avif => "avif",
115        }
116    }
117
118    #[must_use]
119    pub fn content_type(&self) -> &str {
120        match *self {
121            Self::Gif => "image/gif",
122            Self::Jpeg => "image/jpeg",
123            Self::Json => "application/json",
124            Self::Mvt => "application/x-protobuf",
125            Self::Mlt => "application/vnd.maplibre-vector-tile",
126            Self::Png => "image/png",
127            Self::Webp => "image/webp",
128            Self::Avif => "image/avif",
129        }
130    }
131
132    #[must_use]
133    pub fn is_detectable(self) -> bool {
134        match self {
135            Self::Png
136            | Self::Jpeg
137            | Self::Gif
138            | Self::Webp
139            | Self::Avif
140            | Self::Json
141            | Self::Mlt => true,
142            Self::Mvt => false,
143        }
144    }
145}
146
147impl Display for Format {
148    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
149        f.write_str(match *self {
150            Self::Gif => "gif",
151            Self::Jpeg => "jpeg",
152            Self::Json => "json",
153            Self::Mvt => "mvt",
154            Self::Mlt => "mlt",
155            Self::Png => "png",
156            Self::Webp => "webp",
157            Self::Avif => "avif",
158        })
159    }
160}
161
162#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
163pub enum Encoding {
164    /// Data is not compressed, but it can be
165    Uncompressed = 0b0000_0000,
166    /// Some formats like JPEG and PNG are already compressed
167    Internal = 0b0000_0001,
168    Gzip = 0b0000_0010,
169    Zlib = 0b0000_0100,
170    Brotli = 0b0000_1000,
171    Zstd = 0b0001_0000,
172}
173
174impl Encoding {
175    #[must_use]
176    pub fn parse(value: &str) -> Option<Self> {
177        Some(match value.to_ascii_lowercase().as_str() {
178            "none" => Self::Uncompressed,
179            "gzip" => Self::Gzip,
180            "zlib" => Self::Zlib,
181            "brotli" => Self::Brotli,
182            "zstd" => Self::Zstd,
183            _ => None?,
184        })
185    }
186
187    #[must_use]
188    pub fn content_encoding(&self) -> Option<&str> {
189        match *self {
190            Self::Uncompressed | Self::Internal => None,
191            Self::Gzip => Some("gzip"),
192            Self::Zlib => Some("deflate"),
193            Self::Brotli => Some("br"),
194            Self::Zstd => Some("zstd"),
195        }
196    }
197
198    #[must_use]
199    pub fn is_encoded(self) -> bool {
200        match self {
201            Self::Uncompressed | Self::Internal => false,
202            Self::Gzip | Self::Zlib | Self::Brotli | Self::Zstd => true,
203        }
204    }
205}
206
207#[derive(Clone, Copy, Debug, PartialEq, Eq)]
208pub struct TileInfo {
209    pub format: Format,
210    pub encoding: Encoding,
211}
212
213impl TileInfo {
214    #[must_use]
215    pub fn new(format: Format, encoding: Encoding) -> Self {
216        Self { format, encoding }
217    }
218
219    /// Try to figure out the format and encoding of the raw tile data
220    #[must_use]
221    pub fn detect(value: &[u8]) -> Self {
222        // Try GZIP decompression
223        if value.starts_with(b"\x1f\x8b") {
224            if let Ok(decompressed) = decode_gzip(value) {
225                let inner_format = Self::detect_vectorish_format(&decompressed);
226                return Self::new(inner_format, Encoding::Gzip);
227            }
228            // If decompression fails or format is unknown, assume MVT
229            return Self::new(Format::Mvt, Encoding::Gzip);
230        }
231
232        // Try Zlib decompression
233        if value.starts_with(b"\x78\x9c") {
234            if let Ok(decompressed) = decode_zlib(value) {
235                let inner_format = Self::detect_vectorish_format(&decompressed);
236                return Self::new(inner_format, Encoding::Zlib);
237            }
238            // If decompression fails or format is unknown, assume MVT
239            return Self::new(Format::Mvt, Encoding::Zlib);
240        }
241        if let Some(raster_format) = Self::detect_raster_formats(value) {
242            Self::new(raster_format, Encoding::Internal)
243        } else {
244            let inner_format = Self::detect_vectorish_format(value);
245            Self::new(inner_format, Encoding::Uncompressed)
246        }
247    }
248
249    /// Fast-path detection without decompression
250    #[must_use]
251    fn detect_raster_formats(value: &[u8]) -> Option<Format> {
252        match value {
253            v if v.starts_with(b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A") => Some(Format::Png),
254            v if v.starts_with(b"\x47\x49\x46\x38\x39\x61") => Some(Format::Gif),
255            v if v.starts_with(b"\xFF\xD8\xFF") => Some(Format::Jpeg),
256            v if v.starts_with(b"RIFF") && v.len() > 8 && v[8..].starts_with(b"WEBP") => {
257                Some(Format::Webp)
258            }
259            _ => None,
260        }
261    }
262
263    /// Detect the format of vector (or json) data after decompression
264    #[must_use]
265    fn detect_vectorish_format(value: &[u8]) -> Format {
266        match value {
267            v if decode_7bit_length_and_tag(v, &[0x1]).is_ok() => Format::Mlt,
268            v if is_valid_json(v) => Format::Json,
269            // If we can't detect the format, we assume MVT.
270            // Reasoning:
271            //- it's the most common format and
272            //- we don't have a detector for it
273            _ => Format::Mvt,
274        }
275    }
276
277    #[must_use]
278    pub fn encoding(self, encoding: Encoding) -> Self {
279        Self { encoding, ..self }
280    }
281}
282
283impl From<Format> for TileInfo {
284    fn from(format: Format) -> Self {
285        Self::new(
286            format,
287            match format {
288                Format::Mlt
289                | Format::Png
290                | Format::Jpeg
291                | Format::Webp
292                | Format::Gif
293                | Format::Avif => Encoding::Internal,
294                Format::Mvt | Format::Json => Encoding::Uncompressed,
295            },
296        )
297    }
298}
299
300impl Display for TileInfo {
301    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
302        write!(f, "{}", self.format.content_type())?;
303        if let Some(encoding) = self.encoding.content_encoding() {
304            write!(f, "; encoding={encoding}")?;
305        } else if self.encoding != Encoding::Uncompressed {
306            f.write_str("; uncompressed")?;
307        }
308        Ok(())
309    }
310}
311
312#[derive(thiserror::Error, Debug, PartialEq, Eq)]
313enum SevenBitDecodingError {
314    /// Expected a tag, but got nothing
315    #[error("Expected a tag, but got nothing")]
316    TruncatedTag,
317    /// The size of the tile is too large to be decoded
318    #[error("The size of the tile is too large to be decoded")]
319    SizeOverflow,
320    /// The size of the tile is lower than the number of bytes for the size and tag
321    #[error("The size of the tile is lower than the number of bytes for the size and tag")]
322    SizeUnderflow,
323    /// Expected a size, but got nothing
324    #[error("Expected a size, but got nothing")]
325    TruncatedSize,
326    /// Expected data according to the size, but got nothing
327    #[error("Expected {0} bytes of data in layer according to the size, but got only {1}")]
328    TruncatedData(u64, u64),
329    /// Got unexpected tag
330    #[error("Got tag {0} instead of the expected")]
331    UnexpectedTag(u8),
332}
333
334/// Tries to validate that the tile consists of a valid concatination of (`size_7_bit`, `one_of_expected_version`, `data`)
335fn decode_7bit_length_and_tag(tile: &[u8], versions: &[u8]) -> Result<(), SevenBitDecodingError> {
336    if tile.is_empty() {
337        return Err(SevenBitDecodingError::TruncatedSize);
338    }
339    let mut tile_iter = tile.iter().peekable();
340    while tile_iter.peek().is_some() {
341        // need to parse size
342        let mut size = 0_u64;
343        let mut header_bit_count = 0_u64;
344        loop {
345            header_bit_count += 1;
346            let Some(b) = tile_iter.next() else {
347                return Err(SevenBitDecodingError::TruncatedSize);
348            };
349            if header_bit_count * 7 + 8 > 64 {
350                return Err(SevenBitDecodingError::SizeOverflow);
351            }
352            // decode size
353            size <<= 7;
354            let seven_bit_mask = !0x80;
355            size |= u64::from(*b & seven_bit_mask);
356            // 0 => no further size
357            if b & 0x80 == 0 {
358                // need to check tag
359                header_bit_count += 1;
360                let Some(tag) = tile_iter.next() else {
361                    return Err(SevenBitDecodingError::TruncatedTag);
362                };
363                if !versions.contains(tag) {
364                    return Err(SevenBitDecodingError::UnexpectedTag(*tag));
365                }
366                // need to check data-length
367                let payload_len = size
368                    .checked_sub(header_bit_count)
369                    .ok_or(SevenBitDecodingError::SizeUnderflow)?;
370                for i in 0..payload_len {
371                    if tile_iter.next().is_none() {
372                        return Err(SevenBitDecodingError::TruncatedData(payload_len, i));
373                    }
374                }
375                break;
376            }
377        }
378    }
379    Ok(())
380}
381
382/// Detects if the given tile is a valid JSON tile.
383///
384/// The check for a dictionary is used to speed up the validation process.
385fn is_valid_json(tile: &[u8]) -> bool {
386    tile.starts_with(b"{")
387        && tile.ends_with(b"}")
388        && serde_json::from_slice::<serde::de::IgnoredAny>(tile).is_ok()
389}
390
391/// Convert longitude and latitude to a tile (x,y) coordinates for a given zoom
392#[must_use]
393#[expect(clippy::cast_possible_truncation)]
394#[expect(clippy::cast_sign_loss)]
395pub fn tile_index(lng: f64, lat: f64, zoom: u8) -> (u32, u32) {
396    let tile_size = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
397    let (x, y) = wgs84_to_webmercator(lng, lat);
398    let col = (((x - (EARTH_CIRCUMFERENCE * -0.5)).abs() / tile_size) as u32).min((1 << zoom) - 1);
399    let row = ((((EARTH_CIRCUMFERENCE * 0.5) - y).abs() / tile_size) as u32).min((1 << zoom) - 1);
400    (col, row)
401}
402
403/// Convert min/max XYZ tile coordinates to a bounding box values.
404///
405/// The result is `[min_lng, min_lat, max_lng, max_lat]`
406///
407/// # Panics
408/// Panics if `zoom` is greater than [`MAX_ZOOM`].
409#[must_use]
410pub fn xyz_to_bbox(zoom: u8, min_x: u32, min_y: u32, max_x: u32, max_y: u32) -> [f64; 4] {
411    assert!(zoom <= MAX_ZOOM, "zoom {zoom} must be <= {MAX_ZOOM}");
412
413    let tile_length = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
414
415    let left_down_bbox = tile_bbox(min_x, max_y, tile_length);
416    let right_top_bbox = tile_bbox(max_x, min_y, tile_length);
417
418    let (min_lng, min_lat) = webmercator_to_wgs84(left_down_bbox[0], left_down_bbox[1]);
419    let (max_lng, max_lat) = webmercator_to_wgs84(right_top_bbox[2], right_top_bbox[3]);
420    [min_lng, min_lat, max_lng, max_lat]
421}
422
423#[expect(clippy::cast_lossless)]
424fn tile_bbox(x: u32, y: u32, tile_length: f64) -> [f64; 4] {
425    let min_x = EARTH_CIRCUMFERENCE * -0.5 + x as f64 * tile_length;
426    let max_y = EARTH_CIRCUMFERENCE * 0.5 - y as f64 * tile_length;
427
428    [min_x, max_y - tile_length, min_x + tile_length, max_y]
429}
430
431/// Convert bounding box to a tile box `(min_x, min_y, max_x, max_y)` for a given zoom
432#[must_use]
433pub fn bbox_to_xyz(left: f64, bottom: f64, right: f64, top: f64, zoom: u8) -> (u32, u32, u32, u32) {
434    let (min_col, min_row) = tile_index(left, top, zoom);
435    let (max_col, max_row) = tile_index(right, bottom, zoom);
436    (min_col, min_row, max_col, max_row)
437}
438
439/// Compute precision of a zoom level, i.e. how many decimal digits of the longitude and latitude are relevant
440#[must_use]
441#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
442pub fn get_zoom_precision(zoom: u8) -> usize {
443    assert!(zoom <= MAX_ZOOM, "zoom {zoom} must be <= {MAX_ZOOM}");
444    let lng_delta = webmercator_to_wgs84(EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom), 0.0).0;
445    let log = lng_delta.log10() - 0.5;
446    if log > 0.0 { 0 } else { -log.ceil() as usize }
447}
448
449/// transform [`WebMercator`](https://epsg.io/3857) to [WGS84](https://epsg.io/4326)
450// from https://github.com/Esri/arcgis-osm-editor/blob/e4b9905c264aa22f8eeb657efd52b12cdebea69a/src/OSMWeb10_1/Utils/WebMercator.cs
451#[must_use]
452pub fn webmercator_to_wgs84(x: f64, y: f64) -> (f64, f64) {
453    let lng = (x / EARTH_RADIUS).to_degrees();
454    let lat = f64::atan(f64::sinh(y / EARTH_RADIUS)).to_degrees();
455    (lng, lat)
456}
457
458/// transform [WGS84](https://epsg.io/4326) to [`WebMercator`](https://epsg.io/3857)
459// from https://github.com/Esri/arcgis-osm-editor/blob/e4b9905c264aa22f8eeb657efd52b12cdebea69a/src/OSMWeb10_1/Utils/WebMercator.cs
460#[must_use]
461pub fn wgs84_to_webmercator(lon: f64, lat: f64) -> (f64, f64) {
462    let x = lon * PI / 180.0 * EARTH_RADIUS;
463
464    let y_sin = lat.to_radians().sin();
465    let y = EARTH_RADIUS / 2.0 * ((1.0 + y_sin) / (1.0 - y_sin)).ln();
466
467    (x, y)
468}
469
470#[cfg(test)]
471mod tests {
472    use approx::assert_relative_eq;
473    use rstest::rstest;
474
475    use super::*;
476
477    #[rstest]
478    #[case::png(
479        include_bytes!("../fixtures/world.png"),
480        TileInfo::new(Format::Png, Encoding::Internal)
481    )]
482    #[case::jpg(
483        include_bytes!("../fixtures/world.jpg"),
484        TileInfo::new(Format::Jpeg, Encoding::Internal)
485    )]
486    #[case::webp(
487        include_bytes!("../fixtures/dc.webp"),
488        TileInfo::new(Format::Webp, Encoding::Internal)
489    )]
490    #[case::json(
491        br#"{"foo":"bar"}"#,
492        TileInfo::new(Format::Json, Encoding::Uncompressed)
493    )]
494    // we have no way of knowing what is an MVT -> we just say it is out of the
495    // fact that it is not something else
496    #[case::invalid_webp_header(b"RIFF", TileInfo::new(Format::Mvt, Encoding::Uncompressed))]
497    fn test_data_format_detect(#[case] data: &[u8], #[case] expected: TileInfo) {
498        assert_eq!(TileInfo::detect(data), expected);
499    }
500
501    /// Test detection of compressed content (JSON, MLT, MVT)
502    #[test]
503    fn test_compressed_json_gzip() {
504        let json_data = br#"{"type":"FeatureCollection","features":[]}"#;
505        let compressed = encode_gzip(json_data).unwrap();
506        let result = TileInfo::detect(&compressed);
507        assert_eq!(result, TileInfo::new(Format::Json, Encoding::Gzip));
508    }
509
510    #[test]
511    fn test_compressed_json_zlib() {
512        use std::io::Write;
513
514        use flate2::write::ZlibEncoder;
515
516        let json_data = br#"{"type":"FeatureCollection","features":[]}"#;
517        let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
518        encoder.write_all(json_data).unwrap();
519        let compressed = encoder.finish().unwrap();
520
521        let result = TileInfo::detect(&compressed);
522        assert_eq!(result, TileInfo::new(Format::Json, Encoding::Zlib));
523    }
524
525    #[test]
526    fn test_compressed_mlt_gzip() {
527        // MLT tile: length=2 (0x02), version=1 (0x01)
528        let mlt_data = &[0x02, 0x01];
529        let compressed = encode_gzip(mlt_data).unwrap();
530        let result = TileInfo::detect(&compressed);
531        assert_eq!(result, TileInfo::new(Format::Mlt, Encoding::Gzip));
532    }
533
534    #[test]
535    fn test_compressed_mlt_zlib() {
536        use std::io::Write;
537
538        use flate2::write::ZlibEncoder;
539
540        // MLT tile: length=5 (0x05), version=1 (0x01), plus some data
541        let mlt_data = &[0x05, 0x01, 0xaa, 0xbb, 0xcc];
542        let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
543        encoder.write_all(mlt_data).unwrap();
544        let compressed = encoder.finish().unwrap();
545
546        let result = TileInfo::detect(&compressed);
547        assert_eq!(result, TileInfo::new(Format::Mlt, Encoding::Zlib));
548    }
549
550    #[test]
551    fn test_compressed_mvt_gzip_fallback() {
552        // Random data that doesn't match any known format => should be detected as MVT
553        let random_data = &[0x1a, 0x2b, 0x3c, 0x4d];
554        let compressed = encode_gzip(random_data).unwrap();
555        let result = TileInfo::detect(&compressed);
556        assert_eq!(result, TileInfo::new(Format::Mvt, Encoding::Gzip));
557    }
558
559    #[test]
560    fn test_compressed_mvt_zlib_fallback() {
561        use std::io::Write;
562
563        use flate2::write::ZlibEncoder;
564
565        // Random data that doesn't match any known format => should be detected as MVT
566        let random_data = &[0xaa, 0xbb, 0xcc, 0xdd];
567        let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
568        encoder.write_all(random_data).unwrap();
569        let compressed = encoder.finish().unwrap();
570
571        let result = TileInfo::detect(&compressed);
572        assert_eq!(result, TileInfo::new(Format::Mvt, Encoding::Zlib));
573    }
574
575    #[test]
576    fn test_invalid_json_in_gzip() {
577        // Data that looks like JSON but isn't valid => should fall back to MVT
578        let invalid_json = b"{this is not valid json}";
579        let compressed = encode_gzip(invalid_json).unwrap();
580        let result = TileInfo::detect(&compressed);
581        assert_eq!(result, TileInfo::new(Format::Mvt, Encoding::Gzip));
582    }
583
584    #[rstest]
585    #[case::minimal_tile(&[0x02, 0x01], Ok(()))]
586    #[case::one_byte_length(&[0x03, 0x01, 0xaa], Ok(()))]
587    #[case::two_byte_length(&[0x80, 0x04, 0x01, 0xaa], Ok(()))]
588    #[case::multi_byte_length(&[0x80, 0x80, 0x05, 0x01, 0xdd], Ok(()))]
589    #[case::wrong_version(&[0x03, 0x02, 0xaa], Err(SevenBitDecodingError::UnexpectedTag(0x02)))]
590    #[case::empty_input(&[], Err(SevenBitDecodingError::TruncatedSize))]
591    #[case::size_overflow(&[0xFF; 64], Err(SevenBitDecodingError::SizeOverflow))]
592    #[case::size_underflow(&[0x00, 0x01], Err(SevenBitDecodingError::SizeUnderflow))]
593    #[case::unterminated_length(&[0x80], Err(SevenBitDecodingError::TruncatedSize))]
594    #[case::missing_version_byte(&[0x05], Err(SevenBitDecodingError::TruncatedTag))]
595    #[case::wrong_length(&[0x03, 0x01], Err(SevenBitDecodingError::TruncatedData(1, 0)))]
596    fn test_decode_7bit_length_and_tag(
597        #[case] tile: &[u8],
598        #[case] expected: Result<(), SevenBitDecodingError>,
599    ) {
600        let allowed_versions = &[0x01_u8];
601        let decoded = decode_7bit_length_and_tag(tile, allowed_versions);
602        assert_eq!(decoded, expected, "can decode one layer correctly");
603
604        if tile.is_empty() {
605            return;
606        }
607        let mut tile_with_two_layers = vec![0x02, 0x01];
608        tile_with_two_layers.extend_from_slice(tile);
609        let decoded = decode_7bit_length_and_tag(&tile_with_two_layers, allowed_versions);
610        assert_eq!(decoded, expected, "can decode two layers correctly");
611    }
612
613    #[rstest]
614    #[case(-180.0, 85.0511, 0, (0,0))]
615    #[case(-180.0, 85.0511, 1, (0,0))]
616    #[case(-180.0, 85.0511, 2, (0,0))]
617    #[case(0.0, 0.0, 0, (0,0))]
618    #[case(0.0, 0.0, 1, (1,1))]
619    #[case(0.0, 0.0, 2, (2,2))]
620    #[case(0.0, 1.0, 0, (0,0))]
621    #[case(0.0, 1.0, 1, (1,0))]
622    #[case(0.0, 1.0, 2, (2,1))]
623    fn test_tile_colrow(
624        #[case] lng: f64,
625        #[case] lat: f64,
626        #[case] zoom: u8,
627        #[case] expected: (u32, u32),
628    ) {
629        assert_eq!(
630            expected,
631            tile_index(lng, lat, zoom),
632            "{lng},{lat}@z{zoom} should be {expected:?}"
633        );
634    }
635
636    #[rstest]
637    // you could easily get test cases from maptiler: https://www.maptiler.com/google-maps-coordinates-tile-bounds-projection/#4/-118.82/71.02
638    #[case(0, 0, 0, 0, 0, [-180.0,-85.051_128_779_806_6,180.0,85.051_128_779_806_6])]
639    #[case(1, 0, 0, 0, 0, [-180.0,0.0,0.0,85.051_128_779_806_6])]
640    #[case(5, 1, 1, 2, 2, [-168.75,81.093_213_852_608_37,-146.25,83.979_259_498_862_05])]
641    #[case(5, 1, 3, 2, 5, [-168.75,74.019_543_311_502_26,-146.25,81.093_213_852_608_37])]
642    fn test_xyz_to_bbox(
643        #[case] zoom: u8,
644        #[case] min_x: u32,
645        #[case] min_y: u32,
646        #[case] max_x: u32,
647        #[case] max_y: u32,
648        #[case] expected: [f64; 4],
649    ) {
650        let bbox = xyz_to_bbox(zoom, min_x, min_y, max_x, max_y);
651        assert_relative_eq!(bbox[0], expected[0], epsilon = f64::EPSILON * 2.0);
652        assert_relative_eq!(bbox[1], expected[1], epsilon = f64::EPSILON * 2.0);
653        assert_relative_eq!(bbox[2], expected[2], epsilon = f64::EPSILON * 2.0);
654        assert_relative_eq!(bbox[3], expected[3], epsilon = f64::EPSILON * 2.0);
655    }
656
657    #[rstest]
658    #[case(0, (0, 0, 0, 0))]
659    #[case(1, (0, 1, 0, 1))]
660    #[case(2, (0, 3, 0, 3))]
661    #[case(3, (0, 7, 0, 7))]
662    #[case(4, (0, 14, 1, 15))]
663    #[case(5, (0, 29, 2, 31))]
664    #[case(6, (0, 58, 5, 63))]
665    #[case(7, (0, 116, 11, 126))]
666    #[case(8, (0, 233, 23, 253))]
667    #[case(9, (0, 466, 47, 507))]
668    #[case(10, (1, 933, 94, 1_014))]
669    #[case(11, (3, 1_866, 188, 2_029))]
670    #[case(12, (6, 3_732, 377, 4_059))]
671    #[case(13, (12, 7_465, 755, 8_119))]
672    #[case(14, (25, 14_931, 1_510, 16_239))]
673    #[case(15, (51, 29_863, 3_020, 32_479))]
674    #[case(16, (102, 59_727, 6_041, 64_958))]
675    #[case(17, (204, 119_455, 12_083, 129_917))]
676    #[case(18, (409, 238_911, 24_166, 259_834))]
677    #[case(19, (819, 477_823, 48_332, 519_669))]
678    #[case(20, (1_638, 955_647, 96_665, 1_039_339))]
679    #[case(21, (3_276, 1_911_295, 193_331, 2_078_678))]
680    #[case(22, (6_553, 3_822_590, 386_662, 4_157_356))]
681    #[case(23, (13_107, 7_645_181, 773_324, 8_314_713))]
682    #[case(24, (26_214, 15_290_363, 1_546_649, 16_629_427))]
683    #[case(25, (52_428, 30_580_726, 3_093_299, 33_258_855))]
684    #[case(26, (104_857, 61_161_453, 6_186_598, 66_517_711))]
685    #[case(27, (209_715, 122_322_907, 12_373_196, 133_035_423))]
686    #[case(28, (419_430, 244_645_814, 24_746_393, 266_070_846))]
687    #[case(29, (838_860, 489_291_628, 49_492_787, 532_141_692))]
688    #[case(30, (1_677_721, 978_583_256, 98_985_574, 1_064_283_385))]
689    fn test_box_to_xyz(#[case] zoom: u8, #[case] expected_xyz: (u32, u32, u32, u32)) {
690        let actual_xyz = bbox_to_xyz(
691            -179.437_499_999_999_55,
692            -84.769_878_779_806_56,
693            -146.812_499_999_999_6,
694            -81.374_463_852_608_33,
695            zoom,
696        );
697        assert_eq!(
698            actual_xyz, expected_xyz,
699            "zoom {zoom} does not have te right xyz"
700        );
701    }
702
703    #[rstest]
704    // test data via https://epsg.io/transform#s_srs=4326&t_srs=3857
705    #[case((0.0,0.0), (0.0,0.0))]
706    #[case((30.0,0.0), (3_339_584.723_798_207,0.0))]
707    #[case((-30.0,0.0), (-3_339_584.723_798_207,0.0))]
708    #[case((0.0,30.0), (0.0,3_503_549.843_504_375_3))]
709    #[case((0.0,-30.0), (0.0,-3_503_549.843_504_375_3))]
710    #[case((38.897_957,-77.036_560), (4_330_100.766_138_651, -13_872_207.775_755_845))] // white house
711    #[case((-180.0,-85.0), (-20_037_508.342_789_244, -19_971_868.880_408_566))]
712    #[case((180.0,85.0), (20_037_508.342_789_244, 19_971_868.880_408_566))]
713    #[case((0.026_949_458_523_585_632,0.080_848_348_740_973_67), (3000.0, 9000.0))]
714    fn test_coordinate_syste_conversion(
715        #[case] wgs84: (f64, f64),
716        #[case] webmercator: (f64, f64),
717    ) {
718        // epsg produces the expected values with f32 precision, grrr..
719        let epsilon = f64::from(f32::EPSILON);
720
721        let actual_wgs84 = webmercator_to_wgs84(webmercator.0, webmercator.1);
722        assert_relative_eq!(actual_wgs84.0, wgs84.0, epsilon = epsilon);
723        assert_relative_eq!(actual_wgs84.1, wgs84.1, epsilon = epsilon);
724
725        let actual_webmercator = wgs84_to_webmercator(wgs84.0, wgs84.1);
726        assert_relative_eq!(actual_webmercator.0, webmercator.0, epsilon = epsilon);
727        assert_relative_eq!(actual_webmercator.1, webmercator.1, epsilon = epsilon);
728    }
729
730    #[rstest]
731    #[case(0..11, 0)]
732    #[case(11..14, 1)]
733    #[case(14..17, 2)]
734    #[case(17..21, 3)]
735    #[case(21..24, 4)]
736    #[case(24..27, 5)]
737    #[case(27..30, 6)]
738    fn test_get_zoom_precision(
739        #[case] zoom: std::ops::Range<u8>,
740        #[case] expected_precision: usize,
741    ) {
742        for z in zoom {
743            let actual_precision = get_zoom_precision(z);
744            assert_eq!(
745                actual_precision, expected_precision,
746                "Zoom level {z} should have precision {expected_precision}, but was {actual_precision}"
747            );
748        }
749    }
750
751    #[test]
752    fn test_tile_coord_zoom_range() {
753        for z in 0..=MAX_ZOOM {
754            assert!(TileCoord::is_possible_on_zoom_level(z, 0, 0));
755            assert_eq!(
756                TileCoord::new_checked(z, 0, 0),
757                Some(TileCoord { z, x: 0, y: 0 })
758            );
759        }
760        assert!(!TileCoord::is_possible_on_zoom_level(MAX_ZOOM + 1, 0, 0));
761        assert_eq!(TileCoord::new_checked(MAX_ZOOM + 1, 0, 0), None);
762    }
763
764    #[test]
765    fn test_tile_coord_new_checked_xy_for_zoom() {
766        assert!(TileCoord::is_possible_on_zoom_level(5, 0, 0));
767        assert_eq!(
768            TileCoord::new_checked(5, 0, 0),
769            Some(TileCoord { z: 5, x: 0, y: 0 })
770        );
771        assert!(TileCoord::is_possible_on_zoom_level(5, 31, 31));
772        assert_eq!(
773            TileCoord::new_checked(5, 31, 31),
774            Some(TileCoord { z: 5, x: 31, y: 31 })
775        );
776        assert!(!TileCoord::is_possible_on_zoom_level(5, 31, 32));
777        assert_eq!(TileCoord::new_checked(5, 31, 32), None);
778        assert!(!TileCoord::is_possible_on_zoom_level(5, 32, 31));
779        assert_eq!(TileCoord::new_checked(5, 32, 31), None);
780    }
781
782    #[test]
783    /// Any (u8, u32, u32) values can be put inside [`TileCoord`], of course, but some
784    /// functions may panic at runtime (e.g. [`mbtiles::invert_y_value`]) if they are impossible,
785    /// so let's not do that.
786    fn test_tile_coord_new_unchecked() {
787        assert_eq!(
788            TileCoord::new_unchecked(u8::MAX, u32::MAX, u32::MAX),
789            TileCoord {
790                z: u8::MAX,
791                x: u32::MAX,
792                y: u32::MAX
793            }
794        );
795    }
796
797    #[test]
798    fn xyz_format() {
799        let xyz = TileCoord { z: 1, x: 2, y: 3 };
800        assert_eq!(format!("{xyz}"), "1,2,3");
801        assert_eq!(format!("{xyz:#}"), "1/2/3");
802    }
803}