Skip to main content

proj_core/
transform.rs

1use crate::coord::{Coord, Transformable};
2use crate::crs::CrsDef;
3use crate::datum::Datum;
4use crate::error::{Error, Result};
5use crate::geocentric;
6use crate::helmert;
7use crate::projection::{make_projection, ProjectionImpl};
8use crate::registry;
9
10/// A reusable coordinate transformation between two CRS.
11///
12/// Create once with [`Transform::new`], then call [`convert`](Transform::convert)
13/// for each coordinate. All input/output coordinates use the CRS's native units:
14/// degrees (lon/lat) for geographic CRS, meters for projected CRS.
15///
16/// # Example
17///
18/// ```
19/// use proj_core::Transform;
20///
21/// let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
22/// let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
23/// assert!((x - (-8238310.0)).abs() < 1.0);
24/// ```
25pub struct Transform {
26    pipeline: TransformPipeline,
27}
28
29enum TransformPipeline {
30    /// Source and target are the same CRS — no-op.
31    Identity,
32    /// Same datum, geographic source → projected target.
33    SameDatumForward { forward: Box<dyn ProjectionImpl> },
34    /// Same datum, projected source → geographic target.
35    SameDatumInverse { inverse: Box<dyn ProjectionImpl> },
36    /// Same datum, projected source → projected target.
37    SameDatumBoth {
38        inverse: Box<dyn ProjectionImpl>,
39        forward: Box<dyn ProjectionImpl>,
40    },
41    /// Different datums — full pipeline with geocentric conversion and Helmert shift.
42    DatumShift {
43        inverse: Option<Box<dyn ProjectionImpl>>,
44        source_datum: Datum,
45        target_datum: Datum,
46        forward: Option<Box<dyn ProjectionImpl>>,
47    },
48}
49
50impl Transform {
51    /// Create a transform from authority code strings (e.g., `"EPSG:4326"`).
52    ///
53    /// This is the primary constructor, matching the API pattern from C PROJ's
54    /// `Proj::new_known_crs()`.
55    pub fn new(from_crs: &str, to_crs: &str) -> Result<Self> {
56        let source = registry::lookup_authority_code(from_crs)?;
57        let target = registry::lookup_authority_code(to_crs)?;
58        Self::from_crs_defs(&source, &target)
59    }
60
61    /// Create a transform from EPSG codes directly.
62    pub fn from_epsg(from: u32, to: u32) -> Result<Self> {
63        let source = registry::lookup_epsg(from)
64            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {from}")))?;
65        let target = registry::lookup_epsg(to)
66            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {to}")))?;
67        Self::from_crs_defs(&source, &target)
68    }
69
70    /// Create a transform from explicit CRS definitions.
71    ///
72    /// Use this for custom CRS not in the built-in registry.
73    pub fn from_crs_defs(from: &CrsDef, to: &CrsDef) -> Result<Self> {
74        // Identity check — only for known EPSG codes (not custom CRS with epsg=0)
75        if from.epsg() != 0 && from.epsg() == to.epsg() {
76            return Ok(Self {
77                pipeline: TransformPipeline::Identity,
78            });
79        }
80
81        let source_datum = from.datum();
82        let target_datum = to.datum();
83
84        let same_datum = source_datum.same_datum(target_datum)
85            || (source_datum.is_wgs84_compatible() && target_datum.is_wgs84_compatible());
86
87        let pipeline = if same_datum {
88            // Optimized path: no datum shift needed
89            match (from, to) {
90                (CrsDef::Geographic(_), CrsDef::Geographic(_)) => TransformPipeline::Identity,
91                (CrsDef::Geographic(_), CrsDef::Projected(p)) => {
92                    let forward = make_projection(&p.method, &p.datum)?;
93                    TransformPipeline::SameDatumForward { forward }
94                }
95                (CrsDef::Projected(p), CrsDef::Geographic(_)) => {
96                    let inverse = make_projection(&p.method, &p.datum)?;
97                    TransformPipeline::SameDatumInverse { inverse }
98                }
99                (CrsDef::Projected(p_from), CrsDef::Projected(p_to)) => {
100                    let inverse = make_projection(&p_from.method, &p_from.datum)?;
101                    let forward = make_projection(&p_to.method, &p_to.datum)?;
102                    TransformPipeline::SameDatumBoth { inverse, forward }
103                }
104            }
105        } else {
106            // Both datums must have a path to WGS84
107            if !source_datum.is_wgs84_compatible() && source_datum.to_wgs84.is_none() {
108                return Err(Error::UnsupportedProjection(format!(
109                    "source CRS EPSG:{} has no known datum shift to WGS84",
110                    from.epsg()
111                )));
112            }
113            if !target_datum.is_wgs84_compatible() && target_datum.to_wgs84.is_none() {
114                return Err(Error::UnsupportedProjection(format!(
115                    "target CRS EPSG:{} has no known datum shift to WGS84",
116                    to.epsg()
117                )));
118            }
119
120            let inverse = match from {
121                CrsDef::Projected(p) => Some(make_projection(&p.method, &p.datum)?),
122                CrsDef::Geographic(_) => None,
123            };
124            let forward = match to {
125                CrsDef::Projected(p) => Some(make_projection(&p.method, &p.datum)?),
126                CrsDef::Geographic(_) => None,
127            };
128
129            TransformPipeline::DatumShift {
130                inverse,
131                source_datum: *source_datum,
132                target_datum: *target_datum,
133                forward,
134            }
135        };
136
137        Ok(Self { pipeline })
138    }
139
140    /// Transform a single coordinate.
141    ///
142    /// Input and output units are the native units of the respective CRS:
143    /// degrees for geographic CRS, meters for projected CRS.
144    ///
145    /// The return type matches the input type:
146    /// - `(f64, f64)` in → `(f64, f64)` out
147    /// - `Coord` in → `Coord` out
148    /// - `geo_types::Coord<f64>` in → `geo_types::Coord<f64>` out (with `geo-types` feature)
149    pub fn convert<T: Transformable>(&self, coord: T) -> Result<T> {
150        let c = coord.into_coord();
151        let result = self.convert_coord(c)?;
152        Ok(T::from_coord(result))
153    }
154
155    /// Transform a single `Coord` value.
156    fn convert_coord(&self, c: Coord) -> Result<Coord> {
157        match &self.pipeline {
158            TransformPipeline::Identity => Ok(c),
159
160            TransformPipeline::SameDatumForward { forward } => {
161                // Source is geographic (degrees) → radians → forward project → meters
162                let lon_rad = c.x.to_radians();
163                let lat_rad = c.y.to_radians();
164                let (x, y) = forward.forward(lon_rad, lat_rad)?;
165                Ok(Coord { x, y })
166            }
167
168            TransformPipeline::SameDatumInverse { inverse } => {
169                // Source is projected (meters) → inverse project → radians → degrees
170                let (lon_rad, lat_rad) = inverse.inverse(c.x, c.y)?;
171                Ok(Coord {
172                    x: lon_rad.to_degrees(),
173                    y: lat_rad.to_degrees(),
174                })
175            }
176
177            TransformPipeline::SameDatumBoth { inverse, forward } => {
178                // Projected → inverse → radians → forward → projected
179                let (lon_rad, lat_rad) = inverse.inverse(c.x, c.y)?;
180                let (x, y) = forward.forward(lon_rad, lat_rad)?;
181                Ok(Coord { x, y })
182            }
183
184            TransformPipeline::DatumShift {
185                inverse,
186                source_datum,
187                target_datum,
188                forward,
189            } => {
190                // Step 1: Get geographic coords in radians on source datum
191                let (lon_rad, lat_rad) = if let Some(inv) = inverse {
192                    inv.inverse(c.x, c.y)?
193                } else {
194                    (c.x.to_radians(), c.y.to_radians())
195                };
196
197                // Step 2: Source geodetic → geocentric (source ellipsoid)
198                let (x, y, z) = geocentric::geodetic_to_geocentric(
199                    &source_datum.ellipsoid,
200                    lon_rad,
201                    lat_rad,
202                    0.0,
203                );
204
205                // Step 3: Helmert: source datum → WGS84
206                let (x2, y2, z2) = if let Some(params) = &source_datum.to_wgs84 {
207                    helmert::helmert_forward(params, x, y, z)
208                } else {
209                    (x, y, z) // already WGS84
210                };
211
212                // Step 4: Helmert: WGS84 → target datum (inverse)
213                let (x3, y3, z3) = if let Some(params) = &target_datum.to_wgs84 {
214                    helmert::helmert_inverse(params, x2, y2, z2)
215                } else {
216                    (x2, y2, z2) // target is WGS84
217                };
218
219                // Step 5: Geocentric → geodetic (target ellipsoid)
220                let (lon_out, lat_out, _h) =
221                    geocentric::geocentric_to_geodetic(&target_datum.ellipsoid, x3, y3, z3);
222
223                // Step 6: Forward project if target is projected, else convert to degrees
224                if let Some(fwd) = forward {
225                    let (x, y) = fwd.forward(lon_out, lat_out)?;
226                    Ok(Coord { x, y })
227                } else {
228                    Ok(Coord {
229                        x: lon_out.to_degrees(),
230                        y: lat_out.to_degrees(),
231                    })
232                }
233            }
234        }
235    }
236
237    /// Batch transform (sequential).
238    pub fn convert_batch<T: Transformable + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
239        coords.iter().map(|c| self.convert(c.clone())).collect()
240    }
241
242    /// Batch transform with Rayon parallelism.
243    #[cfg(feature = "rayon")]
244    pub fn convert_batch_parallel<T: Transformable + Send + Sync + Clone>(
245        &self,
246        coords: &[T],
247    ) -> Result<Vec<T>> {
248        use rayon::prelude::*;
249        coords.par_iter().map(|c| self.convert(c.clone())).collect()
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn identity_same_crs() {
259        let t = Transform::new("EPSG:4326", "EPSG:4326").unwrap();
260        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
261        assert_eq!(x, -74.006);
262        assert_eq!(y, 40.7128);
263    }
264
265    #[test]
266    fn wgs84_to_web_mercator() {
267        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
268        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
269        // NYC in Web Mercator
270        assert!((x - (-8238310.0)).abs() < 100.0, "x = {x}");
271        assert!((y - 4970072.0).abs() < 100.0, "y = {y}");
272    }
273
274    #[test]
275    fn web_mercator_to_wgs84() {
276        let t = Transform::new("EPSG:3857", "EPSG:4326").unwrap();
277        let (lon, lat) = t.convert((-8238310.0, 4970072.0)).unwrap();
278        assert!((lon - (-74.006)).abs() < 0.001, "lon = {lon}");
279        assert!((lat - 40.7128).abs() < 0.001, "lat = {lat}");
280    }
281
282    #[test]
283    fn roundtrip_4326_3857() {
284        let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
285        let inv = Transform::new("EPSG:3857", "EPSG:4326").unwrap();
286
287        let original = (-74.0445, 40.6892);
288        let projected = fwd.convert(original).unwrap();
289        let back = inv.convert(projected).unwrap();
290
291        assert!(
292            (back.0 - original.0).abs() < 1e-8,
293            "lon: {} vs {}",
294            back.0,
295            original.0
296        );
297        assert!(
298            (back.1 - original.1).abs() < 1e-8,
299            "lat: {} vs {}",
300            back.1,
301            original.1
302        );
303    }
304
305    #[test]
306    fn wgs84_to_utm_18n() {
307        let t = Transform::new("EPSG:4326", "EPSG:32618").unwrap();
308        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
309        assert!((x - 583960.0).abs() < 1.0, "easting = {x}");
310        assert!(y > 4_500_000.0 && y < 4_510_000.0, "northing = {y}");
311    }
312
313    #[test]
314    fn utm_to_web_mercator() {
315        // Projected → Projected (SameDatumBoth)
316        let t = Transform::new("EPSG:32618", "EPSG:3857").unwrap();
317        // NYC in UTM 18N
318        let (x, _y) = t.convert((583960.0, 4507523.0)).unwrap();
319        // Should be near NYC in Web Mercator
320        assert!((x - (-8238310.0)).abs() < 200.0, "x = {x}");
321    }
322
323    #[test]
324    fn wgs84_to_polar_stereo_3413() {
325        let t = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
326        let (x, y) = t.convert((-45.0, 90.0)).unwrap();
327        // North pole should be at origin for EPSG:3413
328        assert!(x.abs() < 1.0, "x = {x}");
329        assert!(y.abs() < 1.0, "y = {y}");
330    }
331
332    #[test]
333    fn roundtrip_4326_3413() {
334        let fwd = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
335        let inv = Transform::new("EPSG:3413", "EPSG:4326").unwrap();
336
337        let original = (-45.0, 75.0);
338        let projected = fwd.convert(original).unwrap();
339        let back = inv.convert(projected).unwrap();
340
341        assert!(
342            (back.0 - original.0).abs() < 1e-6,
343            "lon: {} vs {}",
344            back.0,
345            original.0
346        );
347        assert!(
348            (back.1 - original.1).abs() < 1e-6,
349            "lat: {} vs {}",
350            back.1,
351            original.1
352        );
353    }
354
355    #[test]
356    fn geographic_to_geographic_same_datum_is_identity() {
357        // NAD83 and WGS84 are both WGS84-compatible
358        let t = Transform::new("EPSG:4269", "EPSG:4326").unwrap();
359        let (lon, lat) = t.convert((-74.006, 40.7128)).unwrap();
360        assert_eq!(lon, -74.006);
361        assert_eq!(lat, 40.7128);
362    }
363
364    #[test]
365    fn unknown_crs_error() {
366        let result = Transform::new("EPSG:99999", "EPSG:4326");
367        assert!(result.is_err());
368    }
369
370    #[test]
371    fn cross_datum_nad27_to_wgs84() {
372        let t = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
373        let (lon, lat) = t.convert((-90.0, 45.0)).unwrap();
374        // NAD27→WGS84 shift is small (tens of meters → fraction of degree)
375        assert!((lon - (-90.0)).abs() < 0.01, "lon = {lon}");
376        assert!((lat - 45.0).abs() < 0.01, "lat = {lat}");
377    }
378
379    #[test]
380    fn cross_datum_roundtrip_nad27() {
381        let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
382        let inv = Transform::new("EPSG:4326", "EPSG:4267").unwrap();
383        let original = (-90.0, 45.0);
384        let shifted = fwd.convert(original).unwrap();
385        let back = inv.convert(shifted).unwrap();
386        assert!(
387            (back.0 - original.0).abs() < 1e-6,
388            "lon: {} vs {}",
389            back.0,
390            original.0
391        );
392        assert!(
393            (back.1 - original.1).abs() < 1e-6,
394            "lat: {} vs {}",
395            back.1,
396            original.1
397        );
398    }
399
400    #[test]
401    fn cross_datum_osgb36_to_wgs84() {
402        let t = Transform::new("EPSG:4277", "EPSG:4326").unwrap();
403        let (lon, lat) = t.convert((-0.1278, 51.5074)).unwrap(); // London
404                                                                 // OSGB36→WGS84 shift is larger due to 7-parameter Helmert
405        assert!((lon - (-0.1278)).abs() < 0.01, "lon = {lon}");
406        assert!((lat - 51.5074).abs() < 0.01, "lat = {lat}");
407    }
408
409    #[test]
410    fn batch_transform() {
411        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
412        let coords: Vec<(f64, f64)> = (0..10)
413            .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
414            .collect();
415
416        let results = t.convert_batch(&coords).unwrap();
417        assert_eq!(results.len(), 10);
418        for (x, _y) in &results {
419            assert!(*x < 0.0); // all points are west of prime meridian
420        }
421    }
422
423    #[test]
424    fn coord_type() {
425        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
426        let c = Coord::new(-74.006, 40.7128);
427        let result = t.convert(c).unwrap();
428        assert!((result.x - (-8238310.0)).abs() < 100.0);
429    }
430
431    #[cfg(feature = "geo-types")]
432    #[test]
433    fn geo_types_coord() {
434        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
435        let c = geo_types::Coord {
436            x: -74.006,
437            y: 40.7128,
438        };
439        let result: geo_types::Coord<f64> = t.convert(c).unwrap();
440        assert!((result.x - (-8238310.0)).abs() < 100.0);
441    }
442
443    #[cfg(feature = "rayon")]
444    #[test]
445    fn parallel_batch_transform() {
446        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
447        let coords: Vec<(f64, f64)> = (0..100)
448            .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01))
449            .collect();
450
451        let results = t.convert_batch_parallel(&coords).unwrap();
452        assert_eq!(results.len(), 100);
453    }
454}