use crate::error::{SpatialError, SpatialResult};
use std::f64::consts::PI;
const WGS84_A: f64 = 6_378_137.0; const WGS84_B: f64 = 6_356_752.314_245; const WGS84_F: f64 = 1.0 / 298.257_223_563; const EARTH_RADIUS_M: f64 = 6_371_008.8;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GeoPoint {
lat: f64, lon: f64, }
impl GeoPoint {
pub fn new(lat: f64, lon: f64) -> SpatialResult<Self> {
if !(-90.0..=90.0).contains(&lat) {
return Err(SpatialError::ValueError(format!(
"Latitude {lat} is outside [-90, 90]"
)));
}
if !(-180.0..=180.0).contains(&lon) {
return Err(SpatialError::ValueError(format!(
"Longitude {lon} is outside [-180, 180]"
)));
}
Ok(Self { lat, lon })
}
pub fn lat(&self) -> f64 {
self.lat
}
pub fn lon(&self) -> f64 {
self.lon
}
pub fn lat_rad(&self) -> f64 {
self.lat.to_radians()
}
pub fn lon_rad(&self) -> f64 {
self.lon.to_radians()
}
pub fn haversine_distance_m(&self, other: &GeoPoint) -> f64 {
HaversineDistance::distance_m(self, other)
}
pub fn haversine_distance_km(&self, other: &GeoPoint) -> f64 {
HaversineDistance::distance_km(self, other)
}
pub fn vincenty_distance_m(&self, other: &GeoPoint) -> Option<f64> {
VincentyDistance::distance_m(self, other)
}
pub fn bearing_to(&self, other: &GeoPoint) -> f64 {
let lat1 = self.lat_rad();
let lat2 = other.lat_rad();
let dlon = (other.lon - self.lon).to_radians();
let y = dlon.sin() * lat2.cos();
let x = lat1.cos() * lat2.sin() - lat1.sin() * lat2.cos() * dlon.cos();
let bearing = y.atan2(x).to_degrees();
(bearing + 360.0) % 360.0
}
pub fn destination(&self, distance_m: f64, bearing_deg: f64) -> GeoPoint {
let r = EARTH_RADIUS_M;
let d = distance_m / r;
let theta = bearing_deg.to_radians();
let lat1 = self.lat_rad();
let lon1 = self.lon_rad();
let lat2 = (lat1.sin() * d.cos() + lat1.cos() * d.sin() * theta.cos()).asin();
let lon2 = lon1
+ (theta.sin() * d.sin() * lat1.cos()).atan2(d.cos() - lat1.sin() * lat2.sin());
let lat2_deg = lat2.to_degrees().clamp(-90.0, 90.0);
let lon2_deg = ((lon2.to_degrees() + 540.0) % 360.0) - 180.0;
GeoPoint { lat: lat2_deg, lon: lon2_deg }
}
pub fn to_ecef(&self) -> (f64, f64, f64) {
let lat = self.lat_rad();
let lon = self.lon_rad();
let e2 = 2.0 * WGS84_F - WGS84_F * WGS84_F;
let n = WGS84_A / (1.0 - e2 * lat.sin().powi(2)).sqrt();
let x = n * lat.cos() * lon.cos();
let y = n * lat.cos() * lon.sin();
let z = n * (1.0 - e2) * lat.sin();
(x, y, z)
}
}
impl std::fmt::Display for GeoPoint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let ns = if self.lat >= 0.0 { 'N' } else { 'S' };
let ew = if self.lon >= 0.0 { 'E' } else { 'W' };
write!(f, "{:.4}°{}{:.4}°{}", self.lat.abs(), ns, self.lon.abs(), ew)
}
}
pub struct HaversineDistance;
impl HaversineDistance {
pub fn distance_m(a: &GeoPoint, b: &GeoPoint) -> f64 {
let dlat = (b.lat - a.lat).to_radians();
let dlon = (b.lon - a.lon).to_radians();
let lat1 = a.lat_rad();
let lat2 = b.lat_rad();
let h = (dlat / 2.0).sin().powi(2)
+ lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
2.0 * EARTH_RADIUS_M * h.sqrt().asin()
}
pub fn distance_km(a: &GeoPoint, b: &GeoPoint) -> f64 {
Self::distance_m(a, b) / 1000.0
}
pub fn distance_nm(a: &GeoPoint, b: &GeoPoint) -> f64 {
Self::distance_m(a, b) / 1852.0
}
}
pub struct VincentyDistance;
impl VincentyDistance {
pub fn distance_m(a: &GeoPoint, b: &GeoPoint) -> Option<f64> {
let (a_r, b_r, f) = (WGS84_A, WGS84_B, WGS84_F);
let lat1 = a.lat_rad();
let lat2 = b.lat_rad();
let lon1 = a.lon_rad();
let lon2 = b.lon_rad();
let u1 = ((1.0 - f) * lat1.tan()).atan();
let u2 = ((1.0 - f) * lat2.tan()).atan();
let sin_u1 = u1.sin();
let cos_u1 = u1.cos();
let sin_u2 = u2.sin();
let cos_u2 = u2.cos();
let l = lon2 - lon1;
let mut lambda = l;
let mut prev_lambda;
let (mut cos2_alpha, mut sin_sigma, mut cos_sigma, mut sigma, mut cos2_sigma_m) =
(0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
const MAX_ITER: usize = 1000;
let mut converged = false;
for _ in 0..MAX_ITER {
prev_lambda = lambda;
let sin_lambda = lambda.sin();
let cos_lambda = lambda.cos();
sin_sigma = ((cos_u2 * sin_lambda).powi(2)
+ (cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_lambda).powi(2))
.sqrt();
if sin_sigma.abs() < 1e-10 {
return Some(0.0);
}
cos_sigma = sin_u1 * sin_u2 + cos_u1 * cos_u2 * cos_lambda;
sigma = sin_sigma.atan2(cos_sigma);
let sin_alpha = cos_u1 * cos_u2 * sin_lambda / sin_sigma;
cos2_alpha = 1.0 - sin_alpha * sin_alpha;
cos2_sigma_m = if cos2_alpha.abs() > 1e-10 {
cos_sigma - 2.0 * sin_u1 * sin_u2 / cos2_alpha
} else {
0.0 };
let c = f / 16.0 * cos2_alpha * (4.0 + f * (4.0 - 3.0 * cos2_alpha));
lambda = l
+ (1.0 - c)
* f
* sin_alpha
* (sigma
+ c * sin_sigma
* (cos2_sigma_m
+ c * cos_sigma * (-1.0 + 2.0 * cos2_sigma_m * cos2_sigma_m)));
if (lambda - prev_lambda).abs() < 1e-12 {
converged = true;
break;
}
}
if !converged {
return None;
}
let u2 = cos2_alpha * (a_r * a_r - b_r * b_r) / (b_r * b_r);
let k1 = (1.0 + u2).sqrt();
let big_a = (1.0 + u2 / 4.0 * (4.0 + u2 * (-3.0 + u2))) / k1;
let big_b = u2 / k1 * (1.0 + u2 / 4.0 * ((-1.0 + u2) * (1.0 + u2 / 4.0)));
let delta_sigma = big_b
* sin_sigma
* (cos2_sigma_m
+ big_b / 4.0
* (cos_sigma * (-1.0 + 2.0 * cos2_sigma_m * cos2_sigma_m)
- big_b / 6.0
* cos2_sigma_m
* (-3.0 + 4.0 * sin_sigma * sin_sigma)
* (-3.0 + 4.0 * cos2_sigma_m * cos2_sigma_m)));
Some(b_r * big_a * (sigma - delta_sigma))
}
pub fn distance_km(a: &GeoPoint, b: &GeoPoint) -> Option<f64> {
Self::distance_m(a, b).map(|d| d / 1000.0)
}
}
#[derive(Debug, Clone, Copy)]
pub struct BoundingBox2D {
pub min_lat: f64,
pub max_lat: f64,
pub min_lon: f64,
pub max_lon: f64,
}
impl BoundingBox2D {
pub fn new(
min_lat: f64,
max_lat: f64,
min_lon: f64,
max_lon: f64,
) -> SpatialResult<Self> {
if !(-90.0..=90.0).contains(&min_lat) || !(-90.0..=90.0).contains(&max_lat) {
return Err(SpatialError::ValueError(
"Latitude must be in [-90, 90]".to_string(),
));
}
if !(-180.0..=180.0).contains(&min_lon) || !(-180.0..=180.0).contains(&max_lon) {
return Err(SpatialError::ValueError(
"Longitude must be in [-180, 180]".to_string(),
));
}
if min_lat > max_lat {
return Err(SpatialError::ValueError(format!(
"min_lat ({min_lat}) > max_lat ({max_lat})"
)));
}
if min_lon > max_lon {
return Err(SpatialError::ValueError(format!(
"min_lon ({min_lon}) > max_lon ({max_lon})"
)));
}
Ok(Self { min_lat, max_lat, min_lon, max_lon })
}
pub fn from_points(pts: &[GeoPoint]) -> SpatialResult<Self> {
if pts.is_empty() {
return Err(SpatialError::ValueError(
"Cannot compute bounding box of empty point set".to_string(),
));
}
let min_lat = pts.iter().map(|p| p.lat).fold(f64::INFINITY, f64::min);
let max_lat = pts.iter().map(|p| p.lat).fold(f64::NEG_INFINITY, f64::max);
let min_lon = pts.iter().map(|p| p.lon).fold(f64::INFINITY, f64::min);
let max_lon = pts.iter().map(|p| p.lon).fold(f64::NEG_INFINITY, f64::max);
Ok(Self { min_lat, max_lat, min_lon, max_lon })
}
pub fn contains(&self, p: &GeoPoint) -> bool {
p.lat >= self.min_lat
&& p.lat <= self.max_lat
&& p.lon >= self.min_lon
&& p.lon <= self.max_lon
}
pub fn overlaps(&self, other: &BoundingBox2D) -> bool {
self.min_lat <= other.max_lat
&& self.max_lat >= other.min_lat
&& self.min_lon <= other.max_lon
&& self.max_lon >= other.min_lon
}
pub fn expand_to_include(&self, p: &GeoPoint) -> Self {
Self {
min_lat: self.min_lat.min(p.lat),
max_lat: self.max_lat.max(p.lat),
min_lon: self.min_lon.min(p.lon),
max_lon: self.max_lon.max(p.lon),
}
}
pub fn center(&self) -> GeoPoint {
GeoPoint {
lat: (self.min_lat + self.max_lat) / 2.0,
lon: (self.min_lon + self.max_lon) / 2.0,
}
}
pub fn area_km2(&self) -> f64 {
let sw = GeoPoint { lat: self.min_lat, lon: self.min_lon };
let ne = GeoPoint { lat: self.max_lat, lon: self.max_lon };
let se = GeoPoint { lat: self.min_lat, lon: self.max_lon };
let width_km = HaversineDistance::distance_km(&sw, &se);
let height_km = HaversineDistance::distance_km(&sw, &GeoPoint {
lat: self.max_lat,
lon: self.min_lon,
});
let _ = ne; width_km * height_km
}
}
pub struct Geohash;
const GEOHASH_ALPHABET: &[u8] = b"0123456789bcdefghjkmnpqrstuvwxyz";
fn geohash_decode_char(c: char) -> Option<u64> {
GEOHASH_ALPHABET
.iter()
.position(|&b| b == c as u8)
.map(|i| i as u64)
}
impl Geohash {
pub fn encode(lat: f64, lon: f64, precision: usize) -> SpatialResult<String> {
if !(-90.0..=90.0).contains(&lat) {
return Err(SpatialError::ValueError(format!(
"Latitude {lat} out of range [-90, 90]"
)));
}
if !(-180.0..=180.0).contains(&lon) {
return Err(SpatialError::ValueError(format!(
"Longitude {lon} out of range [-180, 180]"
)));
}
if precision == 0 {
return Err(SpatialError::ValueError(
"Geohash precision must be at least 1".to_string(),
));
}
let mut lat_range = (-90.0_f64, 90.0_f64);
let mut lon_range = (-180.0_f64, 180.0_f64);
let mut hash = String::with_capacity(precision);
let mut bits = 0u64;
let mut num_bits = 0u32;
let mut is_lon = true;
let bits_needed = precision * 5;
for _ in 0..bits_needed {
bits <<= 1;
if is_lon {
let mid = (lon_range.0 + lon_range.1) / 2.0;
if lon >= mid {
bits |= 1;
lon_range.0 = mid;
} else {
lon_range.1 = mid;
}
} else {
let mid = (lat_range.0 + lat_range.1) / 2.0;
if lat >= mid {
bits |= 1;
lat_range.0 = mid;
} else {
lat_range.1 = mid;
}
}
is_lon = !is_lon;
num_bits += 1;
if num_bits == 5 {
let idx = bits & 0x1F;
hash.push(GEOHASH_ALPHABET[idx as usize] as char);
bits = 0;
num_bits = 0;
}
}
Ok(hash)
}
pub fn decode(hash: &str) -> SpatialResult<(f64, f64)> {
if hash.is_empty() {
return Err(SpatialError::ValueError(
"Cannot decode empty geohash".to_string(),
));
}
let mut lat_range = (-90.0_f64, 90.0_f64);
let mut lon_range = (-180.0_f64, 180.0_f64);
let mut is_lon = true;
for c in hash.chars() {
let bits = geohash_decode_char(c).ok_or_else(|| {
SpatialError::ValueError(format!("Invalid geohash character '{c}'"))
})?;
for bit_pos in (0..5u32).rev() {
let bit = (bits >> bit_pos) & 1;
if is_lon {
let mid = (lon_range.0 + lon_range.1) / 2.0;
if bit == 1 {
lon_range.0 = mid;
} else {
lon_range.1 = mid;
}
} else {
let mid = (lat_range.0 + lat_range.1) / 2.0;
if bit == 1 {
lat_range.0 = mid;
} else {
lat_range.1 = mid;
}
}
is_lon = !is_lon;
}
}
let lat = (lat_range.0 + lat_range.1) / 2.0;
let lon = (lon_range.0 + lon_range.1) / 2.0;
Ok((lat, lon))
}
pub fn decode_bbox(hash: &str) -> SpatialResult<(f64, f64, f64, f64)> {
if hash.is_empty() {
return Err(SpatialError::ValueError(
"Cannot decode empty geohash".to_string(),
));
}
let mut lat_range = (-90.0_f64, 90.0_f64);
let mut lon_range = (-180.0_f64, 180.0_f64);
let mut is_lon = true;
for c in hash.chars() {
let bits = geohash_decode_char(c).ok_or_else(|| {
SpatialError::ValueError(format!("Invalid geohash character '{c}'"))
})?;
for bit_pos in (0..5u32).rev() {
let bit = (bits >> bit_pos) & 1;
if is_lon {
let mid = (lon_range.0 + lon_range.1) / 2.0;
if bit == 1 {
lon_range.0 = mid;
} else {
lon_range.1 = mid;
}
} else {
let mid = (lat_range.0 + lat_range.1) / 2.0;
if bit == 1 {
lat_range.0 = mid;
} else {
lat_range.1 = mid;
}
}
is_lon = !is_lon;
}
}
Ok((lat_range.0, lat_range.1, lon_range.0, lon_range.1))
}
pub fn neighbors(hash: &str) -> SpatialResult<[String; 8]> {
let (min_lat, max_lat, min_lon, max_lon) = Self::decode_bbox(hash)?;
let precision = hash.len();
let step_lat = (max_lat - min_lat) * 0.5;
let step_lon = (max_lon - min_lon) * 0.5;
let center_lat = (min_lat + max_lat) / 2.0;
let center_lon = (min_lon + max_lon) / 2.0;
let clamp_lat = |l: f64| l.clamp(-90.0, 90.0);
let clamp_lon = |l: f64| {
if l > 180.0 { l - 360.0 } else if l < -180.0 { l + 360.0 } else { l }
};
let n = Self::encode(clamp_lat(center_lat + step_lat * 2.0), center_lon, precision)?;
let ne = Self::encode(clamp_lat(center_lat + step_lat * 2.0), clamp_lon(center_lon + step_lon * 2.0), precision)?;
let e = Self::encode(center_lat, clamp_lon(center_lon + step_lon * 2.0), precision)?;
let se = Self::encode(clamp_lat(center_lat - step_lat * 2.0), clamp_lon(center_lon + step_lon * 2.0), precision)?;
let s = Self::encode(clamp_lat(center_lat - step_lat * 2.0), center_lon, precision)?;
let sw = Self::encode(clamp_lat(center_lat - step_lat * 2.0), clamp_lon(center_lon - step_lon * 2.0), precision)?;
let w = Self::encode(center_lat, clamp_lon(center_lon - step_lon * 2.0), precision)?;
let nw = Self::encode(clamp_lat(center_lat + step_lat * 2.0), clamp_lon(center_lon - step_lon * 2.0), precision)?;
Ok([n, ne, e, se, s, sw, w, nw])
}
}
pub struct H3Lite;
impl H3Lite {
pub const MAX_RESOLUTION: u8 = 7;
pub fn encode(lat: f64, lon: f64, resolution: u8) -> SpatialResult<u64> {
if resolution > Self::MAX_RESOLUTION {
return Err(SpatialError::ValueError(format!(
"Resolution {resolution} exceeds maximum {}",
Self::MAX_RESOLUTION
)));
}
if !(-90.0..=90.0).contains(&lat) {
return Err(SpatialError::ValueError(format!(
"Latitude {lat} out of range"
)));
}
if !(-180.0..=180.0).contains(&lon) {
return Err(SpatialError::ValueError(format!(
"Longitude {lon} out of range"
)));
}
let base_cells: u64 = 1 << (resolution as u64 + 1);
let hex_size_lat = 180.0 / (base_cells as f64);
let hex_size_lon = 360.0 / (base_cells as f64);
let row = ((lat + 90.0) / hex_size_lat) as i64;
let col = ((lon + 180.0) / hex_size_lon) as i64;
let col_adj = if row % 2 == 0 { col } else { col };
let row_u = row.max(0) as u64;
let col_u = col_adj.max(0) as u64;
let cell_id = ((resolution as u64) << 56) | (row_u << 28) | (col_u & 0x0FFF_FFFF);
Ok(cell_id)
}
pub fn decode(cell_id: u64) -> SpatialResult<(f64, f64)> {
let resolution = (cell_id >> 56) as u8;
if resolution > Self::MAX_RESOLUTION {
return Err(SpatialError::ValueError(format!(
"Invalid resolution field {resolution} in cell ID"
)));
}
let base_cells: u64 = 1 << (resolution as u64 + 1);
let hex_size_lat = 180.0 / (base_cells as f64);
let hex_size_lon = 360.0 / (base_cells as f64);
let row = ((cell_id >> 28) & 0x0FFF_FFFF) as f64;
let col = (cell_id & 0x0FFF_FFFF) as f64;
let lat = row * hex_size_lat - 90.0 + hex_size_lat / 2.0;
let lon = col * hex_size_lon - 180.0 + hex_size_lon / 2.0;
Ok((lat.clamp(-90.0, 90.0), lon.clamp(-180.0, 180.0)))
}
pub fn neighbors(cell_id: u64) -> SpatialResult<Vec<u64>> {
let resolution = (cell_id >> 56) as u8;
if resolution > Self::MAX_RESOLUTION {
return Err(SpatialError::ValueError(format!(
"Invalid resolution field in cell ID"
)));
}
let row = ((cell_id >> 28) & 0x0FFF_FFFF) as i64;
let col = (cell_id & 0x0FFF_FFFF) as i64;
let offsets_even: [(i64, i64); 6] = [
(0, 1), (0, -1), (-1, 0), (1, 0), (-1, -1), (1, -1),
];
let offsets_odd: [(i64, i64); 6] = [
(0, 1), (0, -1), (-1, 0), (1, 0), (-1, 1), (1, 1),
];
let offsets = if row % 2 == 0 { offsets_even } else { offsets_odd };
let base_cells: i64 = 1 << (resolution as i64 + 1);
let mut result = Vec::with_capacity(6);
for (dr, dc) in &offsets {
let nr = row + dr;
let nc = col + dc;
if nr < 0 || nr >= base_cells {
continue; }
let nc_wrapped = ((nc % base_cells) + base_cells) % base_cells;
let nid = ((resolution as u64) << 56) | ((nr as u64) << 28) | (nc_wrapped as u64 & 0x0FFF_FFFF);
result.push(nid);
}
Ok(result)
}
pub fn cell_area_km2(resolution: u8) -> SpatialResult<f64> {
if resolution > Self::MAX_RESOLUTION {
return Err(SpatialError::ValueError(format!(
"Resolution {resolution} exceeds maximum {}",
Self::MAX_RESOLUTION
)));
}
let num_cells = (1u64 << (resolution as u64 + 1)).pow(2) as f64;
Ok(510_072_000.0 / num_cells)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_geopoint_creation() {
assert!(GeoPoint::new(51.5074, -0.1278).is_ok());
assert!(GeoPoint::new(91.0, 0.0).is_err());
assert!(GeoPoint::new(0.0, 181.0).is_err());
}
#[test]
fn test_haversine_london_paris() {
let london = GeoPoint::new(51.5074, -0.1278).unwrap();
let paris = GeoPoint::new(48.8566, 2.3522).unwrap();
let dist = HaversineDistance::distance_km(&london, &paris);
assert!((dist - 343.5).abs() < 2.0, "Expected ~343.5 km, got {dist:.1}");
}
#[test]
fn test_vincenty_london_paris() {
let london = GeoPoint::new(51.5074, -0.1278).unwrap();
let paris = GeoPoint::new(48.8566, 2.3522).unwrap();
let dist = VincentyDistance::distance_km(&london, &paris)
.expect("Vincenty should converge");
assert!((dist - 343.5).abs() < 2.0, "Vincenty: expected ~343.5 km, got {dist:.1}");
}
#[test]
fn test_vincenty_same_point() {
let p = GeoPoint::new(0.0, 0.0).unwrap();
let d = VincentyDistance::distance_m(&p, &p).unwrap();
assert!(d.abs() < 1e-3);
}
#[test]
fn test_bounding_box_2d_contains() {
let bb = BoundingBox2D::new(50.0, 52.0, -1.0, 1.0).unwrap();
let inside = GeoPoint::new(51.0, 0.0).unwrap();
let outside = GeoPoint::new(53.0, 0.0).unwrap();
assert!(bb.contains(&inside));
assert!(!bb.contains(&outside));
}
#[test]
fn test_bounding_box_2d_overlap() {
let a = BoundingBox2D::new(0.0, 10.0, 0.0, 10.0).unwrap();
let b = BoundingBox2D::new(5.0, 15.0, 5.0, 15.0).unwrap();
let c = BoundingBox2D::new(20.0, 30.0, 20.0, 30.0).unwrap();
assert!(a.overlaps(&b));
assert!(!a.overlaps(&c));
}
#[test]
fn test_geohash_encode_decode() {
let hash = Geohash::encode(51.5074, -0.1278, 5).unwrap();
let (lat, lon) = Geohash::decode(&hash).unwrap();
assert!((lat - 51.5074).abs() < 0.1, "lat mismatch: {lat}");
assert!((lon - (-0.1278)).abs() < 0.1, "lon mismatch: {lon}");
}
#[test]
fn test_geohash_precision() {
let h7 = Geohash::encode(48.8566, 2.3522, 7).unwrap();
let h3 = Geohash::encode(48.8566, 2.3522, 3).unwrap();
let (lat7, lon7) = Geohash::decode(&h7).unwrap();
let (lat3, lon3) = Geohash::decode(&h3).unwrap();
let err7 = (lat7 - 48.8566).abs() + (lon7 - 2.3522).abs();
let err3 = (lat3 - 48.8566).abs() + (lon3 - 2.3522).abs();
assert!(err7 < err3, "Higher precision should decode more accurately");
}
#[test]
fn test_geohash_invalid_char() {
assert!(Geohash::decode("gcpvh!").is_err());
}
#[test]
fn test_geohash_neighbors_count() {
let hash = Geohash::encode(51.5074, -0.1278, 4).unwrap();
let neighbors = Geohash::neighbors(&hash).unwrap();
assert_eq!(neighbors.len(), 8);
}
#[test]
fn test_h3lite_encode_decode() {
let (lat, lon) = (37.7749, -122.4194); let cell_id = H3Lite::encode(lat, lon, 4).unwrap();
let (dlat, dlon) = H3Lite::decode(cell_id).unwrap();
assert!((dlat - lat).abs() < 5.0, "lat: {dlat}");
assert!((dlon - lon).abs() < 5.0, "lon: {dlon}");
}
#[test]
fn test_h3lite_neighbors() {
let cell_id = H3Lite::encode(0.0, 0.0, 3).unwrap();
let neighbors = H3Lite::neighbors(cell_id).unwrap();
assert!(!neighbors.is_empty());
assert!(neighbors.len() <= 6);
}
#[test]
fn test_h3lite_cell_area() {
let area = H3Lite::cell_area_km2(0).unwrap();
assert!(area > 0.0);
let area_fine = H3Lite::cell_area_km2(5).unwrap();
assert!(area_fine < area);
}
#[test]
fn test_geopoint_bearing() {
let london = GeoPoint::new(51.5074, -0.1278).unwrap();
let paris = GeoPoint::new(48.8566, 2.3522).unwrap();
let bearing = london.bearing_to(&paris);
assert!(bearing > 100.0 && bearing < 200.0, "bearing {bearing}");
}
#[test]
fn test_geopoint_destination() {
let origin = GeoPoint::new(0.0, 0.0).unwrap();
let dest = origin.destination(111_195.0, 0.0); assert!((dest.lat() - 1.0).abs() < 0.01, "Expected ~1°N, got {}°", dest.lat());
}
#[test]
fn test_geopoint_ecef() {
let origin = GeoPoint::new(0.0, 0.0).unwrap();
let (x, y, z) = origin.to_ecef();
assert!((x - WGS84_A).abs() < 1.0, "x={x}");
assert!(y.abs() < 1.0, "y={y}");
assert!(z.abs() < 1.0, "z={z}");
}
}