use std::f64::consts::PI;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TileCoord {
pub x: u32,
pub y: u32,
pub z: u8,
}
impl TileCoord {
pub fn new(x: u32, y: u32, z: u8) -> Self {
Self { x, y, z }
}
pub fn bounds(&self) -> TileBounds {
let n = 2_f64.powi(self.z as i32);
let lng_min = (self.x as f64) / n * 360.0 - 180.0;
let lng_max = (self.x as f64 + 1.0) / n * 360.0 - 180.0;
let lat_rad = |y: f64| {
let y_rad = PI * (1.0 - 2.0 * y / n);
y_rad.sinh().atan().to_degrees()
};
let lat_max = lat_rad(self.y as f64);
let lat_min = lat_rad(self.y as f64 + 1.0);
TileBounds {
lng_min,
lat_min,
lng_max,
lat_max,
}
}
pub fn parent(&self) -> Option<TileCoord> {
if self.z == 0 {
return None;
}
Some(TileCoord::new(self.x / 2, self.y / 2, self.z - 1))
}
pub fn children(&self) -> Option<[TileCoord; 4]> {
if self.z >= 30 {
return None;
}
let child_z = self.z + 1;
let cx = self.x * 2;
let cy = self.y * 2;
Some([
TileCoord::new(cx, cy, child_z), TileCoord::new(cx + 1, cy, child_z), TileCoord::new(cx, cy + 1, child_z), TileCoord::new(cx + 1, cy + 1, child_z), ])
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TileBounds {
pub lng_min: f64,
pub lat_min: f64,
pub lng_max: f64,
pub lat_max: f64,
}
impl TileBounds {
pub fn new(lng_min: f64, lat_min: f64, lng_max: f64, lat_max: f64) -> Self {
Self {
lng_min,
lat_min,
lng_max,
lat_max,
}
}
pub fn empty() -> Self {
Self {
lng_min: f64::INFINITY,
lat_min: f64::INFINITY,
lng_max: f64::NEG_INFINITY,
lat_max: f64::NEG_INFINITY,
}
}
pub fn is_valid(&self) -> bool {
self.lng_min <= self.lng_max && self.lat_min <= self.lat_max
}
pub fn expand(&mut self, other: &Self) {
self.lng_min = self.lng_min.min(other.lng_min);
self.lat_min = self.lat_min.min(other.lat_min);
self.lng_max = self.lng_max.max(other.lng_max);
self.lat_max = self.lat_max.max(other.lat_max);
}
pub fn width(&self) -> f64 {
self.lng_max - self.lng_min
}
pub fn height(&self) -> f64 {
self.lat_max - self.lat_min
}
}
pub fn lng_lat_to_tile(lng: f64, lat: f64, zoom: u8) -> TileCoord {
let n = 2_f64.powi(zoom as i32);
let max_coord = 2_u32.pow(zoom as u32).saturating_sub(1);
let x = ((lng + 180.0) / 360.0 * n).floor() as u32;
let x = x.min(max_coord);
let lat = lat.clamp(-85.05, 85.05);
let lat_rad = lat.to_radians();
let y = ((1.0 - lat_rad.tan().asinh() / PI) / 2.0 * n).floor() as u32;
let y = y.min(max_coord);
TileCoord::new(x, y, zoom)
}
pub fn tile_bounds(x: u32, y: u32, z: u8) -> TileBounds {
TileCoord::new(x, y, z).bounds()
}
pub fn tiles_for_bbox(bbox: &TileBounds, zoom: u8) -> impl Iterator<Item = TileCoord> {
let r = tile_ranges_for_bbox(bbox, zoom);
let (min_y_tile, max_y_tile) = r.y;
let first = r.x;
let second = r.x2;
let first_tiles = (min_y_tile..=max_y_tile)
.flat_map(move |y| (first.0..=first.1).map(move |x| TileCoord::new(x, y, zoom)));
let second_tiles = second.into_iter().flat_map(move |(x_min, x_max)| {
(min_y_tile..=max_y_tile)
.flat_map(move |y| (x_min..=x_max).map(move |x| TileCoord::new(x, y, zoom)))
});
first_tiles.chain(second_tiles)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct BboxTileRanges {
pub y: (u32, u32),
pub x: (u32, u32),
pub x2: Option<(u32, u32)>,
}
pub(crate) fn tile_ranges_for_bbox(bbox: &TileBounds, zoom: u8) -> BboxTileRanges {
let crosses_antimeridian = bbox.lng_min > bbox.lng_max;
let min_y_tile = lng_lat_to_tile(bbox.lng_min, bbox.lat_max, zoom).y; let max_y_tile = lng_lat_to_tile(bbox.lng_min, bbox.lat_min, zoom).y;
let max_tile_x = 2_u32.pow(zoom as u32) - 1;
let (x, x2): ((u32, u32), Option<(u32, u32)>) = if crosses_antimeridian {
let west_x = lng_lat_to_tile(bbox.lng_min, 0.0, zoom).x; let east_x = lng_lat_to_tile(bbox.lng_max, 0.0, zoom).x;
((west_x, max_tile_x), Some((0, east_x)))
} else {
let min_x = lng_lat_to_tile(bbox.lng_min, 0.0, zoom).x;
let max_x = lng_lat_to_tile(bbox.lng_max, 0.0, zoom).x;
((min_x, max_x), None)
};
BboxTileRanges {
y: (min_y_tile, max_y_tile),
x,
x2,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lng_lat_to_tile_origin() {
let tile = lng_lat_to_tile(0.0, 0.0, 0);
assert_eq!(tile, TileCoord::new(0, 0, 0));
}
#[test]
fn test_lng_lat_to_tile_zoom_1() {
let tile = lng_lat_to_tile(0.0, 0.0, 1);
assert_eq!(tile.x, 1);
assert_eq!(tile.y, 1);
assert_eq!(tile.z, 1);
let tile = lng_lat_to_tile(-90.0, 45.0, 1);
assert_eq!(tile.x, 0);
let tile = lng_lat_to_tile(90.0, 45.0, 1);
assert_eq!(tile.x, 1);
}
#[test]
fn test_tile_bounds() {
let tile = TileCoord::new(0, 0, 0);
let bounds = tile.bounds();
assert!((bounds.lng_min - (-180.0)).abs() < 0.0001);
assert!((bounds.lng_max - 180.0).abs() < 0.0001);
assert!(bounds.lat_min < -85.0);
assert!(bounds.lat_max > 85.0);
}
#[test]
fn test_tiles_for_bbox_single_tile() {
let bbox = TileBounds::new(-1.0, -1.0, 1.0, 1.0);
let tiles: Vec<_> = tiles_for_bbox(&bbox, 10).collect();
assert!(!tiles.is_empty());
for tile in &tiles {
assert_eq!(tile.z, 10);
}
}
#[test]
fn test_tiles_for_bbox_multiple_tiles() {
let bbox = TileBounds::new(-10.0, -10.0, 10.0, 10.0);
let tiles: Vec<_> = tiles_for_bbox(&bbox, 5).collect();
assert!(tiles.len() > 1);
let first = tiles.first().unwrap();
let last = tiles.last().unwrap();
assert!(first.x <= last.x);
assert!(first.y <= last.y);
}
#[test]
fn test_bbox_expand() {
let mut bbox1 = TileBounds::new(-10.0, -10.0, 10.0, 10.0);
let bbox2 = TileBounds::new(-20.0, -5.0, 5.0, 15.0);
bbox1.expand(&bbox2);
assert_eq!(bbox1.lng_min, -20.0);
assert_eq!(bbox1.lat_min, -10.0);
assert_eq!(bbox1.lng_max, 10.0);
assert_eq!(bbox1.lat_max, 15.0);
}
#[test]
fn test_bbox_empty() {
let bbox = TileBounds::empty();
assert!(!bbox.is_valid());
let mut bbox = TileBounds::empty();
bbox.expand(&TileBounds::new(-10.0, -10.0, 10.0, 10.0));
assert!(bbox.is_valid());
assert_eq!(bbox.lng_min, -10.0);
}
#[test]
fn test_tile_coord_round_trip() {
for zoom in 0..=14 {
let max_coord = 2_u32.pow(zoom as u32) - 1;
let x = max_coord.min(100);
let y = max_coord.min(200);
let tile = TileCoord::new(x, y, zoom);
let bounds = tile.bounds();
let center_lng = (bounds.lng_min + bounds.lng_max) / 2.0;
let center_lat = (bounds.lat_min + bounds.lat_max) / 2.0;
let tile_back = lng_lat_to_tile(center_lng, center_lat, zoom);
assert_eq!(tile, tile_back, "Round-trip failed at zoom {}", zoom);
}
}
#[test]
fn test_tiles_for_bbox_antimeridian_crossing() {
let bbox = TileBounds::new(170.0, -20.0, -170.0, -10.0);
let tiles: Vec<_> = tiles_for_bbox(&bbox, 4).collect();
assert!(
!tiles.is_empty(),
"Antimeridian crossing bbox should produce tiles"
);
let x_coords: std::collections::HashSet<_> = tiles.iter().map(|t| t.x).collect();
let has_high_x = x_coords.iter().any(|&x| x >= 15); let has_low_x = x_coords.iter().any(|&x| x <= 1);
assert!(
has_high_x && has_low_x,
"Should have tiles on both sides of antimeridian. Got x coords: {:?}",
x_coords
);
}
#[test]
fn antimeridian_inflated_bbox_covers_full_world_row() {
let bbox = TileBounds::new(-179.9, -0.1, 179.9, 0.1);
let tiles: Vec<_> = tiles_for_bbox(&bbox, 4).collect();
let x_coords: std::collections::HashSet<_> = tiles.iter().map(|t| t.x).collect();
assert_eq!(
x_coords.len(),
16,
"PIN: inflated antimeridian bbox spans all 16 x columns at z4"
);
}
#[test]
fn test_tiles_for_bbox_normal_still_works() {
let bbox = TileBounds::new(-10.0, 40.0, 10.0, 50.0);
let tiles: Vec<_> = tiles_for_bbox(&bbox, 4).collect();
assert!(!tiles.is_empty(), "Normal bbox should produce tiles");
for tile in &tiles {
assert_eq!(tile.z, 4);
}
}
#[test]
fn test_tile_parent_at_zoom_0() {
let tile = TileCoord::new(0, 0, 0);
assert_eq!(tile.parent(), None, "Zoom 0 tile has no parent");
}
#[test]
fn test_tile_parent_at_zoom_1() {
for x in 0..2 {
for y in 0..2 {
let tile = TileCoord::new(x, y, 1);
let parent = tile.parent().expect("z=1 tile should have parent");
assert_eq!(parent, TileCoord::new(0, 0, 0));
}
}
}
#[test]
fn test_tile_parent_at_higher_zoom() {
let tile = TileCoord::new(5, 7, 4);
let parent = tile.parent().expect("Should have parent");
assert_eq!(parent, TileCoord::new(2, 3, 3));
let grandparent = parent.parent().expect("Should have grandparent");
assert_eq!(grandparent, TileCoord::new(1, 1, 2));
}
#[test]
fn test_tile_children() {
let tile = TileCoord::new(1, 2, 3);
let children = tile.children().expect("Should have children");
assert_eq!(children[0], TileCoord::new(2, 4, 4)); assert_eq!(children[1], TileCoord::new(3, 4, 4)); assert_eq!(children[2], TileCoord::new(2, 5, 4)); assert_eq!(children[3], TileCoord::new(3, 5, 4)); }
#[test]
fn test_tile_children_at_max_zoom() {
let tile = TileCoord::new(0, 0, 30);
assert_eq!(tile.children(), None, "Zoom 30 tile has no children");
}
#[test]
fn test_parent_child_round_trip() {
let tile = TileCoord::new(5, 7, 4);
let parent = tile.parent().unwrap();
let siblings = parent.children().unwrap();
assert!(
siblings.contains(&tile),
"Parent's children should include original tile"
);
}
#[test]
fn test_child_parent_round_trip() {
let tile = TileCoord::new(3, 2, 5);
let children = tile.children().unwrap();
for child in &children {
assert_eq!(
child.parent().unwrap(),
tile,
"Each child's parent should be the original tile"
);
}
}
#[test]
fn test_children_cover_parent_bounds() {
let parent = TileCoord::new(1, 1, 2);
let parent_bounds = parent.bounds();
let children = parent.children().unwrap();
let mut min_lng = f64::INFINITY;
let mut max_lng = f64::NEG_INFINITY;
let mut min_lat = f64::INFINITY;
let mut max_lat = f64::NEG_INFINITY;
for child in &children {
let b = child.bounds();
min_lng = min_lng.min(b.lng_min);
max_lng = max_lng.max(b.lng_max);
min_lat = min_lat.min(b.lat_min);
max_lat = max_lat.max(b.lat_max);
}
assert!(
(min_lng - parent_bounds.lng_min).abs() < 1e-10,
"Children lng_min should match parent"
);
assert!(
(max_lng - parent_bounds.lng_max).abs() < 1e-10,
"Children lng_max should match parent"
);
assert!(
(min_lat - parent_bounds.lat_min).abs() < 1e-10,
"Children lat_min should match parent"
);
assert!(
(max_lat - parent_bounds.lat_max).abs() < 1e-10,
"Children lat_max should match parent"
);
}
#[test]
fn test_tiles_for_bbox_antimeridian_tile_count() {
let bbox = TileBounds::new(170.0, -20.0, -170.0, -10.0);
let tiles: Vec<_> = tiles_for_bbox(&bbox, 2).collect();
let x_coords: std::collections::HashSet<_> = tiles.iter().map(|t| t.x).collect();
assert!(
x_coords.len() <= 3,
"Antimeridian bbox should produce tiles only near the crossing, not wrap around. Got {} unique x coords: {:?}",
x_coords.len(),
x_coords
);
}
#[test]
fn test_lng_lat_to_tile_boundary_clamping() {
let tile = lng_lat_to_tile(180.0, 0.0, 0);
assert_eq!(tile.x, 0, "lng=180 at zoom 0 should clamp to x=0");
assert_eq!(tile.y, 0, "lat=0 at zoom 0 should be y=0");
let tile = lng_lat_to_tile(180.0, -85.05, 0);
assert_eq!(tile.x, 0, "lng=180 at zoom 0 should clamp to x=0");
assert_eq!(tile.y, 0, "lat=-85.05 at zoom 0 should clamp to y=0");
let tile = lng_lat_to_tile(180.0, 0.0, 1);
assert!(tile.x <= 1, "lng=180 at zoom 1 should have x <= 1");
for zoom in 0..=10 {
let max_valid = 2_u32.pow(zoom as u32) - 1;
let tile_pos180 = lng_lat_to_tile(180.0, 0.0, zoom);
assert!(
tile_pos180.x <= max_valid,
"lng=180 at zoom {} should have x <= {}, got {}",
zoom,
max_valid,
tile_pos180.x
);
let tile_neg180 = lng_lat_to_tile(-180.0, 0.0, zoom);
assert_eq!(
tile_neg180.x, 0,
"lng=-180 at zoom {} should have x = 0",
zoom
);
let tile_north_pole = lng_lat_to_tile(0.0, 85.05, zoom);
assert!(
tile_north_pole.y <= max_valid,
"lat=85.05 at zoom {} should have y <= {}",
zoom,
max_valid
);
let tile_south_pole = lng_lat_to_tile(0.0, -85.05, zoom);
assert!(
tile_south_pole.y <= max_valid,
"lat=-85.05 at zoom {} should have y <= {}",
zoom,
max_valid
);
}
}
}