Skip to main content

oxigdal_services/
ogc_tiles.rs

1//! OGC API - Tiles implementation
2//!
3//! Implements the OGC Two Dimensional Tile Matrix Set and Tile Set Metadata standard
4//! (OGC 17-083r4 / OGC API - Tiles - Part 1: Core).
5//!
6//! See: <https://docs.ogc.org/is/17-083r4/17-083r4.html>
7//!
8//! # Standards
9//!
10//! - OGC Two Dimensional Tile Matrix Set (17-083r2)
11//! - OGC API - Tiles - Part 1: Core (OGC 20-057)
12//!
13//! # Conformance Classes
14//!
15//! - Core: <http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core>
16//! - TileMatrixSet: <http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tilematrixset>
17//! - GeoDataTiles: <http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/geodata-tilesets>
18
19use serde::{Deserialize, Serialize};
20
21/// OGC TileMatrixSet defines a coordinate reference system, a set of tile matrices
22/// at different scales, and the origin and dimensions of each tile matrix.
23///
24/// Well-known TileMatrixSets are registered at the OGC NA definition server.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct TileMatrixSet {
28    /// Identifier of the TileMatrixSet
29    pub id: String,
30    /// Human-readable title
31    pub title: String,
32    /// URI of the TileMatrixSet definition (well-known sets have OGC URIs)
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub uri: Option<String>,
35    /// Coordinate Reference System as URI
36    pub crs: String,
37    /// Ordered list of tile matrices (one per zoom level)
38    pub tile_matrices: Vec<TileMatrix>,
39}
40
41/// A single zoom level (scale) within a TileMatrixSet.
42///
43/// Describes the tile grid at a specific scale: origin, tile dimensions,
44/// number of tiles in each direction, and scale denominator.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct TileMatrix {
48    /// Identifier (typically the zoom level as a string, e.g. "0", "1", ...)
49    pub id: String,
50    /// Scale denominator of the tile matrix (at 0.28mm/pixel)
51    pub scale_denominator: f64,
52    /// Cell size in CRS units per pixel
53    pub cell_size: f64,
54    /// Corner used as origin for tile addressing
55    pub corner_of_origin: CornerOfOrigin,
56    /// Coordinates of the origin corner [easting/longitude, northing/latitude]
57    pub point_of_origin: [f64; 2],
58    /// Width of each tile in pixels
59    pub tile_width: u32,
60    /// Height of each tile in pixels
61    pub tile_height: u32,
62    /// Number of tiles in the X direction
63    pub matrix_width: u32,
64    /// Number of tiles in the Y direction
65    pub matrix_height: u32,
66}
67
68/// Specifies which corner of the tile matrix is the origin for tile addressing.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub enum CornerOfOrigin {
72    /// Origin is the top-left corner (Y increases downward)
73    TopLeft,
74    /// Origin is the bottom-left corner (Y increases upward)
75    BottomLeft,
76}
77
78/// Metadata describing a specific TileSet — a collection of tiles covering
79/// a geographic area at multiple zoom levels using a defined TileMatrixSet.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct TileSetMetadata {
83    /// Identifier of the TileMatrixSet used
84    pub tile_matrix_set_id: String,
85    /// Data type served by this TileSet
86    pub data_type: TileDataType,
87    /// Links to tile resources and documentation
88    pub links: Vec<TileLink>,
89    /// Human-readable title
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub title: Option<String>,
92    /// Description of this TileSet
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub description: Option<String>,
95    /// Attribution/copyright notice
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub attribution: Option<String>,
98    /// Geographic bounding box of available tiles
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub extent: Option<GeographicBoundingBox>,
101    /// Minimum zoom level available
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub min_tile_matrix: Option<String>,
104    /// Maximum zoom level available
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub max_tile_matrix: Option<String>,
107}
108
109/// The type of data served in a TileSet.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "lowercase")]
112pub enum TileDataType {
113    /// Rendered map image (PNG, JPEG, WebP)
114    Map,
115    /// Vector features (MVT/Mapbox Vector Tile)
116    Vector,
117    /// Coverage / raster data (GeoTIFF, netCDF)
118    Coverage,
119}
120
121/// A hypermedia link in OGC API style.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct TileLink {
124    /// URL of the linked resource
125    pub href: String,
126    /// Link relation type (e.g. "self", "item", "tiles")
127    pub rel: String,
128    /// Media type of the linked resource
129    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
130    pub media_type: Option<String>,
131    /// Human-readable title
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub title: Option<String>,
134}
135
136/// WGS84 geographic bounding box (longitude/latitude).
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct GeographicBoundingBox {
139    /// Lower-left corner [longitude, latitude]
140    pub lower_left: [f64; 2],
141    /// Upper-right corner [longitude, latitude]
142    pub upper_right: [f64; 2],
143}
144
145/// OGC API conformance declaration for OGC API - Tiles.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct ConformanceDeclaration {
149    /// List of conformance class URIs this implementation satisfies
150    pub conforms_to: Vec<String>,
151}
152
153impl ConformanceDeclaration {
154    /// Standard conformance classes for OGC API - Tiles Part 1
155    pub fn ogc_tiles() -> Self {
156        Self {
157            conforms_to: vec![
158                "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/core".into(),
159                "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/tilematrixset".into(),
160                "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/geodata-tilesets".into(),
161                "http://www.opengis.net/spec/ogcapi-tiles-1/1.0/conf/collections-selection".into(),
162                "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core".into(),
163                "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json".into(),
164            ],
165        }
166    }
167}
168
169// ─────────────────────────────────────────────────────────────────────────────
170// TileMatrixSet constructors for well-known grids
171// ─────────────────────────────────────────────────────────────────────────────
172
173impl TileMatrixSet {
174    /// Standard **WebMercatorQuad** (EPSG:3857) tile grid — zoom levels 0–24.
175    ///
176    /// Used by Google Maps, OpenStreetMap, Bing Maps, etc.
177    /// Origin is the top-left corner of the world at (-20037508.34, 20037508.34).
178    pub fn web_mercator_quad() -> Self {
179        // Reference: OGC 17-083r2 Annex D, Table D.1
180        // Scale at zoom 0: 559082264.0287178 (at 0.00028 m/pixel = 96 dpi)
181        // Cell size at zoom 0: 156543.033928041 m/pixel
182        const ORIGIN_X: f64 = -20_037_508.342_789_244;
183        const ORIGIN_Y: f64 = 20_037_508.342_789_244;
184        const SCALE_0: f64 = 559_082_264.028_717_8;
185        const CELL_0: f64 = 156_543.033_928_041;
186
187        let matrices = (0u8..=24)
188            .map(|z| {
189                let n = 1u32 << z;
190                let factor = n as f64;
191                TileMatrix {
192                    id: z.to_string(),
193                    scale_denominator: SCALE_0 / factor,
194                    cell_size: CELL_0 / factor,
195                    corner_of_origin: CornerOfOrigin::TopLeft,
196                    point_of_origin: [ORIGIN_X, ORIGIN_Y],
197                    tile_width: 256,
198                    tile_height: 256,
199                    matrix_width: n,
200                    matrix_height: n,
201                }
202            })
203            .collect();
204
205        Self {
206            id: "WebMercatorQuad".into(),
207            title: "Google Maps Compatible for the World".into(),
208            uri: Some("http://www.opengis.net/def/tilematrixset/OGC/1.0/WebMercatorQuad".into()),
209            crs: "http://www.opengis.net/def/crs/EPSG/0/3857".into(),
210            tile_matrices: matrices,
211        }
212    }
213
214    /// Standard **WorldCRS84Quad** (EPSG:4326 / OGC:CRS84) tile grid — zoom levels 0–17.
215    ///
216    /// Two tiles at zoom 0 cover the whole world (2:1 aspect ratio).
217    /// Used in OGC services and aerospace applications.
218    pub fn world_crs84_quad() -> Self {
219        // Reference: OGC 17-083r2 Annex D, Table D.2
220        const SCALE_0: f64 = 279_541_132.014_358_76;
221        const PIXEL_SIZE_DEG: f64 = 0.000_000_277_777_8; // 1 degree / (256 * 14)
222
223        let matrices = (0u8..=17)
224            .map(|z| {
225                // At zoom 0: 2 columns × 1 row; each step doubles both
226                let n_y = 1u32 << z;
227                let n_x = 2u32 << z;
228                let factor = n_y as f64;
229                TileMatrix {
230                    id: z.to_string(),
231                    scale_denominator: SCALE_0 / factor,
232                    cell_size: PIXEL_SIZE_DEG / factor,
233                    corner_of_origin: CornerOfOrigin::TopLeft,
234                    point_of_origin: [-180.0, 90.0],
235                    tile_width: 256,
236                    tile_height: 256,
237                    matrix_width: n_x,
238                    matrix_height: n_y,
239                }
240            })
241            .collect();
242
243        Self {
244            id: "WorldCRS84Quad".into(),
245            title: "CRS84 for the World".into(),
246            uri: Some("http://www.opengis.net/def/tilematrixset/OGC/1.0/WorldCRS84Quad".into()),
247            crs: "http://www.opengis.net/def/crs/OGC/1.3/CRS84".into(),
248            tile_matrices: matrices,
249        }
250    }
251
252    /// Look up a tile matrix by zoom level.
253    pub fn tile_matrix(&self, zoom: u8) -> Option<&TileMatrix> {
254        self.tile_matrices.iter().find(|m| m.id == zoom.to_string())
255    }
256
257    /// Return the maximum available zoom level.
258    pub fn max_zoom(&self) -> u8 {
259        self.tile_matrices
260            .iter()
261            .filter_map(|m| m.id.parse::<u8>().ok())
262            .max()
263            .unwrap_or(0)
264    }
265
266    /// Return the minimum available zoom level.
267    pub fn min_zoom(&self) -> u8 {
268        self.tile_matrices
269            .iter()
270            .filter_map(|m| m.id.parse::<u8>().ok())
271            .min()
272            .unwrap_or(0)
273    }
274
275    /// Total number of zoom levels defined in this TileMatrixSet.
276    pub fn zoom_level_count(&self) -> usize {
277        self.tile_matrices.len()
278    }
279}
280
281// ─────────────────────────────────────────────────────────────────────────────
282// Tile coordinate utilities
283// ─────────────────────────────────────────────────────────────────────────────
284
285/// Convert tile coordinates (z, x, y) to a geographic bounding box.
286///
287/// Returns `[west, south, east, north]` in degrees (WGS84 / EPSG:4326).
288/// Uses the Web Mercator (Slippy Map) tile convention.
289///
290/// # Arguments
291///
292/// * `z` – zoom level (0–30)
293/// * `x` – tile column (0 .. 2^z − 1)
294/// * `y` – tile row, top-left origin (0 .. 2^z − 1)
295pub fn tile_to_bbox(z: u8, x: u32, y: u32) -> [f64; 4] {
296    let n = 1u32 << z;
297    let nf = n as f64;
298
299    let west = (x as f64 / nf) * 360.0 - 180.0;
300    let east = ((x + 1) as f64 / nf) * 360.0 - 180.0;
301
302    // Mercator latitude from tile row
303    let to_lat = |row: u32| -> f64 {
304        let sinh_arg = (1.0 - 2.0 * row as f64 / nf) * std::f64::consts::PI;
305        sinh_arg.sinh().atan().to_degrees()
306    };
307
308    let north = to_lat(y);
309    let south = to_lat(y + 1);
310
311    [west, south, east, north]
312}
313
314/// Convert WGS84 longitude/latitude to tile coordinates at a given zoom level.
315///
316/// Uses the standard Slippy Map / Web Mercator tile numbering (origin top-left).
317///
318/// # Arguments
319///
320/// * `lon` – longitude in degrees (−180 to 180)
321/// * `lat` – latitude in degrees (−85.051 to 85.051)
322/// * `zoom` – target zoom level
323///
324/// Returns `(x, y)` clamped to the valid tile range.
325pub fn lonlat_to_tile(lon: f64, lat: f64, zoom: u8) -> (u32, u32) {
326    let n = 1u32 << zoom;
327    let nf = n as f64;
328
329    let x_raw = (lon + 180.0) / 360.0 * nf;
330    let lat_rad = lat.to_radians();
331    let y_raw =
332        (1.0 - (lat_rad.tan() + (1.0 / lat_rad.cos())).ln() / std::f64::consts::PI) / 2.0 * nf;
333
334    let x = (x_raw as u32).min(n.saturating_sub(1));
335    let y = (y_raw as u32).min(n.saturating_sub(1));
336    (x, y)
337}
338
339/// Convert tile coordinates to the pixel bounds within a tile grid.
340///
341/// Returns `(pixel_x_min, pixel_y_min, pixel_x_max, pixel_y_max)` in screen pixels
342/// for a full tile grid rendered at the given zoom level (each tile is 256×256 px).
343pub fn tile_to_pixel_bounds(_z: u8, x: u32, y: u32) -> (u64, u64, u64, u64) {
344    let tile_size: u64 = 256;
345    let x0 = x as u64 * tile_size;
346    let y0 = y as u64 * tile_size;
347    (x0, y0, x0 + tile_size, y0 + tile_size)
348}
349
350/// Validate that tile coordinates are within range for a given zoom level.
351///
352/// Returns `true` if x and y are both within [0, 2^z − 1].
353pub fn validate_tile_coords(z: u8, x: u32, y: u32) -> bool {
354    if z > 30 {
355        return false;
356    }
357    let max = (1u32 << z).saturating_sub(1);
358    x <= max && y <= max
359}
360
361/// Return the list of child tiles (next zoom level) for a given tile.
362///
363/// Each tile has exactly 4 children: (2x, 2y), (2x+1, 2y), (2x, 2y+1), (2x+1, 2y+1).
364pub fn tile_children(z: u8, x: u32, y: u32) -> Option<[(u8, u32, u32); 4]> {
365    let next_z = z.checked_add(1)?;
366    Some([
367        (next_z, 2 * x, 2 * y),
368        (next_z, 2 * x + 1, 2 * y),
369        (next_z, 2 * x, 2 * y + 1),
370        (next_z, 2 * x + 1, 2 * y + 1),
371    ])
372}
373
374/// Return the parent tile (previous zoom level) for a given tile.
375///
376/// Returns `None` if `z == 0`.
377pub fn tile_parent(z: u8, x: u32, y: u32) -> Option<(u8, u32, u32)> {
378    let parent_z = z.checked_sub(1)?;
379    Some((parent_z, x / 2, y / 2))
380}
381
382/// Enumerate all tiles at a given zoom level that intersect a bounding box.
383///
384/// `bbox` is `[west, south, east, north]` in degrees.
385/// Returns an iterator of `(x, y)` tile coordinates.
386pub fn tiles_in_bbox(bbox: [f64; 4], zoom: u8) -> impl Iterator<Item = (u32, u32)> {
387    let [west, south, east, north] = bbox;
388    let (x_min, y_max) = lonlat_to_tile(west, south, zoom);
389    let (x_max, y_min) = lonlat_to_tile(east, north, zoom);
390
391    let n = (1u32 << zoom).saturating_sub(1);
392    let x_min = x_min.min(n);
393    let x_max = x_max.min(n);
394    let y_min = y_min.min(n);
395    let y_max = y_max.min(n);
396
397    (y_min..=y_max).flat_map(move |y| (x_min..=x_max).map(move |x| (x, y)))
398}
399
400// ─────────────────────────────────────────────────────────────────────────────
401// TileSetMetadata builders
402// ─────────────────────────────────────────────────────────────────────────────
403
404impl TileSetMetadata {
405    /// Create a minimal TileSetMetadata for vector tiles using WebMercatorQuad.
406    pub fn vector_web_mercator(tile_url_template: impl Into<String>) -> Self {
407        Self {
408            tile_matrix_set_id: "WebMercatorQuad".into(),
409            data_type: TileDataType::Vector,
410            links: vec![TileLink {
411                href: tile_url_template.into(),
412                rel: "item".into(),
413                media_type: Some("application/vnd.mapbox-vector-tile".into()),
414                title: Some("Vector tiles".into()),
415            }],
416            title: None,
417            description: None,
418            attribution: None,
419            extent: None,
420            min_tile_matrix: Some("0".into()),
421            max_tile_matrix: Some("24".into()),
422        }
423    }
424
425    /// Create a minimal TileSetMetadata for map tiles using WebMercatorQuad.
426    pub fn map_web_mercator(tile_url_template: impl Into<String>) -> Self {
427        Self {
428            tile_matrix_set_id: "WebMercatorQuad".into(),
429            data_type: TileDataType::Map,
430            links: vec![TileLink {
431                href: tile_url_template.into(),
432                rel: "item".into(),
433                media_type: Some("image/png".into()),
434                title: Some("Map tiles".into()),
435            }],
436            title: None,
437            description: None,
438            attribution: None,
439            extent: None,
440            min_tile_matrix: Some("0".into()),
441            max_tile_matrix: Some("24".into()),
442        }
443    }
444
445    /// Add a geographic extent to this TileSetMetadata.
446    pub fn with_extent(mut self, west: f64, south: f64, east: f64, north: f64) -> Self {
447        self.extent = Some(GeographicBoundingBox {
448            lower_left: [west, south],
449            upper_right: [east, north],
450        });
451        self
452    }
453
454    /// Set the min/max tile matrix zoom levels.
455    pub fn with_zoom_range(mut self, min_zoom: u8, max_zoom: u8) -> Self {
456        self.min_tile_matrix = Some(min_zoom.to_string());
457        self.max_tile_matrix = Some(max_zoom.to_string());
458        self
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    // ── TileMatrixSet construction ──────────────────────────────────────────
467
468    #[test]
469    fn test_web_mercator_quad_zoom_count() {
470        let tms = TileMatrixSet::web_mercator_quad();
471        assert_eq!(
472            tms.zoom_level_count(),
473            25,
474            "WebMercatorQuad should have zoom 0–24 (25 levels)"
475        );
476    }
477
478    #[test]
479    fn test_web_mercator_quad_max_zoom() {
480        let tms = TileMatrixSet::web_mercator_quad();
481        assert_eq!(tms.max_zoom(), 24);
482    }
483
484    #[test]
485    fn test_web_mercator_quad_min_zoom() {
486        let tms = TileMatrixSet::web_mercator_quad();
487        assert_eq!(tms.min_zoom(), 0);
488    }
489
490    #[test]
491    fn test_web_mercator_quad_zoom0_matrix_size() {
492        let tms = TileMatrixSet::web_mercator_quad();
493        let m = tms.tile_matrix(0).expect("zoom 0 must exist");
494        assert_eq!(m.matrix_width, 1);
495        assert_eq!(m.matrix_height, 1);
496    }
497
498    #[test]
499    fn test_web_mercator_quad_zoom1_matrix_size() {
500        let tms = TileMatrixSet::web_mercator_quad();
501        let m = tms.tile_matrix(1).expect("zoom 1 must exist");
502        assert_eq!(m.matrix_width, 2);
503        assert_eq!(m.matrix_height, 2);
504    }
505
506    #[test]
507    fn test_web_mercator_quad_zoom24_matrix_size() {
508        let tms = TileMatrixSet::web_mercator_quad();
509        let m = tms.tile_matrix(24).expect("zoom 24 must exist");
510        assert_eq!(m.matrix_width, 1 << 24);
511        assert_eq!(m.matrix_height, 1 << 24);
512    }
513
514    #[test]
515    fn test_web_mercator_quad_tile_matrix_none_for_25() {
516        let tms = TileMatrixSet::web_mercator_quad();
517        assert!(tms.tile_matrix(25).is_none());
518    }
519
520    #[test]
521    fn test_world_crs84_quad_zoom_count() {
522        let tms = TileMatrixSet::world_crs84_quad();
523        assert_eq!(
524            tms.zoom_level_count(),
525            18,
526            "WorldCRS84Quad should have zoom 0–17 (18 levels)"
527        );
528    }
529
530    #[test]
531    fn test_world_crs84_quad_max_zoom() {
532        let tms = TileMatrixSet::world_crs84_quad();
533        assert_eq!(tms.max_zoom(), 17);
534    }
535
536    #[test]
537    fn test_world_crs84_quad_zoom0_aspect_ratio() {
538        // WorldCRS84Quad zoom 0: 2 columns × 1 row (2:1 world aspect ratio)
539        let tms = TileMatrixSet::world_crs84_quad();
540        let m = tms.tile_matrix(0).expect("zoom 0 must exist");
541        assert_eq!(m.matrix_width, 2);
542        assert_eq!(m.matrix_height, 1);
543    }
544
545    #[test]
546    fn test_world_crs84_quad_zoom1_size() {
547        let tms = TileMatrixSet::world_crs84_quad();
548        let m = tms.tile_matrix(1).expect("zoom 1 must exist");
549        assert_eq!(m.matrix_width, 4);
550        assert_eq!(m.matrix_height, 2);
551    }
552
553    #[test]
554    fn test_world_crs84_quad_tile_matrix_none_for_18() {
555        let tms = TileMatrixSet::world_crs84_quad();
556        assert!(tms.tile_matrix(18).is_none());
557    }
558
559    #[test]
560    fn test_tile_matrix_corner_of_origin() {
561        let tms = TileMatrixSet::web_mercator_quad();
562        let m = tms.tile_matrix(0).expect("zoom 0 must exist");
563        assert_eq!(m.corner_of_origin, CornerOfOrigin::TopLeft);
564    }
565
566    #[test]
567    fn test_tile_matrix_tile_size() {
568        let tms = TileMatrixSet::web_mercator_quad();
569        let m = tms.tile_matrix(10).expect("zoom 10 must exist");
570        assert_eq!(m.tile_width, 256);
571        assert_eq!(m.tile_height, 256);
572    }
573
574    #[test]
575    fn test_tile_matrix_scale_decreases_with_zoom() {
576        let tms = TileMatrixSet::web_mercator_quad();
577        let m0 = tms.tile_matrix(0).expect("zoom 0");
578        let m1 = tms.tile_matrix(1).expect("zoom 1");
579        assert!(
580            m0.scale_denominator > m1.scale_denominator,
581            "Scale denominator should decrease as zoom increases"
582        );
583    }
584
585    // ── tile_to_bbox ────────────────────────────────────────────────────────
586
587    #[test]
588    fn test_tile_to_bbox_zoom0_full_world() {
589        let [west, south, east, north] = tile_to_bbox(0, 0, 0);
590        assert!((west - (-180.0)).abs() < 1e-6, "west={}", west);
591        assert!((east - 180.0).abs() < 1e-6, "east={}", east);
592        // Mercator clips at ~±85.051°
593        assert!(south < -85.0, "south={}", south);
594        assert!(north > 85.0, "north={}", north);
595    }
596
597    #[test]
598    fn test_tile_to_bbox_zoom1_nw_quadrant() {
599        let [west, south, east, north] = tile_to_bbox(1, 0, 0);
600        assert!((west - (-180.0)).abs() < 1e-6);
601        assert!((east - 0.0).abs() < 1e-6);
602        assert!(north > 0.0);
603        assert!(south > 0.0 || south.abs() < 1e-6);
604    }
605
606    #[test]
607    fn test_tile_to_bbox_zoom1_se_quadrant() {
608        let [west, south, east, north] = tile_to_bbox(1, 1, 1);
609        assert!((west - 0.0).abs() < 1e-6);
610        assert!((east - 180.0).abs() < 1e-6);
611        assert!(south < 0.0);
612        assert!(north.abs() < 1e-6 || north > -1.0);
613    }
614
615    #[test]
616    fn test_tile_to_bbox_ordering() {
617        // west < east, south < north for any valid tile
618        for z in 0u8..=5 {
619            let n = 1u32 << z;
620            for x in 0..n {
621                for y in 0..n {
622                    let [west, south, east, north] = tile_to_bbox(z, x, y);
623                    assert!(west < east, "z={} x={} y={}: west >= east", z, x, y);
624                    assert!(south < north, "z={} x={} y={}: south >= north", z, x, y);
625                }
626            }
627        }
628    }
629
630    // ── lonlat_to_tile ──────────────────────────────────────────────────────
631
632    #[test]
633    fn test_lonlat_to_tile_zoom0_any_point() {
634        // At zoom 0 there is only one tile (0,0)
635        assert_eq!(lonlat_to_tile(0.0, 0.0, 0), (0, 0));
636        assert_eq!(lonlat_to_tile(-90.0, 45.0, 0), (0, 0));
637        assert_eq!(lonlat_to_tile(90.0, -45.0, 0), (0, 0));
638    }
639
640    #[test]
641    fn test_lonlat_to_tile_top_left_zoom1() {
642        // Top-left tile at zoom 1
643        let (x, y) = lonlat_to_tile(-179.999, 84.999, 1);
644        assert_eq!((x, y), (0, 0), "top-left should be (0,0) got ({},{})", x, y);
645    }
646
647    #[test]
648    fn test_lonlat_to_tile_bottom_right_zoom1() {
649        let (x, y) = lonlat_to_tile(179.999, -84.999, 1);
650        assert_eq!(
651            (x, y),
652            (1, 1),
653            "bottom-right at zoom 1 should be (1,1) got ({},{})",
654            x,
655            y
656        );
657    }
658
659    #[test]
660    fn test_lonlat_to_tile_prime_meridian_equator_zoom8() {
661        let (x, y) = lonlat_to_tile(0.0, 0.0, 8);
662        // (0,0) is at the intersection: x should be 128, y near 128
663        assert_eq!(x, 128);
664        assert_eq!(y, 128);
665    }
666
667    #[test]
668    fn test_lonlat_to_tile_roundtrip_consistency() {
669        // tile_to_bbox and lonlat_to_tile should be consistent
670        for z in 0u8..=6 {
671            let n = 1u32 << z;
672            for x in 0..n {
673                for y in 0..n {
674                    let [west, _south, _east, north] = tile_to_bbox(z, x, y);
675                    // Center of the tile's northern edge should map back to the same tile
676                    let center_lon = (west + _east) / 2.0;
677                    let (tx, ty) = lonlat_to_tile(center_lon, north - 0.0001, z);
678                    assert_eq!(
679                        (tx, ty),
680                        (x, y),
681                        "z={} x={} y={}: center mapped to ({},{})",
682                        z,
683                        x,
684                        y,
685                        tx,
686                        ty
687                    );
688                }
689            }
690        }
691    }
692
693    // ── validate_tile_coords ────────────────────────────────────────────────
694
695    #[test]
696    fn test_validate_tile_coords_valid() {
697        assert!(validate_tile_coords(0, 0, 0));
698        assert!(validate_tile_coords(10, 0, 0));
699        assert!(validate_tile_coords(10, 1023, 1023));
700    }
701
702    #[test]
703    fn test_validate_tile_coords_out_of_range() {
704        assert!(!validate_tile_coords(0, 1, 0));
705        assert!(!validate_tile_coords(0, 0, 1));
706        assert!(!validate_tile_coords(10, 1024, 0));
707    }
708
709    // ── tile_children / tile_parent ─────────────────────────────────────────
710
711    #[test]
712    fn test_tile_children_count() {
713        let children = tile_children(5, 10, 7).expect("should have children");
714        assert_eq!(children.len(), 4);
715    }
716
717    #[test]
718    fn test_tile_children_zoom_incremented() {
719        let children = tile_children(3, 2, 2).expect("should have children");
720        for (cz, _, _) in &children {
721            assert_eq!(*cz, 4);
722        }
723    }
724
725    #[test]
726    fn test_tile_parent_basic() {
727        let (pz, px, py) = tile_parent(5, 10, 7).expect("should have parent");
728        assert_eq!(pz, 4);
729        assert_eq!(px, 5);
730        assert_eq!(py, 3);
731    }
732
733    #[test]
734    fn test_tile_parent_none_at_zoom0() {
735        assert!(tile_parent(0, 0, 0).is_none());
736    }
737
738    // ── tiles_in_bbox ───────────────────────────────────────────────────────
739
740    #[test]
741    fn test_tiles_in_bbox_zoom0_world() {
742        let tiles: Vec<_> = tiles_in_bbox([-180.0, -85.0, 180.0, 85.0], 0).collect();
743        assert_eq!(tiles.len(), 1, "zoom 0 whole world = 1 tile");
744    }
745
746    #[test]
747    fn test_tiles_in_bbox_zoom1_world() {
748        let tiles: Vec<_> = tiles_in_bbox([-180.0, -85.0, 180.0, 85.0], 1).collect();
749        assert_eq!(tiles.len(), 4, "zoom 1 whole world = 4 tiles");
750    }
751
752    // ── TileSetMetadata ──────────────────────────────────────────────────────
753
754    #[test]
755    fn test_tileset_metadata_vector_web_mercator() {
756        let meta = TileSetMetadata::vector_web_mercator("https://tiles/{z}/{x}/{y}.mvt");
757        assert_eq!(meta.tile_matrix_set_id, "WebMercatorQuad");
758        assert_eq!(meta.data_type, TileDataType::Vector);
759        assert!(!meta.links.is_empty());
760    }
761
762    #[test]
763    fn test_tileset_metadata_map_web_mercator() {
764        let meta = TileSetMetadata::map_web_mercator("https://tiles/{z}/{x}/{y}.png");
765        assert_eq!(meta.data_type, TileDataType::Map);
766        assert!(meta.links[0].href.contains("{z}"));
767    }
768
769    #[test]
770    fn test_tileset_metadata_with_extent() {
771        let meta = TileSetMetadata::vector_web_mercator("https://tiles/{z}/{x}/{y}.mvt")
772            .with_extent(-10.0, 35.0, 40.0, 70.0);
773        let ext = meta.extent.expect("extent should be set");
774        assert_eq!(ext.lower_left, [-10.0, 35.0]);
775        assert_eq!(ext.upper_right, [40.0, 70.0]);
776    }
777
778    #[test]
779    fn test_tileset_metadata_serialization_roundtrip() {
780        let meta =
781            TileSetMetadata::vector_web_mercator("https://example.com/tiles/{z}/{x}/{y}.mvt")
782                .with_extent(-180.0, -90.0, 180.0, 90.0)
783                .with_zoom_range(0, 14);
784
785        let json = serde_json::to_string(&meta).expect("serialization should succeed");
786        let decoded: TileSetMetadata =
787            serde_json::from_str(&json).expect("deserialization should succeed");
788        assert_eq!(decoded.tile_matrix_set_id, "WebMercatorQuad");
789        assert_eq!(decoded.min_tile_matrix.as_deref(), Some("0"));
790        assert_eq!(decoded.max_tile_matrix.as_deref(), Some("14"));
791    }
792
793    #[test]
794    fn test_tile_link_serialization() {
795        let link = TileLink {
796            href: "https://example.com/tiles/0/0/0.mvt".into(),
797            rel: "item".into(),
798            media_type: Some("application/vnd.mapbox-vector-tile".into()),
799            title: Some("Vector tile".into()),
800        };
801        let json = serde_json::to_string(&link).expect("serialization should succeed");
802        assert!(json.contains("application/vnd.mapbox-vector-tile"));
803        assert!(json.contains("item"));
804    }
805
806    #[test]
807    fn test_tile_data_type_variants() {
808        assert_ne!(TileDataType::Map, TileDataType::Vector);
809        assert_ne!(TileDataType::Vector, TileDataType::Coverage);
810        assert_ne!(TileDataType::Map, TileDataType::Coverage);
811    }
812
813    #[test]
814    fn test_corner_of_origin_variants() {
815        assert_ne!(CornerOfOrigin::TopLeft, CornerOfOrigin::BottomLeft);
816    }
817
818    #[test]
819    fn test_geographic_bounding_box() {
820        let bbox = GeographicBoundingBox {
821            lower_left: [-10.0, 35.0],
822            upper_right: [40.0, 70.0],
823        };
824        assert_eq!(bbox.lower_left[0], -10.0);
825        assert_eq!(bbox.upper_right[1], 70.0);
826    }
827
828    #[test]
829    fn test_conformance_declaration_ogc_tiles() {
830        let conf = ConformanceDeclaration::ogc_tiles();
831        assert!(!conf.conforms_to.is_empty());
832        let has_core = conf
833            .conforms_to
834            .iter()
835            .any(|c| c.contains("ogcapi-tiles-1") && c.contains("conf/core"));
836        assert!(has_core, "should include OGC Tiles core conformance class");
837    }
838
839    #[test]
840    fn test_conformance_declaration_serialization() {
841        let conf = ConformanceDeclaration::ogc_tiles();
842        let json = serde_json::to_string(&conf).expect("serialization should succeed");
843        let decoded: ConformanceDeclaration =
844            serde_json::from_str(&json).expect("deserialization should succeed");
845        assert_eq!(decoded.conforms_to.len(), conf.conforms_to.len());
846    }
847
848    #[test]
849    fn test_tile_pixel_bounds() {
850        let (x0, y0, x1, y1) = tile_to_pixel_bounds(0, 0, 0);
851        assert_eq!(x0, 0);
852        assert_eq!(y0, 0);
853        assert_eq!(x1, 256);
854        assert_eq!(y1, 256);
855
856        let (x0, y0, x1, y1) = tile_to_pixel_bounds(1, 1, 1);
857        assert_eq!(x0, 256);
858        assert_eq!(y0, 256);
859        assert_eq!(x1, 512);
860        assert_eq!(y1, 512);
861    }
862
863    #[test]
864    fn test_tile_matrix_set_id_and_crs() {
865        let tms = TileMatrixSet::web_mercator_quad();
866        assert_eq!(tms.id, "WebMercatorQuad");
867        assert!(tms.crs.contains("3857"));
868
869        let tms2 = TileMatrixSet::world_crs84_quad();
870        assert_eq!(tms2.id, "WorldCRS84Quad");
871        assert!(tms2.crs.contains("CRS84") || tms2.crs.contains("4326"));
872    }
873}