Skip to main content

stt_core/
tile.rs

1//! Tile types and operations
2
3use crate::error::{Error, Result};
4use crate::types::{GeometryType, TimeRange};
5use std::cmp::Ordering;
6
7/// Unique identifier for a spatiotemporal tile
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub struct TileId {
10    /// Zoom level (0-22)
11    pub z: u8,
12    /// X coordinate
13    pub x: u32,
14    /// Y coordinate
15    pub y: u32,
16    /// Timestamp (Unix milliseconds)
17    pub t: u64,
18}
19
20impl TileId {
21    /// Create a new tile ID
22    pub fn new(z: u8, x: u32, y: u32, t: u64) -> Self {
23        Self { z, x, y, t }
24    }
25
26    /// Validate tile coordinates
27    pub fn validate(&self) -> Result<()> {
28        let max_coord = 1u32 << self.z;
29        if self.x >= max_coord || self.y >= max_coord {
30            return Err(Error::InvalidCoordinates(self.z, self.x, self.y));
31        }
32        Ok(())
33    }
34
35    /// Calculate Hilbert curve index for spatial ordering.
36    ///
37    /// Uses the integer (`discrete`) Hilbert curve at the tile's own zoom
38    /// order, so neighbouring tiles never collide on the directory sort key.
39    ///
40    /// The previous implementation normalised `(x, y)` to f64 `[0,1)` and
41    /// multiplied by `u64::MAX`. f64 has only a 52-bit mantissa, so at
42    /// zoom ≥ 14 the per-axis precision was already at the mantissa limit and
43    /// the post-multiply discarded trailing bits — neighbouring tiles could
44    /// collide on the same `hilbert_index`, silently degrading the archive's
45    /// range-coalescing locality. The integer variant is also ~10× faster.
46    pub fn hilbert_index(&self) -> u64 {
47        // `xy2h_discrete` requires `order >= 1`. At zoom 0 the only valid
48        // coordinates are (0, 0) and the index is trivially 0.
49        if self.z == 0 {
50            return 0;
51        }
52        let order = self.z as usize;
53        let h = hilbert_2d::xy2h_discrete(
54            self.x as usize,
55            self.y as usize,
56            order,
57            hilbert_2d::Variant::Hilbert,
58        );
59        h as u64
60    }
61
62    /// Get parent tile at zoom level z-1
63    pub fn parent(&self) -> Option<TileId> {
64        if self.z == 0 {
65            return None;
66        }
67        Some(TileId {
68            z: self.z - 1,
69            x: self.x / 2,
70            y: self.y / 2,
71            t: self.t,
72        })
73    }
74
75    /// Get child tiles at zoom level z+1
76    pub fn children(&self) -> Vec<TileId> {
77        if self.z >= 22 {
78            return vec![];
79        }
80        vec![
81            TileId::new(self.z + 1, self.x * 2, self.y * 2, self.t),
82            TileId::new(self.z + 1, self.x * 2 + 1, self.y * 2, self.t),
83            TileId::new(self.z + 1, self.x * 2, self.y * 2 + 1, self.t),
84            TileId::new(self.z + 1, self.x * 2 + 1, self.y * 2 + 1, self.t),
85        ]
86    }
87
88    /// Convert to string format: z/x/y/t
89    pub fn to_string(&self) -> String {
90        format!("{}/{}/{}/{}", self.z, self.x, self.y, self.t)
91    }
92}
93
94impl PartialOrd for TileId {
95    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
96        Some(self.cmp(other))
97    }
98}
99
100impl Ord for TileId {
101    fn cmp(&self, other: &Self) -> Ordering {
102        // Sort by: zoom, hilbert index, time
103        self.z
104            .cmp(&other.z)
105            .then(self.hilbert_index().cmp(&other.hilbert_index()))
106            .then(self.t.cmp(&other.t))
107    }
108}
109
110/// A decoded tile with all its features
111#[derive(Debug, Clone)]
112pub struct Tile {
113    pub id: TileId,
114    pub time_range: TimeRange,
115    pub layers: Vec<Layer>,
116}
117
118/// A layer within a tile
119#[derive(Debug, Clone)]
120pub struct Layer {
121    pub name: String,
122    pub extent: u32,
123    pub features: Vec<Feature>,
124}
125
126/// A feature within a layer
127#[derive(Debug, Clone)]
128pub struct Feature {
129    pub id: u64,
130    pub geometry_type: GeometryType,
131    pub positions: Vec<Position>,
132    pub properties: std::collections::HashMap<String, Value>,
133    pub time_range: Option<TimeRange>,
134}
135
136/// Absolute geographic position
137#[derive(Debug, Clone, Copy, PartialEq)]
138pub struct Position {
139    pub lon: f64,
140    pub lat: f64,
141}
142
143/// Property value
144#[derive(Debug, Clone)]
145pub enum Value {
146    String(String),
147    Double(f64),
148    Float(f32),
149    Int(i64),
150    UInt(u64),
151    Bool(bool),
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_tile_id_validation() {
160        let valid = TileId::new(10, 512, 384, 1609459200000);
161        assert!(valid.validate().is_ok());
162
163        let invalid = TileId::new(10, 2048, 384, 1609459200000);
164        assert!(invalid.validate().is_err());
165    }
166
167    #[test]
168    fn test_tile_id_parent() {
169        let tile = TileId::new(10, 512, 384, 1609459200000);
170        let parent = tile.parent().unwrap();
171        assert_eq!(parent.z, 9);
172        assert_eq!(parent.x, 256);
173        assert_eq!(parent.y, 192);
174    }
175
176    #[test]
177    fn test_tile_id_children() {
178        let tile = TileId::new(10, 512, 384, 1609459200000);
179        let children = tile.children();
180        assert_eq!(children.len(), 4);
181        assert_eq!(children[0].z, 11);
182    }
183
184    #[test]
185    fn test_tile_id_ordering() {
186        let t1 = TileId::new(10, 512, 384, 1609459200000);
187        let t2 = TileId::new(10, 512, 384, 1609545600000);
188        let t3 = TileId::new(11, 1024, 768, 1609459200000);
189
190        assert!(t1 < t2); // Same spatial, different time
191        assert!(t1 < t3); // Different zoom
192    }
193
194    /// Regression: at zoom ≥ 14 the previous f64-normalised Hilbert collapsed
195    /// neighbouring tiles to the same index, breaking range coalescing. The
196    /// integer variant must produce distinct indices for every distinct tile
197    /// at every supported zoom.
198    #[test]
199    fn hilbert_index_is_distinct_for_distinct_tiles_at_high_zoom() {
200        for &z in &[14u8, 16, 18, 20, 22] {
201            let n = 1u32 << z;
202            // Sample 4 adjacent tiles near the centre.
203            let cx = n / 2;
204            let cy = n / 2;
205            let coords = [(cx, cy), (cx + 1, cy), (cx, cy + 1), (cx + 1, cy + 1)];
206            let indices: std::collections::HashSet<u64> = coords
207                .iter()
208                .map(|&(x, y)| TileId::new(z, x, y, 0).hilbert_index())
209                .collect();
210            assert_eq!(
211                indices.len(),
212                4,
213                "hilbert_index collided at zoom {} for adjacent tiles",
214                z
215            );
216        }
217    }
218
219    /// Zoom 0 is a single tile — Hilbert index is trivially 0 and the function
220    /// must not call into the discrete impl (which rejects order = 0).
221    #[test]
222    fn hilbert_index_zoom_zero_is_zero() {
223        assert_eq!(TileId::new(0, 0, 0, 0).hilbert_index(), 0);
224    }
225}