use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use geo::MapCoordsInPlace;
use geo_types::Geometry;
use proj4rs::Proj;
use crate::error::{Error, Result};
pub fn proj4_def(epsg: i32) -> Option<String> {
match epsg {
4326 => Some("+proj=longlat +datum=WGS84 +no_defs".into()),
3857 => Some(
"+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +no_defs"
.into(),
),
32601..=32660 => Some(format!(
"+proj=utm +zone={} +datum=WGS84 +units=m +no_defs",
epsg - 32600
)),
32701..=32760 => Some(format!(
"+proj=utm +zone={} +south +datum=WGS84 +units=m +no_defs",
epsg - 32700
)),
_ => None,
}
}
thread_local! {
static PROJ_CACHE: RefCell<HashMap<i32, Rc<Proj>>> = RefCell::new(HashMap::new());
}
fn unknown_epsg(func: &'static str, epsg: i32) -> Error {
Error::Unsupported {
func,
reason: format!(
"EPSG:{epsg} is not in kenro's built-in CRS table (WGS84 4326, WebMercator \
3857, WGS84 UTM 32601-32660 north / 32701-32760 south); enable the \
`crs-full` cargo feature for the full EPSG registry — see the README \
\"Supported CRS\" section"
),
}
}
fn cached_proj(func: &'static str, epsg: i32) -> Result<Rc<Proj>> {
PROJ_CACHE.with(|cache| {
if let Some(p) = cache.borrow().get(&epsg) {
return Ok(Rc::clone(p));
}
let proj = match proj4_def(epsg) {
Some(def) => Proj::from_proj_string(&def).map_err(|e| Error::Unsupported {
func,
reason: format!("EPSG:{epsg}: {e}"),
})?,
None => from_full_registry(func, epsg)?,
};
let proj = Rc::new(proj);
cache.borrow_mut().insert(epsg, Rc::clone(&proj));
Ok(proj)
})
}
#[cfg(feature = "crs-full")]
fn from_full_registry(func: &'static str, epsg: i32) -> Result<Proj> {
let code = u16::try_from(epsg).map_err(|_| unknown_epsg(func, epsg))?;
Proj::from_epsg_code(code).map_err(|_| unknown_epsg(func, epsg))
}
#[cfg(not(feature = "crs-full"))]
fn from_full_registry(func: &'static str, epsg: i32) -> Result<Proj> {
Err(unknown_epsg(func, epsg))
}
pub fn transform_geometry(
func: &'static str,
geometry: &mut Geometry<f64>,
from_epsg: i32,
to_epsg: i32,
) -> Result<()> {
let src = cached_proj(func, from_epsg)?;
let dst = cached_proj(func, to_epsg)?;
if src.is_latlong() {
geometry.map_coords_in_place(|c| (c.x.to_radians(), c.y.to_radians()).into());
}
proj4rs::transform::transform(&src, &dst, geometry).map_err(|e| Error::Unsupported {
func,
reason: format!("transform from EPSG:{from_epsg} to EPSG:{to_epsg} failed: {e}"),
})?;
if dst.is_latlong() {
geometry.map_coords_in_place(|c| (c.x.to_degrees(), c.y.to_degrees()).into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use geo_types::{Geometry, Point};
const TOKYO_TOWER: (f64, f64) = (139.745433, 35.658581);
const SYDNEY_OPERA: (f64, f64) = (151.215, -33.8568);
fn transform_point(x: f64, y: f64, from: i32, to: i32) -> (f64, f64) {
let mut g: Geometry<f64> = Geometry::Point(Point::new(x, y));
transform_geometry("test", &mut g, from, to).unwrap();
let Geometry::Point(p) = g else {
unreachable!()
};
(p.x(), p.y())
}
#[test]
fn every_curated_code_parses() {
let codes: Vec<i32> = [4326, 3857]
.into_iter()
.chain(32601..=32660)
.chain(32701..=32760)
.collect();
assert_eq!(codes.len(), 122);
for code in codes {
let def = proj4_def(code).unwrap_or_else(|| panic!("EPSG:{code} missing"));
Proj::from_proj_string(&def).unwrap_or_else(|e| panic!("EPSG:{code}: {e}"));
}
}
#[test]
fn web_mercator_matches_the_analytic_formula() {
let (x, y) = transform_point(TOKYO_TOWER.0, TOKYO_TOWER.1, 4326, 3857);
let r = 6378137.0;
let expect_x = r * TOKYO_TOWER.0.to_radians();
let expect_y = r
* (std::f64::consts::FRAC_PI_4 + TOKYO_TOWER.1.to_radians() / 2.0)
.tan()
.ln();
assert!((x - expect_x).abs() < 1e-6, "{x} vs {expect_x}");
assert!((y - expect_y).abs() < 1e-6, "{y} vs {expect_y}");
}
#[test]
fn utm_north_plausibility_and_roundtrip() {
let (x, y) = transform_point(TOKYO_TOWER.0, TOKYO_TOWER.1, 4326, 32654);
assert!((300_000.0..500_000.0).contains(&x), "easting {x}");
assert!((3_800_000.0..4_050_000.0).contains(&y), "northing {y}");
let (lon, lat) = transform_point(x, y, 32654, 4326);
assert!((lon - TOKYO_TOWER.0).abs() < 1e-9, "{lon}");
assert!((lat - TOKYO_TOWER.1).abs() < 1e-9, "{lat}");
}
#[test]
fn utm_south_has_the_false_northing() {
let (x, y) = transform_point(SYDNEY_OPERA.0, SYDNEY_OPERA.1, 4326, 32756);
assert!((300_000.0..400_000.0).contains(&x), "easting {x}");
assert!((6_200_000.0..6_300_000.0).contains(&y), "northing {y}");
let (lon, lat) = transform_point(x, y, 32756, 4326);
assert!((lon - SYDNEY_OPERA.0).abs() < 1e-9, "{lon}");
assert!((lat - SYDNEY_OPERA.1).abs() < 1e-9, "{lat}");
}
#[cfg(not(feature = "crs-full"))]
#[test]
fn unknown_epsg_names_the_code_and_the_readme() {
let mut g: Geometry<f64> = Geometry::Point(Point::new(0.0, 0.0));
let err = transform_geometry("ST_Transform", &mut g, 4326, 27700).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("EPSG:27700"), "{msg}");
assert!(msg.contains("Supported CRS"), "{msg}");
assert!(msg.contains("crs-full"), "{msg}");
}
}