1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4use std::f64::consts::PI;
8use std::fmt::{Display, Formatter};
9
10pub const EARTH_CIRCUMFERENCE: f64 = 40_075_016.685_578_5;
12pub const EARTH_CIRCUMFERENCE_DEGREES: u32 = 360;
14
15pub const EARTH_RADIUS: f64 = EARTH_CIRCUMFERENCE / 2.0 / PI;
17
18pub const MAX_ZOOM: u8 = 30;
19
20mod decoders;
21pub use decoders::*;
22mod rectangle;
23pub use rectangle::{TileRect, append_rect};
24
25#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
26pub struct TileCoord {
27 pub z: u8,
28 pub x: u32,
29 pub y: u32,
30}
31
32pub type TileData = Vec<u8>;
33pub type Tile = (TileCoord, Option<TileData>);
34
35impl Display for TileCoord {
36 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
37 if f.alternate() {
38 write!(f, "{}/{}/{}", self.z, self.x, self.y)
39 } else {
40 write!(f, "{},{},{}", self.z, self.x, self.y)
41 }
42 }
43}
44
45impl TileCoord {
46 #[must_use]
51 pub fn new_checked(z: u8, x: u32, y: u32) -> Option<Self> {
52 Self::is_possible_on_zoom_level(z, x, y).then_some(Self { z, x, y })
53 }
54
55 #[must_use]
59 pub fn new_unchecked(z: u8, x: u32, y: u32) -> Self {
60 Self { z, x, y }
61 }
62
63 #[must_use]
65 pub fn is_possible_on_zoom_level(z: u8, x: u32, y: u32) -> bool {
66 if z > MAX_ZOOM {
67 return false;
68 }
69
70 let side_len = 1_u32 << z;
71 x < side_len && y < side_len
72 }
73}
74
75#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
76pub enum Format {
77 Gif,
78 Jpeg,
79 Json,
80 Mvt,
81 Mlt,
82 Png,
83 Webp,
84 Avif,
85}
86
87impl Format {
88 pub const IMAGE_FORMATS: &[Self] = &[Self::Gif, Self::Jpeg, Self::Png, Self::Webp, Self::Avif];
90
91 #[must_use]
92 pub fn parse(value: &str) -> Option<Self> {
93 Some(match value.to_ascii_lowercase().as_str() {
94 "gif" => Self::Gif,
95 "jpg" | "jpeg" => Self::Jpeg,
96 "json" => Self::Json,
97 "pbf" | "mvt" => Self::Mvt,
98 "mlt" => Self::Mlt,
99 "png" => Self::Png,
100 "webp" => Self::Webp,
101 "avif" => Self::Avif,
102 _ => None?,
103 })
104 }
105
106 #[must_use]
108 pub fn metadata_format_value(self) -> &'static str {
109 match self {
110 Self::Gif => "gif",
111 Self::Jpeg => "jpeg",
112 Self::Json => "json",
113 Self::Mvt => "pbf",
115 Self::Mlt => "mlt",
116 Self::Png => "png",
117 Self::Webp => "webp",
118 Self::Avif => "avif",
119 }
120 }
121
122 #[must_use]
123 pub fn content_type(&self) -> &str {
124 match *self {
125 Self::Gif => "image/gif",
126 Self::Jpeg => "image/jpeg",
127 Self::Json => "application/json",
128 Self::Mvt => "application/x-protobuf",
129 Self::Mlt => "application/vnd.maplibre-tile",
130 Self::Png => "image/png",
131 Self::Webp => "image/webp",
132 Self::Avif => "image/avif",
133 }
134 }
135
136 #[must_use]
138 pub fn from_content_type(supertype: &str, subtype: &str) -> Option<Self> {
139 Some(match (supertype, subtype) {
140 ("image", "gif") => Self::Gif,
141 ("image", "jpeg" | "jpg") => Self::Jpeg,
142 ("application", "json") => Self::Json,
143 ("application", "x-protobuf" | "vnd.mapbox-vector-tile") => Self::Mvt,
144 ("application", "vnd.maplibre-vector-tile" | "vnd.maplibre-tile") => Self::Mlt,
145 ("image", "png") => Self::Png,
146 ("image", "webp") => Self::Webp,
147 ("image", "avif") => Self::Avif,
148 _ => None?,
149 })
150 }
151
152 #[must_use]
153 pub fn is_detectable(self) -> bool {
154 match self {
155 Self::Png
156 | Self::Jpeg
157 | Self::Gif
158 | Self::Webp
159 | Self::Avif
160 | Self::Json
161 | Self::Mlt => true,
162 Self::Mvt => false,
163 }
164 }
165}
166
167impl Display for Format {
168 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
169 f.write_str(match *self {
170 Self::Gif => "gif",
171 Self::Jpeg => "jpeg",
172 Self::Json => "json",
173 Self::Mvt => "mvt",
174 Self::Mlt => "mlt",
175 Self::Png => "png",
176 Self::Webp => "webp",
177 Self::Avif => "avif",
178 })
179 }
180}
181
182#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
183pub enum Encoding {
184 Uncompressed = 0b0000_0000,
186 Internal = 0b0000_0001,
188 Gzip = 0b0000_0010,
189 Zlib = 0b0000_0100,
190 Brotli = 0b0000_1000,
191 Zstd = 0b0001_0000,
192}
193
194impl Encoding {
195 #[must_use]
197 pub fn parse(value: &str) -> Option<Self> {
198 Some(match value.to_ascii_lowercase().as_str() {
199 "none" | "identity" => Self::Uncompressed,
200 "gzip" => Self::Gzip,
201 "deflate" | "zlib" => Self::Zlib,
202 "br" | "brotli" => Self::Brotli,
203 "zstd" => Self::Zstd,
204 _ => None?,
205 })
206 }
207
208 #[must_use]
211 pub fn compression(self) -> Option<&'static str> {
212 match self {
213 Self::Uncompressed | Self::Internal => None,
214 Self::Gzip => Some("gzip"),
215 Self::Zlib => Some("deflate"),
216 Self::Brotli => Some("br"),
217 Self::Zstd => Some("zstd"),
218 }
219 }
220
221 #[must_use]
222 pub fn is_encoded(self) -> bool {
223 match self {
224 Self::Uncompressed | Self::Internal => false,
225 Self::Gzip | Self::Zlib | Self::Brotli | Self::Zstd => true,
226 }
227 }
228}
229
230#[derive(Clone, Copy, Debug, PartialEq, Eq)]
231pub struct TileInfo {
232 pub format: Format,
233 pub encoding: Encoding,
234}
235
236impl TileInfo {
237 #[must_use]
238 pub fn new(format: Format, encoding: Encoding) -> Self {
239 Self { format, encoding }
240 }
241
242 #[must_use]
244 pub fn detect(value: &[u8]) -> Self {
245 if value.starts_with(b"\x1f\x8b") {
247 if let Ok(decompressed) = decode_gzip(value) {
248 let inner_format = Self::detect_vectorish_format(&decompressed);
249 return Self::new(inner_format, Encoding::Gzip);
250 }
251 return Self::new(Format::Mvt, Encoding::Gzip);
253 }
254
255 if value.starts_with(b"\x78\x9c") {
257 if let Ok(decompressed) = decode_zlib(value) {
258 let inner_format = Self::detect_vectorish_format(&decompressed);
259 return Self::new(inner_format, Encoding::Zlib);
260 }
261 return Self::new(Format::Mvt, Encoding::Zlib);
263 }
264 if let Some(raster_format) = Self::detect_raster_formats(value) {
265 Self::new(raster_format, Encoding::Internal)
266 } else {
267 Self::detect_vectorish_format(value).into()
268 }
269 }
270
271 #[must_use]
273 fn detect_raster_formats(value: &[u8]) -> Option<Format> {
274 match value {
275 v if v.starts_with(b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A") => Some(Format::Png),
276 v if v.starts_with(b"\x47\x49\x46\x38\x39\x61") => Some(Format::Gif),
277 v if v.starts_with(b"\xFF\xD8\xFF") => Some(Format::Jpeg),
278 v if v.starts_with(b"RIFF") && v.len() > 8 && v[8..].starts_with(b"WEBP") => {
279 Some(Format::Webp)
280 }
281 _ => None,
282 }
283 }
284
285 #[must_use]
287 fn detect_vectorish_format(value: &[u8]) -> Format {
288 match value {
289 v if decode_7bit_length_and_tag(v, &[0x1]).is_ok() => Format::Mlt,
290 v if is_valid_json(v) => Format::Json,
291 _ => Format::Mvt,
296 }
297 }
298
299 #[must_use]
300 pub fn encoding(self, encoding: Encoding) -> Self {
301 Self { encoding, ..self }
302 }
303}
304
305impl From<Format> for TileInfo {
306 fn from(format: Format) -> Self {
307 Self::new(
308 format,
309 match format {
310 Format::Mlt
311 | Format::Png
312 | Format::Jpeg
313 | Format::Webp
314 | Format::Gif
315 | Format::Avif => Encoding::Internal,
316 Format::Mvt | Format::Json => Encoding::Uncompressed,
317 },
318 )
319 }
320}
321
322impl Display for TileInfo {
323 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
324 write!(f, "{}", self.format.content_type())?;
325 if let Some(encoding) = self.encoding.compression() {
326 write!(f, "; encoding={encoding}")?;
327 } else if self.encoding != Encoding::Uncompressed {
328 f.write_str("; uncompressed")?;
329 }
330 Ok(())
331 }
332}
333
334#[derive(thiserror::Error, Debug, PartialEq, Eq)]
335enum SevenBitDecodingError {
336 #[error("Expected a tag, but got nothing")]
338 TruncatedTag,
339 #[error("The size of the tile is too large to be decoded")]
341 SizeOverflow,
342 #[error("The size of the tile is lower than the number of bytes for the size and tag")]
344 SizeUnderflow,
345 #[error("Expected a size, but got nothing")]
347 TruncatedSize,
348 #[error(
350 "Expected {expected} bytes of data in layer according to the size, but got only {actual}"
351 )]
352 TruncatedData { expected: u64, actual: u64 },
353 #[error("Got tag {0} instead of the expected")]
355 UnexpectedTag(u8),
356}
357
358fn decode_7bit_length_and_tag(tile: &[u8], versions: &[u8]) -> Result<(), SevenBitDecodingError> {
360 if tile.is_empty() {
361 return Err(SevenBitDecodingError::TruncatedSize);
362 }
363 let mut tile_iter = tile.iter().peekable();
364 while tile_iter.peek().is_some() {
365 let mut size = 0_u64;
367 let mut header_bit_count = 0_u64;
368 loop {
369 header_bit_count += 1;
370 let Some(b) = tile_iter.next() else {
371 return Err(SevenBitDecodingError::TruncatedSize);
372 };
373 if header_bit_count * 7 + 8 > 64 {
374 return Err(SevenBitDecodingError::SizeOverflow);
375 }
376 size <<= 7;
378 let seven_bit_mask = !0x80;
379 size |= u64::from(*b & seven_bit_mask);
380 if b & 0x80 == 0 {
382 header_bit_count += 1;
384 let Some(tag) = tile_iter.next() else {
385 return Err(SevenBitDecodingError::TruncatedTag);
386 };
387 if !versions.contains(tag) {
388 return Err(SevenBitDecodingError::UnexpectedTag(*tag));
389 }
390 let payload_len = size
392 .checked_sub(header_bit_count)
393 .ok_or(SevenBitDecodingError::SizeUnderflow)?;
394 for i in 0..payload_len {
395 if tile_iter.next().is_none() {
396 return Err(SevenBitDecodingError::TruncatedData {
397 expected: payload_len,
398 actual: i,
399 });
400 }
401 }
402 break;
403 }
404 }
405 }
406 Ok(())
407}
408
409fn is_valid_json(tile: &[u8]) -> bool {
413 tile.starts_with(b"{")
414 && tile.ends_with(b"}")
415 && serde_json::from_slice::<serde::de::IgnoredAny>(tile).is_ok()
416}
417
418#[must_use]
420#[expect(clippy::cast_possible_truncation)]
421#[expect(clippy::cast_sign_loss)]
422pub fn tile_index(lng: f64, lat: f64, zoom: u8) -> (u32, u32) {
423 let tile_size = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
424 let (x, y) = wgs84_to_webmercator(lng, lat);
425 let col = (((x - (EARTH_CIRCUMFERENCE * -0.5)).abs() / tile_size) as u32).min((1 << zoom) - 1);
426 let row = ((((EARTH_CIRCUMFERENCE * 0.5) - y).abs() / tile_size) as u32).min((1 << zoom) - 1);
427 (col, row)
428}
429
430#[must_use]
437pub fn xyz_to_bbox(zoom: u8, min_x: u32, min_y: u32, max_x: u32, max_y: u32) -> [f64; 4] {
438 assert!(zoom <= MAX_ZOOM, "zoom {zoom} must be <= {MAX_ZOOM}");
439
440 let tile_length = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
441
442 let left_down_bbox = tile_bbox(min_x, max_y, tile_length);
443 let right_top_bbox = tile_bbox(max_x, min_y, tile_length);
444
445 let (min_lng, min_lat) = webmercator_to_wgs84(left_down_bbox[0], left_down_bbox[1]);
446 let (max_lng, max_lat) = webmercator_to_wgs84(right_top_bbox[2], right_top_bbox[3]);
447 [min_lng, min_lat, max_lng, max_lat]
448}
449
450#[expect(clippy::cast_lossless)]
451#[must_use]
452pub fn tile_bbox(x: u32, y: u32, tile_length: f64) -> [f64; 4] {
453 let min_x = EARTH_CIRCUMFERENCE * -0.5 + x as f64 * tile_length;
454 let max_y = EARTH_CIRCUMFERENCE * 0.5 - y as f64 * tile_length;
455
456 [min_x, max_y - tile_length, min_x + tile_length, max_y]
457}
458
459#[must_use]
461pub fn bbox_to_xyz(left: f64, bottom: f64, right: f64, top: f64, zoom: u8) -> (u32, u32, u32, u32) {
462 let (min_col, min_row) = tile_index(left, top, zoom);
463 let (max_col, max_row) = tile_index(right, bottom, zoom);
464 (min_col, min_row, max_col, max_row)
465}
466
467#[must_use]
469#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
470pub fn get_zoom_precision(zoom: u8) -> usize {
471 assert!(zoom <= MAX_ZOOM, "zoom {zoom} must be <= {MAX_ZOOM}");
472 let lng_delta = webmercator_to_wgs84(EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom), 0.0).0;
473 let log = lng_delta.log10() - 0.5;
474 if log > 0.0 { 0 } else { -log.ceil() as usize }
475}
476
477#[must_use]
480pub fn webmercator_to_wgs84(x: f64, y: f64) -> (f64, f64) {
481 let lng = (x / EARTH_RADIUS).to_degrees();
482 let lat = f64::atan(f64::sinh(y / EARTH_RADIUS)).to_degrees();
483 (lng, lat)
484}
485
486#[must_use]
489pub fn wgs84_to_webmercator(lon: f64, lat: f64) -> (f64, f64) {
490 let x = lon * PI / 180.0 * EARTH_RADIUS;
491
492 let y_sin = lat.to_radians().sin();
493 let y = EARTH_RADIUS / 2.0 * ((1.0 + y_sin) / (1.0 - y_sin)).ln();
494
495 (x, y)
496}
497
498#[cfg(test)]
499mod tests {
500 use approx::assert_relative_eq;
501 use rstest::rstest;
502
503 use super::*;
504
505 #[rstest]
506 #[case::png(
507 include_bytes!("../fixtures/world.png"),
508 TileInfo::new(Format::Png, Encoding::Internal)
509 )]
510 #[case::jpg(
511 include_bytes!("../fixtures/world.jpg"),
512 TileInfo::new(Format::Jpeg, Encoding::Internal)
513 )]
514 #[case::webp(
515 include_bytes!("../fixtures/dc.webp"),
516 TileInfo::new(Format::Webp, Encoding::Internal)
517 )]
518 #[case::json(
519 br#"{"foo":"bar"}"#,
520 TileInfo::new(Format::Json, Encoding::Uncompressed)
521 )]
522 #[case::invalid_webp_header(b"RIFF", TileInfo::new(Format::Mvt, Encoding::Uncompressed))]
525 fn test_data_format_detect(#[case] data: &[u8], #[case] expected: TileInfo) {
526 assert_eq!(TileInfo::detect(data), expected);
527 }
528
529 #[test]
531 fn compressed_json_gzip() {
532 let json_data = br#"{"type":"FeatureCollection","features":[]}"#;
533 let compressed = encode_gzip(json_data).unwrap();
534 let result = TileInfo::detect(&compressed);
535 assert_eq!(result, TileInfo::new(Format::Json, Encoding::Gzip));
536 }
537
538 #[test]
539 fn compressed_json_zlib() {
540 use std::io::Write as _;
541
542 use flate2::write::ZlibEncoder;
543
544 let json_data = br#"{"type":"FeatureCollection","features":[]}"#;
545 let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
546 encoder.write_all(json_data).unwrap();
547 let compressed = encoder.finish().unwrap();
548
549 let result = TileInfo::detect(&compressed);
550 assert_eq!(result, TileInfo::new(Format::Json, Encoding::Zlib));
551 }
552
553 #[test]
554 fn raw_mlt_encoding_internal() {
555 let mlt_data = &[0x02, 0x01];
558 let result = TileInfo::detect(mlt_data);
559 assert_eq!(result, TileInfo::new(Format::Mlt, Encoding::Internal));
560 }
561
562 #[test]
563 fn compressed_mlt_gzip() {
564 let mlt_data = &[0x02, 0x01];
566 let compressed = encode_gzip(mlt_data).unwrap();
567 let result = TileInfo::detect(&compressed);
568 assert_eq!(result, TileInfo::new(Format::Mlt, Encoding::Gzip));
569 }
570
571 #[test]
572 fn compressed_mlt_zlib() {
573 use std::io::Write as _;
574
575 use flate2::write::ZlibEncoder;
576
577 let mlt_data = &[0x05, 0x01, 0xaa, 0xbb, 0xcc];
579 let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
580 encoder.write_all(mlt_data).unwrap();
581 let compressed = encoder.finish().unwrap();
582
583 let result = TileInfo::detect(&compressed);
584 assert_eq!(result, TileInfo::new(Format::Mlt, Encoding::Zlib));
585 }
586
587 #[test]
588 fn compressed_mvt_gzip_fallback() {
589 let random_data = &[0x1a, 0x2b, 0x3c, 0x4d];
591 let compressed = encode_gzip(random_data).unwrap();
592 let result = TileInfo::detect(&compressed);
593 assert_eq!(result, TileInfo::new(Format::Mvt, Encoding::Gzip));
594 }
595
596 #[test]
597 fn compressed_mvt_zlib_fallback() {
598 use std::io::Write as _;
599
600 use flate2::write::ZlibEncoder;
601
602 let random_data = &[0xaa, 0xbb, 0xcc, 0xdd];
604 let mut encoder = ZlibEncoder::new(Vec::new(), flate2::Compression::default());
605 encoder.write_all(random_data).unwrap();
606 let compressed = encoder.finish().unwrap();
607
608 let result = TileInfo::detect(&compressed);
609 assert_eq!(result, TileInfo::new(Format::Mvt, Encoding::Zlib));
610 }
611
612 #[test]
613 fn invalid_json_in_gzip() {
614 let invalid_json = b"{this is not valid json}";
616 let compressed = encode_gzip(invalid_json).unwrap();
617 let result = TileInfo::detect(&compressed);
618 assert_eq!(result, TileInfo::new(Format::Mvt, Encoding::Gzip));
619 }
620
621 #[rstest]
622 #[case::minimal_tile(&[0x02, 0x01], Ok(()))]
623 #[case::one_byte_length(&[0x03, 0x01, 0xaa], Ok(()))]
624 #[case::two_byte_length(&[0x80, 0x04, 0x01, 0xaa], Ok(()))]
625 #[case::multi_byte_length(&[0x80, 0x80, 0x05, 0x01, 0xdd], Ok(()))]
626 #[case::wrong_version(&[0x03, 0x02, 0xaa], Err(SevenBitDecodingError::UnexpectedTag(0x02)))]
627 #[case::empty_input(&[], Err(SevenBitDecodingError::TruncatedSize))]
628 #[case::size_overflow(&[0xFF; 64], Err(SevenBitDecodingError::SizeOverflow))]
629 #[case::size_underflow(&[0x00, 0x01], Err(SevenBitDecodingError::SizeUnderflow))]
630 #[case::unterminated_length(&[0x80], Err(SevenBitDecodingError::TruncatedSize))]
631 #[case::missing_version_byte(&[0x05], Err(SevenBitDecodingError::TruncatedTag))]
632 #[case::wrong_length(&[0x03, 0x01], Err(SevenBitDecodingError::TruncatedData { expected: 1, actual: 0 }))]
633 fn test_decode_7bit_length_and_tag(
634 #[case] tile: &[u8],
635 #[case] expected: Result<(), SevenBitDecodingError>,
636 ) {
637 let allowed_versions = &[0x01_u8];
638 let decoded = decode_7bit_length_and_tag(tile, allowed_versions);
639 assert_eq!(decoded, expected, "can decode one layer correctly");
640
641 if tile.is_empty() {
642 return;
643 }
644 let mut tile_with_two_layers = vec![0x02, 0x01];
645 tile_with_two_layers.extend_from_slice(tile);
646 let decoded = decode_7bit_length_and_tag(&tile_with_two_layers, allowed_versions);
647 assert_eq!(decoded, expected, "can decode two layers correctly");
648 }
649
650 #[rstest]
651 #[case(-180.0, 85.0511, 0, (0,0))]
652 #[case(-180.0, 85.0511, 1, (0,0))]
653 #[case(-180.0, 85.0511, 2, (0,0))]
654 #[case(0.0, 0.0, 0, (0,0))]
655 #[case(0.0, 0.0, 1, (1,1))]
656 #[case(0.0, 0.0, 2, (2,2))]
657 #[case(0.0, 1.0, 0, (0,0))]
658 #[case(0.0, 1.0, 1, (1,0))]
659 #[case(0.0, 1.0, 2, (2,1))]
660 fn test_tile_colrow(
661 #[case] lng: f64,
662 #[case] lat: f64,
663 #[case] zoom: u8,
664 #[case] expected: (u32, u32),
665 ) {
666 assert_eq!(
667 expected,
668 tile_index(lng, lat, zoom),
669 "{lng},{lat}@z{zoom} should be {expected:?}"
670 );
671 }
672
673 #[rstest]
674 #[case(0, 0, 0, 0, 0, [-180.0,-85.051_128_779_806_6,180.0,85.051_128_779_806_6])]
676 #[case(1, 0, 0, 0, 0, [-180.0,0.0,0.0,85.051_128_779_806_6])]
677 #[case(5, 1, 1, 2, 2, [-168.75,81.093_213_852_608_37,-146.25,83.979_259_498_862_05])]
678 #[case(5, 1, 3, 2, 5, [-168.75,74.019_543_311_502_26,-146.25,81.093_213_852_608_37])]
679 fn test_xyz_to_bbox(
680 #[case] zoom: u8,
681 #[case] min_x: u32,
682 #[case] min_y: u32,
683 #[case] max_x: u32,
684 #[case] max_y: u32,
685 #[case] expected: [f64; 4],
686 ) {
687 let bbox = xyz_to_bbox(zoom, min_x, min_y, max_x, max_y);
688 assert_relative_eq!(bbox[0], expected[0], epsilon = f64::EPSILON * 2.0);
689 assert_relative_eq!(bbox[1], expected[1], epsilon = f64::EPSILON * 2.0);
690 assert_relative_eq!(bbox[2], expected[2], epsilon = f64::EPSILON * 2.0);
691 assert_relative_eq!(bbox[3], expected[3], epsilon = f64::EPSILON * 2.0);
692 }
693
694 #[rstest]
695 #[case(0, 0, 0, [-20_037_508.342_789_25, -20_037_508.342_789_25, 20_037_508.342_789_25, 20_037_508.342_789_25])]
696 #[case(1, 0, 0, [-20_037_508.342_789_25, 0.0, 0.0, 20_037_508.342_789_25])]
697 #[case(1, 1, 1, [0.0, -20_037_508.342_789_25, 20_037_508.342_789_25, 0.0])]
698 #[case(2, 0, 0, [-20_037_508.342_789_25, 10_018_754.171_394_625, -10_018_754.171_394_625, 20_037_508.342_789_25])]
699 #[case(2, 2, 2, [0.0, -10_018_754.171_394_625, 10_018_754.171_394_625, 0.0])]
700 fn test_tile_bbox(
701 #[case] zoom: u8,
702 #[case] x: u32,
703 #[case] y: u32,
704 #[case] expected: [f64; 4],
705 ) {
706 let tile_length = EARTH_CIRCUMFERENCE / f64::from(1_u32 << zoom);
707 let bbox = tile_bbox(x, y, tile_length);
708 assert_relative_eq!(bbox[0], expected[0], epsilon = f64::EPSILON * 2.0);
709 assert_relative_eq!(bbox[1], expected[1], epsilon = f64::EPSILON * 2.0);
710 assert_relative_eq!(bbox[2], expected[2], epsilon = f64::EPSILON * 2.0);
711 assert_relative_eq!(bbox[3], expected[3], epsilon = f64::EPSILON * 2.0);
712 assert_relative_eq!(bbox[2] - bbox[0], tile_length, epsilon = f64::EPSILON * 2.0);
713 assert_relative_eq!(bbox[3] - bbox[1], tile_length, epsilon = f64::EPSILON * 2.0);
714 }
715
716 #[rstest]
717 #[case(0, (0, 0, 0, 0))]
718 #[case(1, (0, 1, 0, 1))]
719 #[case(2, (0, 3, 0, 3))]
720 #[case(3, (0, 7, 0, 7))]
721 #[case(4, (0, 14, 1, 15))]
722 #[case(5, (0, 29, 2, 31))]
723 #[case(6, (0, 58, 5, 63))]
724 #[case(7, (0, 116, 11, 126))]
725 #[case(8, (0, 233, 23, 253))]
726 #[case(9, (0, 466, 47, 507))]
727 #[case(10, (1, 933, 94, 1_014))]
728 #[case(11, (3, 1_866, 188, 2_029))]
729 #[case(12, (6, 3_732, 377, 4_059))]
730 #[case(13, (12, 7_465, 755, 8_119))]
731 #[case(14, (25, 14_931, 1_510, 16_239))]
732 #[case(15, (51, 29_863, 3_020, 32_479))]
733 #[case(16, (102, 59_727, 6_041, 64_958))]
734 #[case(17, (204, 119_455, 12_083, 129_917))]
735 #[case(18, (409, 238_911, 24_166, 259_834))]
736 #[case(19, (819, 477_823, 48_332, 519_669))]
737 #[case(20, (1_638, 955_647, 96_665, 1_039_339))]
738 #[case(21, (3_276, 1_911_295, 193_331, 2_078_678))]
739 #[case(22, (6_553, 3_822_590, 386_662, 4_157_356))]
740 #[case(23, (13_107, 7_645_181, 773_324, 8_314_713))]
741 #[case(24, (26_214, 15_290_363, 1_546_649, 16_629_427))]
742 #[case(25, (52_428, 30_580_726, 3_093_299, 33_258_855))]
743 #[case(26, (104_857, 61_161_453, 6_186_598, 66_517_711))]
744 #[case(27, (209_715, 122_322_907, 12_373_196, 133_035_423))]
745 #[case(28, (419_430, 244_645_814, 24_746_393, 266_070_846))]
746 #[case(29, (838_860, 489_291_628, 49_492_787, 532_141_692))]
747 #[case(30, (1_677_721, 978_583_256, 98_985_574, 1_064_283_385))]
748 fn test_box_to_xyz(#[case] zoom: u8, #[case] expected_xyz: (u32, u32, u32, u32)) {
749 let actual_xyz = bbox_to_xyz(
750 -179.437_499_999_999_55,
751 -84.769_878_779_806_56,
752 -146.812_499_999_999_6,
753 -81.374_463_852_608_33,
754 zoom,
755 );
756 assert_eq!(
757 actual_xyz, expected_xyz,
758 "zoom {zoom} does not have the right xyz"
759 );
760 }
761
762 #[rstest]
763 #[case((0.0,0.0), (0.0,0.0))]
765 #[case((30.0,0.0), (3_339_584.723_798_207,0.0))]
766 #[case((-30.0,0.0), (-3_339_584.723_798_207,0.0))]
767 #[case((0.0,30.0), (0.0,3_503_549.843_504_375_3))]
768 #[case((0.0,-30.0), (0.0,-3_503_549.843_504_375_3))]
769 #[case((38.897_957,-77.036_560), (4_330_100.766_138_651, -13_872_207.775_755_845))] #[case((-180.0,-85.0), (-20_037_508.342_789_244, -19_971_868.880_408_566))]
771 #[case((180.0,85.0), (20_037_508.342_789_244, 19_971_868.880_408_566))]
772 #[case((0.026_949_458_523_585_632,0.080_848_348_740_973_67), (3000.0, 9000.0))]
773 fn test_coordinate_syste_conversion(
774 #[case] wgs84: (f64, f64),
775 #[case] webmercator: (f64, f64),
776 ) {
777 let epsilon = f64::from(f32::EPSILON);
779
780 let actual_wgs84 = webmercator_to_wgs84(webmercator.0, webmercator.1);
781 assert_relative_eq!(actual_wgs84.0, wgs84.0, epsilon = epsilon);
782 assert_relative_eq!(actual_wgs84.1, wgs84.1, epsilon = epsilon);
783
784 let actual_webmercator = wgs84_to_webmercator(wgs84.0, wgs84.1);
785 assert_relative_eq!(actual_webmercator.0, webmercator.0, epsilon = epsilon);
786 assert_relative_eq!(actual_webmercator.1, webmercator.1, epsilon = epsilon);
787 }
788
789 #[rstest]
790 #[case(0..11, 0)]
791 #[case(11..14, 1)]
792 #[case(14..17, 2)]
793 #[case(17..21, 3)]
794 #[case(21..24, 4)]
795 #[case(24..27, 5)]
796 #[case(27..30, 6)]
797 fn test_get_zoom_precision(
798 #[case] zoom: std::ops::Range<u8>,
799 #[case] expected_precision: usize,
800 ) {
801 for z in zoom {
802 let actual_precision = get_zoom_precision(z);
803 assert_eq!(
804 actual_precision, expected_precision,
805 "Zoom level {z} should have precision {expected_precision}, but was {actual_precision}"
806 );
807 }
808 }
809
810 #[test]
811 fn tile_coord_zoom_range() {
812 for z in 0..=MAX_ZOOM {
813 assert!(TileCoord::is_possible_on_zoom_level(z, 0, 0));
814 assert_eq!(
815 TileCoord::new_checked(z, 0, 0),
816 Some(TileCoord { z, x: 0, y: 0 })
817 );
818 }
819 assert!(!TileCoord::is_possible_on_zoom_level(MAX_ZOOM + 1, 0, 0));
820 assert_eq!(TileCoord::new_checked(MAX_ZOOM + 1, 0, 0), None);
821 }
822
823 #[test]
824 fn tile_coord_new_checked_xy_for_zoom() {
825 assert!(TileCoord::is_possible_on_zoom_level(5, 0, 0));
826 assert_eq!(
827 TileCoord::new_checked(5, 0, 0),
828 Some(TileCoord { z: 5, x: 0, y: 0 })
829 );
830 assert!(TileCoord::is_possible_on_zoom_level(5, 31, 31));
831 assert_eq!(
832 TileCoord::new_checked(5, 31, 31),
833 Some(TileCoord { z: 5, x: 31, y: 31 })
834 );
835 assert!(!TileCoord::is_possible_on_zoom_level(5, 31, 32));
836 assert_eq!(TileCoord::new_checked(5, 31, 32), None);
837 assert!(!TileCoord::is_possible_on_zoom_level(5, 32, 31));
838 assert_eq!(TileCoord::new_checked(5, 32, 31), None);
839 }
840
841 #[test]
842 fn tile_coord_new_unchecked() {
846 assert_eq!(
847 TileCoord::new_unchecked(u8::MAX, u32::MAX, u32::MAX),
848 TileCoord {
849 z: u8::MAX,
850 x: u32::MAX,
851 y: u32::MAX
852 }
853 );
854 }
855
856 #[test]
857 fn xyz_format() {
858 let xyz = TileCoord { z: 1, x: 2, y: 3 };
859 assert_eq!(format!("{xyz}"), "1,2,3");
860 assert_eq!(format!("{xyz:#}"), "1/2/3");
861 }
862
863 #[rstest]
864 #[case("none", Some(Encoding::Uncompressed))]
865 #[case("identity", Some(Encoding::Uncompressed))]
866 #[case("IDENTITY", Some(Encoding::Uncompressed))]
867 #[case("gzip", Some(Encoding::Gzip))]
868 #[case("GZIP", Some(Encoding::Gzip))]
869 #[case("deflate", Some(Encoding::Zlib))]
870 #[case("zlib", Some(Encoding::Zlib))]
871 #[case("br", Some(Encoding::Brotli))]
872 #[case("brotli", Some(Encoding::Brotli))]
873 #[case("zstd", Some(Encoding::Zstd))]
874 #[case("unknown", None)]
875 #[case("", None)]
876 fn test_encoding_parse(#[case] input: &str, #[case] expected: Option<Encoding>) {
877 assert_eq!(Encoding::parse(input), expected);
878 }
879
880 #[rstest]
881 #[case(Encoding::Uncompressed, None)]
882 #[case(Encoding::Internal, None)]
883 #[case(Encoding::Gzip, Some("gzip"))]
884 #[case(Encoding::Zlib, Some("deflate"))]
885 #[case(Encoding::Brotli, Some("br"))]
886 #[case(Encoding::Zstd, Some("zstd"))]
887 fn test_compression(#[case] encoding: Encoding, #[case] expected: Option<&str>) {
888 assert_eq!(encoding.compression(), expected);
889 }
890}