1#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub struct TileId {
10 pub z: u8,
11 pub x: u32,
12 pub y: u32,
13}
14
15impl TileId {
16 pub const fn new(z: u8, x: u32, y: u32) -> Self {
17 Self { z, x, y }
18 }
19
20 #[inline]
22 pub fn axis_tiles(self) -> u32 {
23 1u32 << self.z
24 }
25
26 #[inline]
29 pub fn parent(self) -> Option<TileId> {
30 if self.z == 0 {
31 None
32 } else {
33 Some(TileId::new(self.z - 1, self.x >> 1, self.y >> 1))
34 }
35 }
36
37 #[inline]
40 pub fn ancestor_at(self, z: u8) -> Option<TileId> {
41 if z >= self.z {
42 return None;
43 }
44 let dz = self.z - z;
45 Some(TileId::new(z, self.x >> dz, self.y >> dz))
46 }
47
48 pub fn is_ancestor_of(self, other: TileId) -> bool {
51 if other.z <= self.z {
52 return false;
53 }
54 let dz = other.z - self.z;
55 other.x >> dz == self.x && other.y >> dz == self.y
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct WorldPos {
62 pub x: f64,
63 pub y: f64,
64}
65
66impl WorldPos {
67 pub const fn new(x: f64, y: f64) -> Self {
68 Self { x, y }
69 }
70}
71
72#[inline]
74pub fn tile_to_world(tile: TileId, tx: f64, ty: f64, extent: f64) -> WorldPos {
75 let n = tile.axis_tiles() as f64;
76 WorldPos {
77 x: (tile.x as f64 + tx / extent) / n,
78 y: (tile.y as f64 + ty / extent) / n,
79 }
80}
81
82pub const EARTH_CIRCUMFERENCE_M: f64 = 40_075_016.685_578_5;
85
86pub const MERCATOR_MAX_LAT: f64 = 85.051_128_779_8;
89
90#[inline]
92pub fn lon_to_world_x(lon_deg: f64) -> f64 {
93 (lon_deg + 180.0) / 360.0
94}
95
96#[inline]
99pub fn lat_to_world_y(lat_deg: f64) -> f64 {
100 let lat = lat_deg
101 .clamp(-MERCATOR_MAX_LAT, MERCATOR_MAX_LAT)
102 .to_radians();
103 (1.0 - lat.tan().asinh() / std::f64::consts::PI) / 2.0
104}
105
106#[inline]
108pub fn world_x_to_lon(wx: f64) -> f64 {
109 wx * 360.0 - 180.0
110}
111
112#[inline]
114pub fn world_y_to_lat(wy: f64) -> f64 {
115 (std::f64::consts::PI * (1.0 - 2.0 * wy))
116 .sinh()
117 .atan()
118 .to_degrees()
119}
120
121#[inline]
129pub fn metres_per_world_unit(wy: f64) -> f64 {
130 let u = std::f64::consts::PI * (1.0 - 2.0 * wy);
133 EARTH_CIRCUMFERENCE_M / u.cosh()
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn parent_walks_one_level() {
142 assert_eq!(TileId::new(0, 0, 0).parent(), None);
143 assert_eq!(TileId::new(3, 5, 6).parent(), Some(TileId::new(2, 2, 3)));
144 }
145
146 #[test]
147 fn ancestor_at_handles_invalid() {
148 let t = TileId::new(5, 10, 20);
149 assert_eq!(t.ancestor_at(5), None); assert_eq!(t.ancestor_at(6), None); assert_eq!(t.ancestor_at(3), Some(TileId::new(3, 2, 5)));
152 assert_eq!(t.ancestor_at(0), Some(TileId::new(0, 0, 0)));
153 }
154
155 #[test]
156 fn is_ancestor_of() {
157 let parent = TileId::new(5, 10, 20);
158 assert!(parent.is_ancestor_of(TileId::new(7, 41, 81))); assert!(!parent.is_ancestor_of(TileId::new(7, 44, 81))); assert!(!parent.is_ancestor_of(parent)); assert!(!parent.is_ancestor_of(TileId::new(4, 5, 10))); }
163
164 #[test]
165 fn lon_lat_round_trip_through_world_coords() {
166 for lon in [-180.0, -74.0, 0.0, 139.7, 180.0] {
167 let wx = lon_to_world_x(lon);
168 assert!((world_x_to_lon(wx) - lon).abs() < 1e-9, "lon {lon}");
169 }
170 for lat in [-80.0, -35.7, 0.0, 51.5, 80.0] {
171 let wy = lat_to_world_y(lat);
172 assert!((world_y_to_lat(wy) - lat).abs() < 1e-9, "lat {lat}");
173 }
174 assert!(lat_to_world_y(80.0) < lat_to_world_y(-80.0));
176 assert!((lat_to_world_y(0.0) - 0.5).abs() < 1e-12);
177 assert_eq!(lat_to_world_y(89.0), lat_to_world_y(MERCATOR_MAX_LAT));
179 }
180
181 #[test]
182 fn metres_per_world_unit_matches_mercator_scale() {
183 assert!((metres_per_world_unit(0.5) - EARTH_CIRCUMFERENCE_M).abs() < 1.0);
185 let wy_60n = (1.0 - 60f64.to_radians().tan().asinh() / std::f64::consts::PI) / 2.0;
187 let ratio = metres_per_world_unit(wy_60n) / EARTH_CIRCUMFERENCE_M;
188 assert!((ratio - 0.5).abs() < 1e-9, "ratio {ratio}");
189 assert!((metres_per_world_unit(0.2) - metres_per_world_unit(0.8)).abs() < 1e-6);
191 }
192}