Skip to main content

ndslive_math/
tileid.rs

1// SPDX-License-Identifier: BSD-3-Clause
2//! NDS.Live Packed Tile IDs and bounding-box tile enumeration.
3//!
4//! Faithful port of `python/src/ndslive/math/tileid.py`, cross-checked against
5//! `cpp/include/ndsmath/packedtileid.h`.
6
7use crate::morton::MortonCode;
8use crate::wgs84::Wgs84;
9
10/// Error returned when constructing or validating a [`PackedTileId`].
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum TileIdError {
13    /// The level is outside the valid range 0..=15.
14    InvalidLevel(u32),
15    /// The morton number is outside the valid range for the given level.
16    InvalidMortonNumber {
17        /// The offending morton number.
18        morton: i64,
19        /// The level it was supplied for.
20        level: u32,
21        /// The maximum allowed morton number for `level`.
22        max: u64,
23    },
24    /// The packed value is below the minimum valid packed tile id (`1 << 16`).
25    ValueTooSmall(i32),
26}
27
28impl std::fmt::Display for TileIdError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            TileIdError::InvalidLevel(level) => {
32                write!(f, "Invalid level {level} (must be 0-15)")
33            }
34            TileIdError::InvalidMortonNumber { morton, level, max } => write!(
35                f,
36                "Invalid morton number {morton} for level {level} (allowed: 0-{max})"
37            ),
38            TileIdError::ValueTooSmall(value) => write!(
39                f,
40                "Invalid PackedTileId({value}): value must be >= {} or negative for level 15",
41                1u32 << 16
42            ),
43        }
44    }
45}
46
47impl std::error::Error for TileIdError {}
48
49/// An NDS.Live Packed Tile ID.
50///
51/// Per the NDS.Live standard, tile IDs are signed 32-bit integers: levels 0-14
52/// are positive, while level 15 values are negative (bit 31 is the sign bit).
53/// Internally the value is stored as a `u32` so that bit operations need not
54/// deal with the sign bit; conversion to/from signed happens only at the API
55/// boundary (see [`PackedTileId::value`] and [`PackedTileId::new`]).
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
57pub struct PackedTileId {
58    /// Internal unsigned representation.
59    value: u32,
60}
61
62impl PackedTileId {
63    /// Construct a `PackedTileId` from a signed `i32` tile ID value.
64    ///
65    /// Accepts the signed representation (negative for level 15). For a
66    /// constructor that also accepts the unsigned representation or
67    /// out-of-range values, see [`PackedTileId::from_i64`].
68    pub fn new(value: i32) -> Result<Self, TileIdError> {
69        Self::from_i64(value as i64)
70    }
71
72    /// Construct a `PackedTileId` from an `i64`, accepting both signed and
73    /// unsigned 32-bit representations (matching the Python constructor).
74    ///
75    /// Negative values are interpreted as signed `i32` and converted to the
76    /// unsigned internal representation; values `>= 2^32` are masked to 32 bits.
77    pub fn from_i64(value: i64) -> Result<Self, TileIdError> {
78        let stored: u32 = if value < 0 {
79            // Convert signed int32 to unsigned (e.g. -2147483648 -> 2147483648).
80            (value + (1i64 << 32)) as u32
81        } else if value >= (1i64 << 32) {
82            (value as u64 & 0xFFFF_FFFF) as u32
83        } else {
84            value as u32
85        };
86
87        let tile = PackedTileId { value: stored };
88        tile.validate()?;
89        Ok(tile)
90    }
91
92    /// Get the tile ID value as a signed `i32`, per the NDS.Live standard.
93    ///
94    /// Level 15 tiles return negative values; levels 0-14 return positive
95    /// values.
96    pub fn value(&self) -> i32 {
97        // A `u32 as i32` cast reinterprets the bits as two's complement: values
98        // with bit 31 set (level 15) become negative, exactly as the Python
99        // `value` property computes via `_value - (1 << 32)`.
100        self.value as i32
101    }
102
103    /// Create a `PackedTileId` directly from a tile morton number and level,
104    /// without any coordinate conversion.
105    ///
106    /// `morton_number` must be in `0..=2^(2*level+1) - 1`.
107    pub fn from_tile_index(morton_number: u32, level: u32) -> Result<Self, TileIdError> {
108        if level > 15 {
109            return Err(TileIdError::InvalidLevel(level));
110        }
111
112        let max_morton: u64 = (1u64 << (2 * level + 1)) - 1;
113        if (morton_number as u64) > max_morton {
114            return Err(TileIdError::InvalidMortonNumber {
115                morton: morton_number as i64,
116                level,
117                max: max_morton,
118            });
119        }
120
121        let value: u32 = morton_number + (1u32 << (16 + level));
122        PackedTileId::from_i64(value as i64)
123    }
124
125    /// Create a `PackedTileId` for the tile at `level` that contains the
126    /// full-precision NDS coordinates encoded in `morton_code`.
127    ///
128    /// Note: the resulting tile's [`PackedTileId::morton_number`] generally
129    /// differs from `morton_code.value()`.
130    pub fn from_morton_and_level(morton_code: MortonCode, level: u32) -> Result<Self, TileIdError> {
131        if level > 15 {
132            return Err(TileIdError::InvalidLevel(level));
133        }
134
135        let (x_coord, y_coord) = morton_code.to_nds_coordinates();
136
137        // Move into the unsigned domain (matches Python's add of 2^32 / 2^31).
138        let x_coord: u64 = if x_coord < 0 {
139            (x_coord as i64 + (1i64 << 32)) as u64
140        } else {
141            x_coord as u64
142        };
143        let y_coord: u64 = if y_coord < 0 {
144            (y_coord as i64 + (1i64 << 31)) as u64
145        } else {
146            y_coord as u64
147        };
148
149        let n_level = 31 - level;
150        let n_x = (x_coord >> n_level) as i32;
151        let n_y = (y_coord >> n_level) as i32;
152
153        let temp = MortonCode::from_nds_coordinates(n_x, n_y);
154
155        let value: u64 = temp.value() + (1u64 << (16 + level));
156        PackedTileId::from_i64(value as i64)
157    }
158
159    /// Level of the tile (0..=15).
160    pub fn level(&self) -> u32 {
161        let mut level = 0u32;
162        let mut tile_id = self.value >> 16;
163        while tile_id > 1 {
164            tile_id >>= 1;
165            level += 1;
166        }
167        level
168    }
169
170    /// Size of the tile in NDS coordinate units (`1 << (31 - level)`).
171    pub fn size(&self) -> i64 {
172        1i64 << (31 - self.level())
173    }
174
175    /// Tile dimensions in meters at the tile's center latitude.
176    ///
177    /// Returns `(width_meters, height_meters)`.
178    pub fn dimensions_in_meters(&self) -> (f64, f64) {
179        let (center_x, center_y) = self.center();
180        let center_wgs = Wgs84::from_nds_coordinates(center_x as i32, center_y as i32);
181        let tile_size = self.size() as i32;
182        Wgs84::nds_distance_to_meters(tile_size, tile_size, center_wgs.lat)
183    }
184
185    /// Center of the tile in NDS coordinates `(x, y)`.
186    pub fn center(&self) -> (i64, i64) {
187        let (x, y) = self.south_west_corner();
188        let half_size = self.size() / 2;
189        (x + half_size, y + half_size)
190    }
191
192    /// South-west corner of the tile in NDS coordinates `(x, y)`.
193    pub fn south_west_corner(&self) -> (i64, i64) {
194        let morton_number = self.morton_number() as u64;
195        let level = self.level();
196        let shift = 63 - (2 * level + 1);
197        let (x, y) = MortonCode::new(morton_number << shift).to_nds_coordinates();
198        (x as i64, y as i64)
199    }
200
201    /// North-east corner of the tile in NDS coordinates `(x, y)`.
202    ///
203    /// This boundary is **exclusive** — it is the first point outside the tile.
204    pub fn north_east_corner(&self) -> (i64, i64) {
205        let (x, y) = self.south_west_corner();
206        let size = self.size();
207        (x + size, y + size)
208    }
209
210    /// Morton number of the tile (the packed value minus the level offset).
211    pub fn morton_number(&self) -> u32 {
212        let tile_level = self.level();
213        self.value - (1u32 << (16 + tile_level))
214    }
215
216    /// Validate this tile id against the NDS constraints.
217    fn validate(&self) -> Result<(), TileIdError> {
218        let min_packed_tile_id: u32 = 1 << 16;
219        if self.value < min_packed_tile_id {
220            return Err(TileIdError::ValueTooSmall(self.value()));
221        }
222
223        let tile_level = self.level();
224        let morton = self.morton_number();
225        let max_morton: u64 = (1u64 << (2 * tile_level + 1)) - 1;
226
227        // morton_number is computed as an unsigned subtraction that cannot
228        // underflow for a value >= min_packed_tile_id, so only the upper bound
229        // can be violated.
230        if (morton as u64) > max_morton {
231            return Err(TileIdError::InvalidMortonNumber {
232                morton: morton as i64,
233                level: tile_level,
234                max: max_morton,
235            });
236        }
237
238        Ok(())
239    }
240
241    /// Extract X and Y coordinates from a tile morton number.
242    ///
243    /// X has `level + 1` bits, Y has `level` bits.
244    fn deinterleave_morton(morton: u32, level: u32) -> (u32, u32) {
245        let mut x = 0u32;
246        let mut y = 0u32;
247        for i in 0..level {
248            if morton & (1u32 << (2 * i)) != 0 {
249                x |= 1u32 << i;
250            }
251            if morton & (1u32 << (2 * i + 1)) != 0 {
252                y |= 1u32 << i;
253            }
254        }
255        if morton & (1u32 << (2 * level)) != 0 {
256            x |= 1u32 << level;
257        }
258        (x, y)
259    }
260
261    /// Create a tile morton number from X and Y coordinates.
262    ///
263    /// X has `level + 1` bits, Y has `level` bits.
264    fn interleave_coords(x: u32, y: u32, level: u32) -> u32 {
265        let mut morton = 0u32;
266        for i in 0..level {
267            if x & (1u32 << i) != 0 {
268                morton |= 1u32 << (2 * i);
269            }
270            if y & (1u32 << i) != 0 {
271                morton |= 1u32 << (2 * i + 1);
272            }
273        }
274        if x & (1u32 << level) != 0 {
275            morton |= 1u32 << (2 * level);
276        }
277        morton
278    }
279
280    /// Tile to the west at the same level (wraps at the antimeridian).
281    pub fn west_neighbour(&self) -> PackedTileId {
282        let level = self.level();
283        let morton = self.morton_number();
284        let (mut x, y) = Self::deinterleave_morton(morton, level);
285        let max_x = (1u32 << (level + 1)) - 1;
286        x = x.wrapping_sub(1) & max_x;
287        let new_morton = Self::interleave_coords(x, y, level);
288        PackedTileId::from_tile_index(new_morton, level).expect("valid neighbour")
289    }
290
291    /// Tile to the east at the same level (wraps at the antimeridian).
292    pub fn east_neighbour(&self) -> PackedTileId {
293        let level = self.level();
294        let morton = self.morton_number();
295        let (mut x, y) = Self::deinterleave_morton(morton, level);
296        let max_x = (1u32 << (level + 1)) - 1;
297        x = x.wrapping_add(1) & max_x;
298        let new_morton = Self::interleave_coords(x, y, level);
299        PackedTileId::from_tile_index(new_morton, level).expect("valid neighbour")
300    }
301
302    /// Tile to the south at the same level (wraps at the south pole).
303    pub fn south_neighbour(&self) -> PackedTileId {
304        let level = self.level();
305        let morton = self.morton_number();
306        let (x, mut y) = Self::deinterleave_morton(morton, level);
307        let max_y = (1u32 << level) - 1;
308        y = y.wrapping_sub(1) & max_y;
309        let new_morton = Self::interleave_coords(x, y, level);
310        PackedTileId::from_tile_index(new_morton, level).expect("valid neighbour")
311    }
312
313    /// Tile to the north at the same level (wraps at the north pole).
314    pub fn north_neighbour(&self) -> PackedTileId {
315        let level = self.level();
316        let morton = self.morton_number();
317        let (x, mut y) = Self::deinterleave_morton(morton, level);
318        let max_y = (1u32 << level) - 1;
319        y = y.wrapping_add(1) & max_y;
320        let new_morton = Self::interleave_coords(x, y, level);
321        PackedTileId::from_tile_index(new_morton, level).expect("valid neighbour")
322    }
323}
324
325impl std::fmt::Display for PackedTileId {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        write!(f, "PackedTileId(value={})", self.value())
328    }
329}
330
331/// Floor division for `i64` (matches Python's `//`).
332///
333/// Rust's `/` truncates toward zero, so `div_euclid` differs from Python `//`
334/// for mixed-sign operands. For a positive divisor (always the case here, since
335/// `tile_size > 0`), `div_euclid` equals floor division.
336#[inline]
337fn floor_div(a: i64, b: i64) -> i64 {
338    a.div_euclid(b)
339}
340
341/// Get all tile IDs that intersect a bounding box given by NDS coordinates.
342///
343/// `(sw_x, sw_y)` is the south-west corner and `(ne_x, ne_y)` the north-east
344/// corner, both inclusive. Faithful port of the Python
345/// `get_tile_ids_for_bounding_box`.
346pub fn get_tile_ids_for_bounding_box(
347    sw_x: i64,
348    sw_y: i64,
349    ne_x: i64,
350    ne_y: i64,
351    level: u32,
352) -> Vec<PackedTileId> {
353    let mut tile_ids = Vec::new();
354
355    let tile_size: i64 = 1 << (31 - level);
356
357    // Floor division so negative coordinates map to the correct tile, matching
358    // Python's `//` (Rust `/` would truncate toward zero).
359    let start_tile_x = floor_div(sw_x, tile_size);
360    let start_tile_y = floor_div(sw_y, tile_size);
361    let end_tile_x = floor_div(ne_x, tile_size);
362    let end_tile_y = floor_div(ne_y, tile_size);
363
364    let mut tile_y = start_tile_y;
365    while tile_y <= end_tile_y {
366        let mut tile_x = start_tile_x;
367        while tile_x <= end_tile_x {
368            let tile_sw_x = tile_x * tile_size;
369            let tile_sw_y = tile_y * tile_size;
370
371            // from_nds_coordinates / morton encoding operate on i32 with
372            // wrapping, matching the Python reference's wrapping reduction.
373            let morton = MortonCode::from_nds_coordinates(tile_sw_x as i32, tile_sw_y as i32);
374            let tile_id = PackedTileId::from_morton_and_level(morton, level)
375                .expect("tile within bounding box must be valid");
376            tile_ids.push(tile_id);
377
378            tile_x += 1;
379        }
380        tile_y += 1;
381    }
382
383    tile_ids
384}
385
386/// Create a tight bounding box (in NDS coordinates) covering all the tiles.
387///
388/// Returns `(min_x, min_y, max_x, max_y)` where `max_x`/`max_y` are the **last
389/// inclusive** points inside the tiles — i.e. the exclusive NE corner minus 1,
390/// matching the Python `bounding_box_from_tile_ids`.
391///
392/// Returns `None` if `tiles` is empty.
393pub fn bounding_box_from_tile_ids(tiles: &[PackedTileId]) -> Option<(i64, i64, i64, i64)> {
394    let (first, rest) = tiles.split_first()?;
395
396    let (mut min_x, mut min_y) = first.south_west_corner();
397    let (mut max_x, mut max_y) = first.north_east_corner();
398
399    for tile in rest {
400        let (sw_x, sw_y) = tile.south_west_corner();
401        let (ne_x, ne_y) = tile.north_east_corner();
402        min_x = min_x.min(sw_x);
403        min_y = min_y.min(sw_y);
404        max_x = max_x.max(ne_x);
405        max_y = max_y.max(ne_y);
406    }
407
408    // NE corner is exclusive; subtract 1 to make it the last inclusive point.
409    Some((min_x, min_y, max_x - 1, max_y - 1))
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn level_15_is_negative() {
418        let t = PackedTileId::from_tile_index(0, 15).unwrap();
419        assert_eq!(t.value(), -2147483648);
420        assert_eq!(t.level(), 15);
421        assert_eq!(t.morton_number(), 0);
422    }
423
424    #[test]
425    fn signed_unsigned_constructors_agree() {
426        let a = PackedTileId::from_i64(-2147483648).unwrap();
427        let b = PackedTileId::from_i64(2147483648).unwrap();
428        assert_eq!(a, b);
429        assert_eq!(a.value(), -2147483648);
430    }
431
432    #[test]
433    fn max_level15_tile() {
434        let t = PackedTileId::from_tile_index((1 << 31) - 1, 15).unwrap();
435        assert_eq!(t.value(), -1);
436    }
437
438    #[test]
439    fn level14_positive() {
440        let t = PackedTileId::from_tile_index(0, 14).unwrap();
441        assert_eq!(t.value(), 1073741824);
442    }
443
444    #[test]
445    fn invalid_level() {
446        assert!(matches!(
447            PackedTileId::from_tile_index(0, 16),
448            Err(TileIdError::InvalidLevel(16))
449        ));
450    }
451
452    #[test]
453    fn invalid_morton() {
454        // level 0 allows morton 0..=1.
455        assert!(PackedTileId::from_tile_index(2, 0).is_err());
456    }
457
458    #[test]
459    fn corners_and_center() {
460        let t = PackedTileId::from_tile_index(0, 0).unwrap();
461        assert_eq!(t.size(), 2147483648);
462        assert_eq!(t.south_west_corner(), (0, 0));
463        assert_eq!(t.north_east_corner(), (2147483648, 2147483648));
464        assert_eq!(t.center(), (1073741824, 1073741824));
465    }
466
467    #[test]
468    fn bbox_floor_div_negative() {
469        // sw at a negative coordinate must use floor division.
470        let tiles = get_tile_ids_for_bounding_box(-1, -1, 0, 0, 1);
471        assert!(!tiles.is_empty());
472    }
473
474    #[test]
475    fn bbox_from_single_tile() {
476        let t = PackedTileId::from_i64(131072).unwrap();
477        let bbox = bounding_box_from_tile_ids(&[t]).unwrap();
478        assert_eq!(bbox, (0, 0, 1073741823, 1073741823));
479    }
480
481    #[test]
482    fn empty_bbox_returns_none() {
483        assert!(bounding_box_from_tile_ids(&[]).is_none());
484    }
485}