Skip to main content

terrain_codec/
layer_json.rs

1//! Cesium `layer.json` (TerrainProvider metadata) builder.
2//!
3//! `layer.json` is the manifest Cesium fetches before any terrain tiles
4//! to learn the format, tiling scheme, attribution, extensions, and
5//! per-zoom-level tile availability. This module ships the serde-typed
6//! [`LayerJson`] struct, a [`LayerJsonConfig`] builder, and helpers for
7//! filling [`TileAvailability`] ranges from geographic bounds (via the
8//! schemes in [`crate::tile_coords`]).
9//!
10//! Reference:
11//! <https://github.com/CesiumGS/cesium/wiki/layer.json>
12
13use serde::{Deserialize, Serialize};
14
15use crate::tile_coords::{geodetic_tms, web_mercator, web_mercator_tms};
16
17/// Tiling scheme advertised in `scheme`.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum TilingScheme {
20    /// Global-geodetic TMS (Cesium terrain default).
21    Tms,
22    /// Web Mercator XYZ.
23    Xyz,
24}
25
26impl TilingScheme {
27    /// Canonical string used in `layer.json`.
28    pub const fn as_str(self) -> &'static str {
29        match self {
30            Self::Tms => "tms",
31            Self::Xyz => "xyz",
32        }
33    }
34}
35
36/// Terrain format advertised in `format`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum TerrainFormat {
39    /// Legacy `heightmap-1.0` (u16 grid + child mask + extensions).
40    Heightmap1,
41    /// `quantized-mesh-1.0` (mesh tiles).
42    QuantizedMesh1,
43}
44
45impl TerrainFormat {
46    /// Canonical string used in `layer.json`.
47    pub const fn as_str(self) -> &'static str {
48        match self {
49            Self::Heightmap1 => "heightmap-1.0",
50            Self::QuantizedMesh1 => "quantized-mesh-1.0",
51        }
52    }
53}
54
55/// One available tile range at a single zoom level.
56///
57/// `(start_x, start_y)` and `(end_x, end_y)` are inclusive.
58#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "camelCase")]
60pub struct TileAvailability {
61    /// Starting X coordinate (inclusive).
62    pub start_x: u32,
63    /// Starting Y coordinate (inclusive).
64    pub start_y: u32,
65    /// Ending X coordinate (inclusive).
66    pub end_x: u32,
67    /// Ending Y coordinate (inclusive).
68    pub end_y: u32,
69}
70
71impl TileAvailability {
72    /// Create a new range.
73    pub const fn new(start_x: u32, start_y: u32, end_x: u32, end_y: u32) -> Self {
74        Self {
75            start_x,
76            start_y,
77            end_x,
78            end_y,
79        }
80    }
81
82    /// Full zoom level in the global-geodetic TMS scheme
83    /// (`2^(z+1) × 2^z` tiles).
84    pub fn full_level_geodetic_tms(zoom: u8) -> Self {
85        Self::new(
86            0,
87            0,
88            geodetic_tms::tile_count_x(zoom) - 1,
89            geodetic_tms::tile_count_y(zoom) - 1,
90        )
91    }
92
93    /// Full zoom level in Web Mercator XYZ (`2^z × 2^z` tiles).
94    pub fn full_level_xyz(zoom: u8) -> Self {
95        let n = web_mercator::tile_count(zoom);
96        Self::new(0, 0, n - 1, n - 1)
97    }
98
99    /// Range covering `(west, south, east, north)` in the global-geodetic
100    /// TMS scheme.
101    pub fn from_bounds_geodetic_tms(
102        zoom: u8,
103        west: f64,
104        south: f64,
105        east: f64,
106        north: f64,
107    ) -> Self {
108        let (start_x, start_y) = geodetic_tms::lonlat_to_tile(west, south, zoom);
109        let (end_x, end_y) = geodetic_tms::lonlat_to_tile(east, north, zoom);
110        Self::new(start_x, start_y, end_x, end_y)
111    }
112
113    /// Range covering `(west, south, east, north)` in Web Mercator XYZ.
114    pub fn from_bounds_xyz(zoom: u8, west: f64, south: f64, east: f64, north: f64) -> Self {
115        // XYZ Y=0 is north → south latitude maps to the larger y.
116        let (start_x, end_y) = web_mercator::lonlat_to_tile(west, south, zoom);
117        let (end_x, start_y) = web_mercator::lonlat_to_tile(east, north, zoom);
118        Self::new(start_x, start_y, end_x, end_y)
119    }
120
121    /// Range covering `(west, south, east, north)` in the TMS-flipped
122    /// Web Mercator scheme (Y=0 south).
123    pub fn from_bounds_web_mercator_tms(
124        zoom: u8,
125        west: f64,
126        south: f64,
127        east: f64,
128        north: f64,
129    ) -> Self {
130        let (start_x, start_y) = web_mercator_tms::lonlat_to_tile(west, south, zoom);
131        let (end_x, end_y) = web_mercator_tms::lonlat_to_tile(east, north, zoom);
132        Self::new(start_x, start_y, end_x, end_y)
133    }
134}
135
136/// Builder-style configuration for assembling a [`LayerJson`].
137#[derive(Debug, Clone)]
138pub struct LayerJsonConfig {
139    /// Tile URL template (e.g. `"{z}/{x}/{y}.terrain"`).
140    pub tiles_template: String,
141    /// Data version, also used by Cesium for cache busting.
142    pub version: String,
143    /// Attribution text (HTML allowed).
144    pub attribution: Option<String>,
145    /// Per-zoom-level tile availability ranges (outer index = zoom level).
146    pub available: Vec<Vec<TileAvailability>>,
147    /// Minimum zoom level the server supports.
148    pub min_zoom: Option<u8>,
149    /// Maximum zoom level the server supports.
150    pub max_zoom: Option<u8>,
151    /// Tiling scheme.
152    pub scheme: TilingScheme,
153    /// Geographic bounds `[west, south, east, north]`.
154    pub bounds: Option<[f64; 4]>,
155    /// Enabled extensions (e.g. `"octvertexnormals"`, `"watermask"`,
156    /// `"metadata"`).
157    pub extensions: Vec<String>,
158    /// Terrain format.
159    pub format: TerrainFormat,
160    /// Metadata-availability level for the `metadata` extension. When
161    /// set, Cesium walks the tree by reading each tile's metadata
162    /// extension instead of relying on the static `available` array.
163    pub metadata_availability: Option<u8>,
164}
165
166impl Default for LayerJsonConfig {
167    fn default() -> Self {
168        Self {
169            tiles_template: "{z}/{x}/{y}.terrain".to_string(),
170            version: "1.0.0".to_string(),
171            attribution: None,
172            available: Vec::new(),
173            min_zoom: None,
174            max_zoom: None,
175            scheme: TilingScheme::Tms,
176            bounds: None,
177            extensions: Vec::new(),
178            format: TerrainFormat::QuantizedMesh1,
179            metadata_availability: None,
180        }
181    }
182}
183
184/// Serializable Cesium `layer.json` structure.
185///
186/// Build via [`LayerJson::from_config`] or construct directly. Serialize
187/// with `serde_json` to produce the file Cesium expects.
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
189#[serde(rename_all = "camelCase")]
190pub struct LayerJson {
191    /// TileJSON version (Cesium expects `"2.1.0"`).
192    pub tilejson: String,
193    /// Terrain format identifier (e.g. `"quantized-mesh-1.0"`).
194    pub format: String,
195    /// Data version, used by Cesium for cache busting.
196    pub version: String,
197    /// Tiling scheme (`"tms"` or `"xyz"`).
198    pub scheme: String,
199    /// Tile URL templates (Cesium picks the first one).
200    pub tiles: Vec<String>,
201    /// Per-zoom-level tile availability.
202    #[serde(skip_serializing_if = "Vec::is_empty", default)]
203    pub available: Vec<Vec<TileAvailability>>,
204    /// Attribution text.
205    #[serde(skip_serializing_if = "Option::is_none", default)]
206    pub attribution: Option<String>,
207    /// Minimum zoom level.
208    #[serde(skip_serializing_if = "Option::is_none", rename = "minzoom", default)]
209    pub min_zoom: Option<u8>,
210    /// Maximum zoom level.
211    #[serde(skip_serializing_if = "Option::is_none", rename = "maxzoom", default)]
212    pub max_zoom: Option<u8>,
213    /// Geographic bounds `[west, south, east, north]`.
214    #[serde(skip_serializing_if = "Option::is_none", default)]
215    pub bounds: Option<[f64; 4]>,
216    /// Enabled extensions.
217    #[serde(skip_serializing_if = "Vec::is_empty", default)]
218    pub extensions: Vec<String>,
219    /// Metadata-availability zoom depth for the `metadata` extension.
220    #[serde(skip_serializing_if = "Option::is_none", default)]
221    pub metadata_availability: Option<u8>,
222}
223
224impl LayerJson {
225    /// Build from a [`LayerJsonConfig`].
226    pub fn from_config(config: &LayerJsonConfig) -> Self {
227        Self {
228            tilejson: "2.1.0".to_string(),
229            format: config.format.as_str().to_string(),
230            version: config.version.clone(),
231            scheme: config.scheme.as_str().to_string(),
232            tiles: vec![config.tiles_template.clone()],
233            available: config.available.clone(),
234            attribution: config.attribution.clone(),
235            min_zoom: config.min_zoom,
236            max_zoom: config.max_zoom,
237            bounds: config.bounds,
238            extensions: config.extensions.clone(),
239            metadata_availability: config.metadata_availability,
240        }
241    }
242
243    /// Convenience: serialize to a pretty-printed JSON string.
244    pub fn to_json_pretty(&self) -> serde_json::Result<String> {
245        serde_json::to_string_pretty(self)
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn full_level_geodetic_tms_z0_covers_two_x_one_y() {
255        let r = TileAvailability::full_level_geodetic_tms(0);
256        assert_eq!(r, TileAvailability::new(0, 0, 1, 0));
257    }
258
259    #[test]
260    fn from_bounds_geodetic_tms_is_ordered() {
261        let r = TileAvailability::from_bounds_geodetic_tms(4, 122.0, 20.0, 154.0, 46.0);
262        assert!(r.start_x <= r.end_x);
263        assert!(r.start_y <= r.end_y);
264    }
265
266    #[test]
267    fn layer_json_round_trips_through_serde() {
268        let cfg = LayerJsonConfig {
269            attribution: Some("Made with terrain-codec".into()),
270            available: vec![vec![TileAvailability::full_level_geodetic_tms(0)]],
271            min_zoom: Some(0),
272            max_zoom: Some(10),
273            bounds: Some([-180.0, -90.0, 180.0, 90.0]),
274            extensions: vec!["octvertexnormals".into(), "watermask".into()],
275            format: TerrainFormat::QuantizedMesh1,
276            metadata_availability: Some(10),
277            ..Default::default()
278        };
279        let lj = LayerJson::from_config(&cfg);
280        let json = serde_json::to_string(&lj).unwrap();
281        let parsed: LayerJson = serde_json::from_str(&json).unwrap();
282        assert_eq!(parsed, lj);
283        assert!(json.contains("quantized-mesh-1.0"));
284        assert!(json.contains("octvertexnormals"));
285        // The `metadata_availability` field uses camelCase.
286        assert!(json.contains("metadataAvailability"));
287    }
288
289    #[test]
290    fn empty_optionals_are_omitted() {
291        let lj = LayerJson::from_config(&LayerJsonConfig::default());
292        let json = serde_json::to_string(&lj).unwrap();
293        assert!(!json.contains("available"));
294        assert!(!json.contains("bounds"));
295        assert!(!json.contains("attribution"));
296        assert!(!json.contains("extensions"));
297        assert!(!json.contains("minzoom"));
298        assert!(!json.contains("maxzoom"));
299    }
300}