Skip to main content

spatialrust_interchange/tiles3d/
tileset.rs

1//! OGC 3D Tiles 1.1 `tileset.json` model, validation, and codec.
2
3use crate::json::{parse_json, serialize_json, Json};
4use crate::{InterchangeError, InterchangeResult};
5
6/// Supported refinement policy for a tile.
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum Refinement {
9    /// Child tiles are rendered in addition to the parent.
10    Add,
11    /// Child tiles replace the parent when refined.
12    Replace,
13}
14
15impl Refinement {
16    fn as_str(self) -> &'static str {
17        match self {
18            Refinement::Add => "ADD",
19            Refinement::Replace => "REPLACE",
20        }
21    }
22
23    fn from_str(value: &str) -> Option<Self> {
24        match value {
25            "ADD" => Some(Refinement::Add),
26            "REPLACE" => Some(Refinement::Replace),
27            _ => None,
28        }
29    }
30}
31
32/// Bounding volume for a tile.
33#[derive(Clone, Debug, PartialEq)]
34pub enum BoundingVolume {
35    /// Right-handed axis-aligned box: `[center(3), x-half-axis(3), y-half-axis(3), z-half-axis(3)]`.
36    Box([f64; 12]),
37}
38
39impl BoundingVolume {
40    /// Creates an axis-aligned box from ordered min/max corners.
41    pub fn box_from_bounds(min: [f64; 3], max: [f64; 3]) -> InterchangeResult<Self> {
42        for axis in 0..3 {
43            if !min[axis].is_finite() || !max[axis].is_finite() || min[axis] > max[axis] {
44                return Err(InterchangeError::InvalidConfiguration(
45                    "tile box bounds must be finite and ordered".into(),
46                ));
47            }
48        }
49        let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5, (min[2] + max[2]) * 0.5];
50        let half = [(max[0] - min[0]) * 0.5, (max[1] - min[1]) * 0.5, (max[2] - min[2]) * 0.5];
51        let mut box_value = [0.0f64; 12];
52        box_value[0..3].copy_from_slice(&center);
53        box_value[3] = half[0];
54        box_value[7] = half[1];
55        box_value[11] = half[2];
56        Ok(BoundingVolume::Box(box_value))
57    }
58
59    fn to_json(&self) -> Json {
60        match self {
61            BoundingVolume::Box(value) => Json::object(vec![(
62                "box",
63                Json::Array(value.iter().map(|v| Json::Number(v.to_string())).collect()),
64            )]),
65        }
66    }
67
68    fn from_json(json: &Json) -> InterchangeResult<Self> {
69        let box_values = json
70            .get("box")
71            .and_then(Json::as_array)
72            .ok_or_else(|| InterchangeError::InvalidConfiguration("missing tile box".into()))?;
73        if box_values.len() != 12 {
74            return Err(InterchangeError::InvalidConfiguration(
75                "tile box must contain twelve values".into(),
76            ));
77        }
78        let mut value = [0.0f64; 12];
79        for (index, item) in box_values.iter().enumerate() {
80            value[index] = item.as_f64().ok_or_else(|| {
81                InterchangeError::InvalidConfiguration("tile box value is not numeric".into())
82            })?;
83            if !value[index].is_finite() {
84                return Err(InterchangeError::InvalidConfiguration(
85                    "tile box must contain finite values".into(),
86                ));
87            }
88        }
89        Ok(BoundingVolume::Box(value))
90    }
91}
92
93/// Content reference for a tile.
94#[derive(Clone, Debug, PartialEq)]
95pub struct TileContent {
96    /// Content URI relative to the tileset document.
97    pub uri: String,
98}
99
100/// One tile in a 3D Tiles hierarchy.
101#[derive(Clone, Debug, PartialEq)]
102pub struct Tile {
103    /// Bounding volume for this tile.
104    pub bounding_volume: BoundingVolume,
105    /// Geometric error in world units; leaves should be `0.0`.
106    pub geometric_error: f64,
107    /// Optional refinement policy; absent inherits the parent policy.
108    pub refine: Option<Refinement>,
109    /// Optional content reference.
110    pub content: Option<TileContent>,
111    /// Child tiles in deterministic order.
112    pub children: Vec<Tile>,
113}
114
115impl Tile {
116    fn validate(&self) -> InterchangeResult<()> {
117        if !self.geometric_error.is_finite() || self.geometric_error < 0.0 {
118            return Err(InterchangeError::InvalidConfiguration(
119                "tile geometric error must be finite and non-negative".into(),
120            ));
121        }
122        let child_ids: Vec<_> = self.children.iter().map(|child| child.hash_key()).collect();
123        for index in 0..child_ids.len() {
124            if child_ids.iter().skip(index + 1).any(|id| *id == child_ids[index]) {
125                return Err(InterchangeError::InvalidConfiguration(
126                    "tileset contains duplicate child tiles".into(),
127                ));
128            }
129        }
130        for child in &self.children {
131            child.validate()?;
132        }
133        Ok(())
134    }
135
136    fn hash_key(&self) -> String {
137        let mut hasher = std::collections::hash_map::DefaultHasher::new();
138        use std::hash::{Hash, Hasher};
139        serialize_json(&self.bounding_volume.to_json()).hash(&mut hasher);
140        hasher.finish().to_string()
141    }
142
143    fn to_json(&self) -> Json {
144        let mut members = vec![
145            ("boundingVolume", self.bounding_volume.to_json()),
146            ("geometricError", Json::Number(self.geometric_error.to_string())),
147        ];
148        if let Some(refine) = self.refine {
149            members.push(("refine", Json::String(refine.as_str().to_owned())));
150        }
151        if let Some(content) = &self.content {
152            members
153                .push(("content", Json::object(vec![("uri", Json::String(content.uri.clone()))])));
154        }
155        if !self.children.is_empty() {
156            members
157                .push(("children", Json::Array(self.children.iter().map(Tile::to_json).collect())));
158        }
159        Json::object(members)
160    }
161
162    fn from_json(json: &Json, depth: usize) -> InterchangeResult<Self> {
163        if depth > 64 {
164            return Err(InterchangeError::InvalidConfiguration(
165                "tileset nesting exceeds 64 levels".into(),
166            ));
167        }
168        let bounding_volume =
169            json.get("boundingVolume").map(BoundingVolume::from_json).transpose()?.ok_or_else(
170                || InterchangeError::InvalidConfiguration("tile missing boundingVolume".into()),
171            )?;
172        let geometric_error =
173            json.get("geometricError").and_then(Json::as_f64).ok_or_else(|| {
174                InterchangeError::InvalidConfiguration("missing geometricError".into())
175            })?;
176        let refine = json.get("refine").and_then(Json::as_str).and_then(Refinement::from_str);
177        let content = match json.get("content") {
178            None => None,
179            Some(content) => Some(TileContent {
180                uri: content
181                    .get("uri")
182                    .and_then(Json::as_str)
183                    .ok_or_else(|| {
184                        InterchangeError::InvalidConfiguration("content missing uri".into())
185                    })?
186                    .to_owned(),
187            }),
188        };
189        let children = match json.get("children") {
190            None => Vec::new(),
191            Some(Json::Array(children)) => children
192                .iter()
193                .map(|child| Tile::from_json(child, depth + 1))
194                .collect::<InterchangeResult<Vec<_>>>()?,
195            Some(_) => {
196                return Err(InterchangeError::InvalidConfiguration(
197                    "tile children must be an array".into(),
198                ));
199            }
200        };
201        let tile = Tile { bounding_volume, geometric_error, refine, content, children };
202        tile.validate()?;
203        Ok(tile)
204    }
205}
206
207/// Root document of a 3D Tiles tileset.
208#[derive(Clone, Debug, PartialEq)]
209pub struct Tileset {
210    /// Tileset-level geometric error.
211    pub geometric_error: f64,
212    /// Root tile.
213    pub root: Tile,
214}
215
216impl Tileset {
217    /// Serializes the tileset to a 3D Tiles 1.1 `tileset.json` document.
218    #[must_use]
219    pub fn to_json(&self) -> String {
220        let document = Json::object(vec![
221            ("asset", Json::object(vec![("version", Json::String("1.1".into()))])),
222            ("geometricError", Json::Number(self.geometric_error.to_string())),
223            ("root", self.root.to_json()),
224        ]);
225        serialize_json(&document)
226    }
227}
228
229/// Serializes a tileset to a 3D Tiles 1.1 `tileset.json` string.
230pub fn serialize_tileset_json(tileset: &Tileset) -> InterchangeResult<String> {
231    validate_tileset(tileset)?;
232    Ok(tileset.to_json())
233}
234
235/// Parses and validates a 3D Tiles 1.1 `tileset.json` string.
236pub fn parse_tileset_json(document: &str) -> InterchangeResult<Tileset> {
237    let json = parse_json(document)?;
238    let asset = json
239        .get("asset")
240        .ok_or_else(|| InterchangeError::InvalidConfiguration("tileset missing asset".into()))?;
241    let version = asset.get("version").and_then(Json::as_str).ok_or_else(|| {
242        InterchangeError::InvalidConfiguration("tileset asset missing version".into())
243    })?;
244    if version != "1.1" && version != "1.0" {
245        return Err(InterchangeError::InvalidConfiguration(format!(
246            "unsupported tileset version {version}"
247        )));
248    }
249    let geometric_error = json.get("geometricError").and_then(Json::as_f64).ok_or_else(|| {
250        InterchangeError::InvalidConfiguration("tileset missing geometricError".into())
251    })?;
252    let root = Tile::from_json(
253        json.get("root")
254            .ok_or_else(|| InterchangeError::InvalidConfiguration("tileset missing root".into()))?,
255        0,
256    )?;
257    let tileset = Tileset { geometric_error, root };
258    validate_tileset(&tileset)?;
259    Ok(tileset)
260}
261
262fn validate_tileset(tileset: &Tileset) -> InterchangeResult<()> {
263    if !tileset.geometric_error.is_finite() || tileset.geometric_error < 0.0 {
264        return Err(InterchangeError::InvalidConfiguration(
265            "tileset geometric error must be finite and non-negative".into(),
266        ));
267    }
268    tileset.root.validate()?;
269    Ok(())
270}
271
272#[cfg(test)]
273mod tests {
274    use super::{
275        parse_tileset_json, serialize_tileset_json, BoundingVolume, Refinement, Tile, TileContent,
276        Tileset,
277    };
278
279    fn sample_tileset() -> Tileset {
280        let child = Tile {
281            bounding_volume: BoundingVolume::box_from_bounds([0.0, 0.0, 0.0], [1.0, 1.0, 1.0])
282                .unwrap(),
283            geometric_error: 0.0,
284            refine: None,
285            content: Some(TileContent { uri: "1.pnts".into() }),
286            children: Vec::new(),
287        };
288        Tileset {
289            geometric_error: 10.0,
290            root: Tile {
291                bounding_volume: BoundingVolume::box_from_bounds([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])
292                    .unwrap(),
293                geometric_error: 10.0,
294                refine: Some(Refinement::Replace),
295                content: Some(TileContent { uri: "0.pnts".into() }),
296                children: vec![child],
297            },
298        }
299    }
300
301    #[test]
302    fn round_trips_hierarchy() {
303        let tileset = sample_tileset();
304        let document = serialize_tileset_json(&tileset).unwrap();
305        let parsed = parse_tileset_json(&document).unwrap();
306        assert_eq!(parsed, tileset);
307    }
308
309    #[test]
310    fn rejects_negative_geometric_error() {
311        let mut tileset = sample_tileset();
312        tileset.root.geometric_error = -1.0;
313        assert!(serialize_tileset_json(&tileset).is_err());
314    }
315
316    #[test]
317    fn rejects_unknown_refine() {
318        let document = r#"{"asset":{"version":"1.1"},"geometricError":1,"root":{"boundingVolume":{"box":[0,0,0,1,0,0,0,1,0,0,0,1]},"geometricError":0,"refine":"SWAP"}}"#;
319        let parsed = parse_tileset_json(document).unwrap();
320        assert_eq!(parsed.root.refine, None);
321    }
322
323    #[test]
324    fn rejects_deep_nesting() {
325        let mut document = String::from(
326            r#"{"asset":{"version":"1.1"},"geometricError":1,"root":{"boundingVolume":{"box":[0,0,0,1,0,0,0,1,0,0,0,1]},"geometricError":0,"children":["#,
327        );
328        for _ in 0..80 {
329            document.push_str(r#"{"boundingVolume":{"box":[0,0,0,1,0,0,0,1,0,0,0,1]},"geometricError":0,"children":["#);
330        }
331        for _ in 0..80 {
332            document.push_str("]}");
333        }
334        document.push_str("]}}");
335        assert!(parse_tileset_json(&document).is_err());
336    }
337}