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 the signed NDS.Live public value.
73    pub fn from_value(value: i32) -> Result<Self, TileIdError> {
74        Self::new(value)
75    }
76
77    /// Construct a `PackedTileId` from an `i64`, accepting both signed and
78    /// unsigned 32-bit representations (matching the Python constructor).
79    ///
80    /// Negative values are interpreted as signed `i32` and converted to the
81    /// unsigned internal representation; values `>= 2^32` are masked to 32 bits.
82    pub fn from_i64(value: i64) -> Result<Self, TileIdError> {
83        let stored: u32 = if value < 0 {
84            // Convert signed int32 to unsigned (e.g. -2147483648 -> 2147483648).
85            (value + (1i64 << 32)) as u32
86        } else if value >= (1i64 << 32) {
87            (value as u64 & 0xFFFF_FFFF) as u32
88        } else {
89            value as u32
90        };
91
92        let tile = PackedTileId { value: stored };
93        tile.validate()?;
94        Ok(tile)
95    }
96
97    /// Get the tile ID value as a signed `i32`, per the NDS.Live standard.
98    ///
99    /// Level 15 tiles return negative values; levels 0-14 return positive
100    /// values.
101    pub fn value(&self) -> i32 {
102        // A `u32 as i32` cast reinterprets the bits as two's complement: values
103        // with bit 31 set (level 15) become negative, exactly as the Python
104        // `value` property computes via `_value - (1 << 32)`.
105        self.value as i32
106    }
107
108    /// Create a `PackedTileId` directly from a tile morton number and level,
109    /// without any coordinate conversion.
110    ///
111    /// `morton_number` must be in `0..=2^(2*level+1) - 1`.
112    pub fn from_tile_index(morton_number: u32, level: u32) -> Result<Self, TileIdError> {
113        if level > 15 {
114            return Err(TileIdError::InvalidLevel(level));
115        }
116
117        let max_morton: u64 = (1u64 << (2 * level + 1)) - 1;
118        if (morton_number as u64) > max_morton {
119            return Err(TileIdError::InvalidMortonNumber {
120                morton: morton_number as i64,
121                level,
122                max: max_morton,
123            });
124        }
125
126        let value: u32 = morton_number + (1u32 << (16 + level));
127        PackedTileId::from_i64(value as i64)
128    }
129
130    /// Create a `PackedTileId` from tile-grid coordinates at the given level.
131    ///
132    /// X is in `0..=2^(level+1)-1`, Y is in `0..=2^level-1`. Coordinates use
133    /// the NDS Morton tile-grid order and are inverse to [`PackedTileId::x`]
134    /// and [`PackedTileId::y`].
135    pub fn from_tile_xy(x: u32, y: u32, level: u32) -> Result<Self, TileIdError> {
136        if level > 15 {
137            return Err(TileIdError::InvalidLevel(level));
138        }
139        let max_x = (1u32 << (level + 1)) - 1;
140        let max_y = (1u32 << level) - 1;
141        if x > max_x {
142            return Err(TileIdError::InvalidMortonNumber {
143                morton: x as i64,
144                level,
145                max: max_x as u64,
146            });
147        }
148        if y > max_y {
149            return Err(TileIdError::InvalidMortonNumber {
150                morton: y as i64,
151                level,
152                max: max_y as u64,
153            });
154        }
155        Self::from_tile_index(Self::interleave_coords(x, y, level), level)
156    }
157
158    /// Create the tile at `level` that contains the given NDS coordinate.
159    pub fn from_nds_coordinates(x: i32, y: i32, level: u32) -> Result<Self, TileIdError> {
160        Self::from_morton_and_level(MortonCode::from_nds_coordinates(x, y), level)
161    }
162
163    /// Create the tile at `level` that contains the given WGS84 coordinate.
164    pub fn from_wgs84(longitude: f64, latitude: f64, level: u32) -> Result<Self, TileIdError> {
165        let (x, y) = Wgs84::new(longitude, latitude).to_nds_coordinates();
166        Self::from_nds_coordinates(x, y, level)
167    }
168
169    /// Create a `PackedTileId` for the tile at `level` that contains the
170    /// full-precision NDS coordinates encoded in `morton_code`.
171    ///
172    /// Note: the resulting tile's [`PackedTileId::morton_number`] generally
173    /// differs from `morton_code.value()`.
174    pub fn from_morton_and_level(morton_code: MortonCode, level: u32) -> Result<Self, TileIdError> {
175        if level > 15 {
176            return Err(TileIdError::InvalidLevel(level));
177        }
178
179        let (x_coord, y_coord) = morton_code.to_nds_coordinates();
180
181        // Move into the unsigned domain (matches Python's add of 2^32 / 2^31).
182        let x_coord: u64 = if x_coord < 0 {
183            (x_coord as i64 + (1i64 << 32)) as u64
184        } else {
185            x_coord as u64
186        };
187        let y_coord: u64 = if y_coord < 0 {
188            (y_coord as i64 + (1i64 << 31)) as u64
189        } else {
190            y_coord as u64
191        };
192
193        let n_level = 31 - level;
194        let n_x = (x_coord >> n_level) as i32;
195        let n_y = (y_coord >> n_level) as i32;
196
197        let temp = MortonCode::from_nds_coordinates(n_x, n_y);
198
199        let value: u64 = temp.value() + (1u64 << (16 + level));
200        PackedTileId::from_i64(value as i64)
201    }
202
203    /// Level of the tile (0..=15).
204    pub fn level(&self) -> u32 {
205        let mut level = 0u32;
206        let mut tile_id = self.value >> 16;
207        while tile_id > 1 {
208            tile_id >>= 1;
209            level += 1;
210        }
211        level
212    }
213
214    /// Size of the tile in NDS coordinate units (`1 << (31 - level)`).
215    pub fn size(&self) -> i64 {
216        1i64 << (31 - self.level())
217    }
218
219    /// Tile dimensions in meters at the tile's center latitude.
220    ///
221    /// Returns `(width_meters, height_meters)`.
222    pub fn dimensions_in_meters(&self) -> (f64, f64) {
223        let (center_x, center_y) = self.center();
224        let center_wgs = Wgs84::from_nds_coordinates(center_x as i32, center_y as i32);
225        let tile_size = self.size() as i32;
226        Wgs84::nds_distance_to_meters(tile_size, tile_size, center_wgs.lat)
227    }
228
229    /// Center of the tile in NDS coordinates `(x, y)`.
230    pub fn center(&self) -> (i64, i64) {
231        let (x, y) = self.south_west_corner();
232        let half_size = self.size() / 2;
233        (x + half_size, y + half_size)
234    }
235
236    /// Convert NDS integer coordinates to lon/lat degrees without edge
237    /// normalization.
238    pub fn wgs84_from_nds_coordinates(x: i64, y: i64) -> (f64, f64) {
239        (
240            (x as f64) * 360.0 / 4294967296.0,
241            (y as f64) * 180.0 / 2147483648.0,
242        )
243    }
244
245    /// Center of the tile in lon/lat degrees.
246    pub fn center_wgs84(&self) -> (f64, f64) {
247        let (x, y) = self.center();
248        Self::wgs84_from_nds_coordinates(x, y)
249    }
250
251    /// South-west tile corner in lon/lat degrees.
252    pub fn south_west_wgs84(&self) -> (f64, f64) {
253        let (x, y) = self.south_west_corner();
254        Self::wgs84_from_nds_coordinates(x, y)
255    }
256
257    /// Exclusive north-east tile corner in lon/lat degrees.
258    pub fn north_east_wgs84(&self) -> (f64, f64) {
259        let (x, y) = self.north_east_corner();
260        Self::wgs84_from_nds_coordinates(x, y)
261    }
262
263    /// Tile width/height in lon/lat degrees.
264    pub fn wgs84_size(&self) -> (f64, f64) {
265        let tile_size = self.size() as f64;
266        (
267            tile_size * 360.0 / 4294967296.0,
268            tile_size * 180.0 / 2147483648.0,
269        )
270    }
271
272    /// South-west corner of the tile in NDS coordinates `(x, y)`.
273    pub fn south_west_corner(&self) -> (i64, i64) {
274        let morton_number = self.morton_number() as u64;
275        let level = self.level();
276        let shift = 63 - (2 * level + 1);
277        let (x, y) = MortonCode::new(morton_number << shift).to_nds_coordinates();
278        (x as i64, y as i64)
279    }
280
281    /// North-east corner of the tile in NDS coordinates `(x, y)`.
282    ///
283    /// This boundary is **exclusive** — it is the first point outside the tile.
284    pub fn north_east_corner(&self) -> (i64, i64) {
285        let (x, y) = self.south_west_corner();
286        let size = self.size();
287        (x + size, y + size)
288    }
289
290    /// Morton number of the tile (the packed value minus the level offset).
291    pub fn morton_number(&self) -> u32 {
292        let tile_level = self.level();
293        self.value - (1u32 << (16 + tile_level))
294    }
295
296    /// Tile-grid X coordinate at this tile's level.
297    pub fn x(&self) -> u32 {
298        Self::deinterleave_morton(self.morton_number(), self.level()).0
299    }
300
301    /// Tile-grid Y coordinate at this tile's level.
302    pub fn y(&self) -> u32 {
303        Self::deinterleave_morton(self.morton_number(), self.level()).1
304    }
305
306    /// Validate this tile id against the NDS constraints.
307    fn validate(&self) -> Result<(), TileIdError> {
308        let min_packed_tile_id: u32 = 1 << 16;
309        if self.value < min_packed_tile_id {
310            return Err(TileIdError::ValueTooSmall(self.value()));
311        }
312
313        let tile_level = self.level();
314        let morton = self.morton_number();
315        let max_morton: u64 = (1u64 << (2 * tile_level + 1)) - 1;
316
317        // morton_number is computed as an unsigned subtraction that cannot
318        // underflow for a value >= min_packed_tile_id, so only the upper bound
319        // can be violated.
320        if (morton as u64) > max_morton {
321            return Err(TileIdError::InvalidMortonNumber {
322                morton: morton as i64,
323                level: tile_level,
324                max: max_morton,
325            });
326        }
327
328        Ok(())
329    }
330
331    /// Extract X and Y coordinates from a tile morton number.
332    ///
333    /// X has `level + 1` bits, Y has `level` bits.
334    fn deinterleave_morton(morton: u32, level: u32) -> (u32, u32) {
335        let mut x = 0u32;
336        let mut y = 0u32;
337        for i in 0..level {
338            if morton & (1u32 << (2 * i)) != 0 {
339                x |= 1u32 << i;
340            }
341            if morton & (1u32 << (2 * i + 1)) != 0 {
342                y |= 1u32 << i;
343            }
344        }
345        if morton & (1u32 << (2 * level)) != 0 {
346            x |= 1u32 << level;
347        }
348        (x, y)
349    }
350
351    /// Create a tile morton number from X and Y coordinates.
352    ///
353    /// X has `level + 1` bits, Y has `level` bits.
354    fn interleave_coords(x: u32, y: u32, level: u32) -> u32 {
355        let mut morton = 0u32;
356        for i in 0..level {
357            if x & (1u32 << i) != 0 {
358                morton |= 1u32 << (2 * i);
359            }
360            if y & (1u32 << i) != 0 {
361                morton |= 1u32 << (2 * i + 1);
362            }
363        }
364        if x & (1u32 << level) != 0 {
365            morton |= 1u32 << (2 * level);
366        }
367        morton
368    }
369
370    fn wrapped_offset(coordinate: u32, offset: i32, modulo: u32) -> u32 {
371        ((coordinate as i64 + offset as i64).rem_euclid(modulo as i64)) as u32
372    }
373
374    /// Same-level tile at a relative grid offset, wrapping at the respective
375    /// limits.
376    pub fn neighbour(&self, offset_x: i32, offset_y: i32) -> PackedTileId {
377        let level = self.level();
378        let morton = self.morton_number();
379        let (x, y) = Self::deinterleave_morton(morton, level);
380        let x = Self::wrapped_offset(x, offset_x, 1u32 << (level + 1));
381        let y = Self::wrapped_offset(y, offset_y, 1u32 << level);
382        let new_morton = Self::interleave_coords(x, y, level);
383        PackedTileId::from_tile_index(new_morton, level).expect("valid neighbour")
384    }
385
386    /// Tile to the west at the same level (wraps at the antimeridian).
387    pub fn west_neighbour(&self) -> PackedTileId {
388        self.neighbour(-1, 0)
389    }
390
391    /// Tile to the east at the same level (wraps at the antimeridian).
392    pub fn east_neighbour(&self) -> PackedTileId {
393        self.neighbour(1, 0)
394    }
395
396    /// Tile to the south at the same level (wraps at the south pole).
397    pub fn south_neighbour(&self) -> PackedTileId {
398        self.neighbour(0, -1)
399    }
400
401    /// Tile to the north at the same level (wraps at the north pole).
402    pub fn north_neighbour(&self) -> PackedTileId {
403        self.neighbour(0, 1)
404    }
405}
406
407impl std::fmt::Display for PackedTileId {
408    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409        write!(f, "PackedTileId(value={})", self.value())
410    }
411}
412
413/// Floor division for `i64` (matches Python's `//`).
414///
415/// Rust's `/` truncates toward zero, so `div_euclid` differs from Python `//`
416/// for mixed-sign operands. For a positive divisor (always the case here, since
417/// `tile_size > 0`), `div_euclid` equals floor division.
418#[inline]
419fn floor_div(a: i64, b: i64) -> i64 {
420    a.div_euclid(b)
421}
422
423/// Get all tile IDs that intersect a bounding box given by NDS coordinates.
424///
425/// `(sw_x, sw_y)` is the south-west corner and `(ne_x, ne_y)` the north-east
426/// corner, both inclusive. Faithful port of the Python
427/// `get_tile_ids_for_bounding_box`.
428pub fn get_tile_ids_for_bounding_box(
429    sw_x: i64,
430    sw_y: i64,
431    ne_x: i64,
432    ne_y: i64,
433    level: u32,
434) -> Vec<PackedTileId> {
435    let mut tile_ids = Vec::new();
436
437    let tile_size: i64 = 1 << (31 - level);
438
439    // Floor division so negative coordinates map to the correct tile, matching
440    // Python's `//` (Rust `/` would truncate toward zero).
441    let start_tile_x = floor_div(sw_x, tile_size);
442    let start_tile_y = floor_div(sw_y, tile_size);
443    let end_tile_x = floor_div(ne_x, tile_size);
444    let end_tile_y = floor_div(ne_y, tile_size);
445
446    let mut tile_y = start_tile_y;
447    while tile_y <= end_tile_y {
448        let mut tile_x = start_tile_x;
449        while tile_x <= end_tile_x {
450            let tile_sw_x = tile_x * tile_size;
451            let tile_sw_y = tile_y * tile_size;
452
453            // from_nds_coordinates / morton encoding operate on i32 with
454            // wrapping, matching the Python reference's wrapping reduction.
455            let morton = MortonCode::from_nds_coordinates(tile_sw_x as i32, tile_sw_y as i32);
456            let tile_id = PackedTileId::from_morton_and_level(morton, level)
457                .expect("tile within bounding box must be valid");
458            tile_ids.push(tile_id);
459
460            tile_x += 1;
461        }
462        tile_y += 1;
463    }
464
465    tile_ids
466}
467
468/// Create a tight bounding box (in NDS coordinates) covering all the tiles.
469///
470/// Returns `(min_x, min_y, max_x, max_y)` where `max_x`/`max_y` are the **last
471/// inclusive** points inside the tiles — i.e. the exclusive NE corner minus 1,
472/// matching the Python `bounding_box_from_tile_ids`.
473///
474/// Returns `None` if `tiles` is empty.
475pub fn bounding_box_from_tile_ids(tiles: &[PackedTileId]) -> Option<(i64, i64, i64, i64)> {
476    let (first, rest) = tiles.split_first()?;
477
478    let (mut min_x, mut min_y) = first.south_west_corner();
479    let (mut max_x, mut max_y) = first.north_east_corner();
480
481    for tile in rest {
482        let (sw_x, sw_y) = tile.south_west_corner();
483        let (ne_x, ne_y) = tile.north_east_corner();
484        min_x = min_x.min(sw_x);
485        min_y = min_y.min(sw_y);
486        max_x = max_x.max(ne_x);
487        max_y = max_y.max(ne_y);
488    }
489
490    // NE corner is exclusive; subtract 1 to make it the last inclusive point.
491    Some((min_x, min_y, max_x - 1, max_y - 1))
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn level_15_is_negative() {
500        let t = PackedTileId::from_tile_index(0, 15).unwrap();
501        assert_eq!(t.value(), -2147483648);
502        assert_eq!(t.level(), 15);
503        assert_eq!(t.morton_number(), 0);
504    }
505
506    #[test]
507    fn signed_unsigned_constructors_agree() {
508        let a = PackedTileId::from_i64(-2147483648).unwrap();
509        let b = PackedTileId::from_i64(2147483648).unwrap();
510        assert_eq!(a, b);
511        assert_eq!(a.value(), -2147483648);
512    }
513
514    #[test]
515    fn max_level15_tile() {
516        let t = PackedTileId::from_tile_index((1 << 31) - 1, 15).unwrap();
517        assert_eq!(t.value(), -1);
518    }
519
520    #[test]
521    fn level14_positive() {
522        let t = PackedTileId::from_tile_index(0, 14).unwrap();
523        assert_eq!(t.value(), 1073741824);
524    }
525
526    #[test]
527    fn invalid_level() {
528        assert!(matches!(
529            PackedTileId::from_tile_index(0, 16),
530            Err(TileIdError::InvalidLevel(16))
531        ));
532    }
533
534    #[test]
535    fn invalid_morton() {
536        // level 0 allows morton 0..=1.
537        assert!(PackedTileId::from_tile_index(2, 0).is_err());
538    }
539
540    #[test]
541    fn corners_and_center() {
542        let t = PackedTileId::from_tile_index(0, 0).unwrap();
543        assert_eq!(t.size(), 2147483648);
544        assert_eq!(t.south_west_corner(), (0, 0));
545        assert_eq!(t.north_east_corner(), (2147483648, 2147483648));
546        assert_eq!(t.center(), (1073741824, 1073741824));
547    }
548
549    #[test]
550    fn bbox_floor_div_negative() {
551        // sw at a negative coordinate must use floor division.
552        let tiles = get_tile_ids_for_bounding_box(-1, -1, 0, 0, 1);
553        assert!(!tiles.is_empty());
554    }
555
556    #[test]
557    fn bbox_from_single_tile() {
558        let t = PackedTileId::from_i64(131072).unwrap();
559        let bbox = bounding_box_from_tile_ids(&[t]).unwrap();
560        assert_eq!(bbox, (0, 0, 1073741823, 1073741823));
561    }
562
563    #[test]
564    fn empty_bbox_returns_none() {
565        assert!(bounding_box_from_tile_ids(&[]).is_none());
566    }
567}