Skip to main content

proj_core/
transform.rs

1use crate::coord::{Bounds, Coord, Coord3D, Transformable, Transformable3D};
2use crate::crs::{CrsDef, ProjectionMethod};
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#[cfg(feature = "rayon")]
11const PARALLEL_MIN_ITEMS_PER_THREAD: usize = 2_048;
12#[cfg(feature = "rayon")]
13const PARALLEL_CHUNKS_PER_THREAD: usize = 4;
14#[cfg(feature = "rayon")]
15const PARALLEL_MIN_CHUNK_SIZE: usize = 1_024;
16
17/// A reusable coordinate transformation between two CRS.
18///
19/// Create once with [`Transform::new`], then call [`convert`](Transform::convert)
20/// or [`convert_3d`](Transform::convert_3d) for each coordinate. All input/output
21/// coordinates use the CRS's native units: degrees (lon/lat) for geographic CRS,
22/// and the CRS's native projected linear unit for projected CRS. For 3D
23/// coordinates, the third ordinate is preserved
24/// unchanged by the current horizontal-only pipeline.
25///
26/// # Example
27///
28/// ```
29/// use proj_core::Transform;
30///
31/// let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
32/// let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
33/// assert!((x - (-8238310.0)).abs() < 1.0);
34/// ```
35pub struct Transform {
36    source: CrsDef,
37    target: CrsDef,
38    pipeline: TransformPipeline,
39}
40
41enum TransformPipeline {
42    /// Source and target are the same CRS — no-op.
43    Identity,
44    /// Same datum, geographic source → projected target.
45    SameDatumForward { forward: Box<dyn ProjectionImpl> },
46    /// Same datum, projected source → geographic target.
47    SameDatumInverse { inverse: Box<dyn ProjectionImpl> },
48    /// Same datum, projected source → projected target.
49    SameDatumBoth {
50        inverse: Box<dyn ProjectionImpl>,
51        forward: Box<dyn ProjectionImpl>,
52    },
53    /// Different datums — full pipeline with geocentric conversion and Helmert shift.
54    DatumShift {
55        inverse: Option<Box<dyn ProjectionImpl>>,
56        source_datum: Datum,
57        target_datum: Datum,
58        forward: Option<Box<dyn ProjectionImpl>>,
59    },
60}
61
62impl Transform {
63    /// Create a transform from authority code strings (e.g., `"EPSG:4326"`).
64    ///
65    /// This is the primary constructor, matching the API pattern from C PROJ's
66    /// `Proj::new_known_crs()`.
67    pub fn new(from_crs: &str, to_crs: &str) -> Result<Self> {
68        let source = registry::lookup_authority_code(from_crs)?;
69        let target = registry::lookup_authority_code(to_crs)?;
70        Self::from_crs_defs(&source, &target)
71    }
72
73    /// Create a transform from EPSG codes directly.
74    pub fn from_epsg(from: u32, to: u32) -> Result<Self> {
75        let source = registry::lookup_epsg(from)
76            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {from}")))?;
77        let target = registry::lookup_epsg(to)
78            .ok_or_else(|| Error::UnknownCrs(format!("unknown EPSG code: {to}")))?;
79        Self::from_crs_defs(&source, &target)
80    }
81
82    /// Create a transform from explicit CRS definitions.
83    ///
84    /// Use this for custom CRS not in the built-in registry.
85    pub fn from_crs_defs(from: &CrsDef, to: &CrsDef) -> Result<Self> {
86        // Identity check for known EPSG codes and semantically identical custom CRS definitions.
87        if (from.epsg() != 0 && from.epsg() == to.epsg()) || same_crs_definition(from, to) {
88            return Ok(Self {
89                source: *from,
90                target: *to,
91                pipeline: TransformPipeline::Identity,
92            });
93        }
94
95        let source_datum = from.datum();
96        let target_datum = to.datum();
97
98        let same_datum = source_datum.same_datum(target_datum)
99            || (source_datum.is_wgs84_compatible() && target_datum.is_wgs84_compatible());
100
101        let pipeline = if same_datum {
102            // Optimized path: no datum shift needed
103            match (from, to) {
104                (CrsDef::Geographic(_), CrsDef::Geographic(_)) => TransformPipeline::Identity,
105                (CrsDef::Geographic(_), CrsDef::Projected(p)) => {
106                    let forward = make_projection(&p.method(), p.datum())?;
107                    TransformPipeline::SameDatumForward { forward }
108                }
109                (CrsDef::Projected(p), CrsDef::Geographic(_)) => {
110                    let inverse = make_projection(&p.method(), p.datum())?;
111                    TransformPipeline::SameDatumInverse { inverse }
112                }
113                (CrsDef::Projected(p_from), CrsDef::Projected(p_to)) => {
114                    let inverse = make_projection(&p_from.method(), p_from.datum())?;
115                    let forward = make_projection(&p_to.method(), p_to.datum())?;
116                    TransformPipeline::SameDatumBoth { inverse, forward }
117                }
118            }
119        } else {
120            // Both datums must have a path to WGS84
121            if !source_datum.is_wgs84_compatible() && source_datum.to_wgs84.is_none() {
122                return Err(Error::UnsupportedProjection(format!(
123                    "source CRS EPSG:{} has no known datum shift to WGS84",
124                    from.epsg()
125                )));
126            }
127            if !target_datum.is_wgs84_compatible() && target_datum.to_wgs84.is_none() {
128                return Err(Error::UnsupportedProjection(format!(
129                    "target CRS EPSG:{} has no known datum shift to WGS84",
130                    to.epsg()
131                )));
132            }
133
134            let inverse = match from {
135                CrsDef::Projected(p) => Some(make_projection(&p.method(), p.datum())?),
136                CrsDef::Geographic(_) => None,
137            };
138            let forward = match to {
139                CrsDef::Projected(p) => Some(make_projection(&p.method(), p.datum())?),
140                CrsDef::Geographic(_) => None,
141            };
142
143            TransformPipeline::DatumShift {
144                inverse,
145                source_datum: *source_datum,
146                target_datum: *target_datum,
147                forward,
148            }
149        };
150
151        Ok(Self {
152            source: *from,
153            target: *to,
154            pipeline,
155        })
156    }
157
158    /// Transform a single coordinate.
159    ///
160    /// Input and output units are the native units of the respective CRS:
161    /// degrees for geographic CRS, and the CRS's native projected linear unit
162    /// for projected CRS.
163    ///
164    /// The return type matches the input type:
165    /// - `(f64, f64)` in → `(f64, f64)` out
166    /// - `Coord` in → `Coord` out
167    /// - `geo_types::Coord<f64>` in → `geo_types::Coord<f64>` out (with `geo-types` feature)
168    pub fn convert<T: Transformable>(&self, coord: T) -> Result<T> {
169        let c = coord.into_coord();
170        let result = self.convert_coord(c)?;
171        Ok(T::from_coord(result))
172    }
173
174    /// Transform a single 3D coordinate.
175    ///
176    /// The horizontal components use the CRS's native units:
177    /// degrees for geographic CRS and the CRS's native projected linear unit
178    /// for projected CRS.
179    /// The vertical component is carried through unchanged because the current
180    /// CRS model is horizontal-only.
181    ///
182    /// The return type matches the input type:
183    /// - `(f64, f64, f64)` in → `(f64, f64, f64)` out
184    /// - `Coord3D` in → `Coord3D` out
185    pub fn convert_3d<T: Transformable3D>(&self, coord: T) -> Result<T> {
186        let c = coord.into_coord3d();
187        let result = self.convert_coord3d(c)?;
188        Ok(T::from_coord3d(result))
189    }
190
191    /// Return the source CRS definition for this transform.
192    pub fn source_crs(&self) -> &CrsDef {
193        &self.source
194    }
195
196    /// Return the target CRS definition for this transform.
197    pub fn target_crs(&self) -> &CrsDef {
198        &self.target
199    }
200
201    /// Build the inverse transform by swapping the source and target CRS.
202    pub fn inverse(&self) -> Result<Self> {
203        Self::from_crs_defs(&self.target, &self.source)
204    }
205
206    /// Reproject a 2D bounding box by sampling its perimeter.
207    ///
208    /// `densify_points` controls how many evenly spaced interior points are sampled
209    /// on each edge between the corners. `0` samples only the four corners.
210    ///
211    /// The returned bounds are axis-aligned in the target CRS. Geographic outputs
212    /// that cross the antimeridian are not normalized into a wrapped representation.
213    pub fn transform_bounds(&self, bounds: Bounds, densify_points: usize) -> Result<Bounds> {
214        if !bounds.is_valid() {
215            return Err(Error::OutOfRange(
216                "bounds must be finite and satisfy min <= max".into(),
217            ));
218        }
219
220        let segments = densify_points
221            .checked_add(1)
222            .ok_or_else(|| Error::OutOfRange("densify point count is too large".into()))?;
223
224        let mut transformed: Option<Bounds> = None;
225        for i in 0..=segments {
226            let t = i as f64 / segments as f64;
227            let x = bounds.min_x + bounds.width() * t;
228            let y = bounds.min_y + bounds.height() * t;
229
230            for sample in [
231                Coord::new(x, bounds.min_y),
232                Coord::new(x, bounds.max_y),
233                Coord::new(bounds.min_x, y),
234                Coord::new(bounds.max_x, y),
235            ] {
236                let coord = self.convert_coord(sample)?;
237                if let Some(accum) = &mut transformed {
238                    accum.expand_to_include(coord);
239                } else {
240                    transformed = Some(Bounds::new(coord.x, coord.y, coord.x, coord.y));
241                }
242            }
243        }
244
245        transformed.ok_or_else(|| Error::OutOfRange("failed to sample bounds".into()))
246    }
247
248    /// Transform a single `Coord` value.
249    fn convert_coord(&self, c: Coord) -> Result<Coord> {
250        let result = self.convert_coord3d(Coord3D::new(c.x, c.y, 0.0))?;
251        Ok(Coord {
252            x: result.x,
253            y: result.y,
254        })
255    }
256
257    /// Transform a single `Coord3D` value.
258    fn convert_coord3d(&self, c: Coord3D) -> Result<Coord3D> {
259        match &self.pipeline {
260            TransformPipeline::Identity => Ok(c),
261
262            TransformPipeline::SameDatumForward { forward } => {
263                // Source is geographic (degrees) → radians → forward project → meters
264                // → target native projected units.
265                let lon_rad = c.x.to_radians();
266                let lat_rad = c.y.to_radians();
267                let (x_m, y_m) = forward.forward(lon_rad, lat_rad)?;
268                let (x, y) = self.projected_meters_to_target_native(x_m, y_m);
269                Ok(Coord3D { x, y, z: c.z })
270            }
271
272            TransformPipeline::SameDatumInverse { inverse } => {
273                // Source is projected (native units) → meters → inverse project
274                // → radians → degrees.
275                let (x_m, y_m) = self.source_projected_native_to_meters(c.x, c.y);
276                let (lon_rad, lat_rad) = inverse.inverse(x_m, y_m)?;
277                Ok(Coord3D {
278                    x: lon_rad.to_degrees(),
279                    y: lat_rad.to_degrees(),
280                    z: c.z,
281                })
282            }
283
284            TransformPipeline::SameDatumBoth { inverse, forward } => {
285                // Projected native units → meters → inverse → radians → forward
286                // → meters → target native units.
287                let (x_m, y_m) = self.source_projected_native_to_meters(c.x, c.y);
288                let (lon_rad, lat_rad) = inverse.inverse(x_m, y_m)?;
289                let (x_m, y_m) = forward.forward(lon_rad, lat_rad)?;
290                let (x, y) = self.projected_meters_to_target_native(x_m, y_m);
291                Ok(Coord3D { x, y, z: c.z })
292            }
293
294            TransformPipeline::DatumShift {
295                inverse,
296                source_datum,
297                target_datum,
298                forward,
299            } => {
300                // Step 1: Get geographic coords in radians on source datum
301                let (lon_rad, lat_rad) = if let Some(inv) = inverse {
302                    let (x_m, y_m) = self.source_projected_native_to_meters(c.x, c.y);
303                    inv.inverse(x_m, y_m)?
304                } else {
305                    (c.x.to_radians(), c.y.to_radians())
306                };
307
308                // Step 2: Source geodetic → geocentric (source ellipsoid)
309                let (x, y, z) = geocentric::geodetic_to_geocentric(
310                    &source_datum.ellipsoid,
311                    lon_rad,
312                    lat_rad,
313                    0.0,
314                );
315
316                // Step 3: Helmert: source datum → WGS84
317                let (x2, y2, z2) = if let Some(params) = &source_datum.to_wgs84 {
318                    helmert::helmert_forward(params, x, y, z)
319                } else {
320                    (x, y, z) // already WGS84
321                };
322
323                // Step 4: Helmert: WGS84 → target datum (inverse)
324                let (x3, y3, z3) = if let Some(params) = &target_datum.to_wgs84 {
325                    helmert::helmert_inverse(params, x2, y2, z2)
326                } else {
327                    (x2, y2, z2) // target is WGS84
328                };
329
330                // Step 5: Geocentric → geodetic (target ellipsoid)
331                let (lon_out, lat_out, _h_out) =
332                    geocentric::geocentric_to_geodetic(&target_datum.ellipsoid, x3, y3, z3);
333
334                // Step 6: Forward project if target is projected, else convert to degrees
335                if let Some(fwd) = forward {
336                    let (x_m, y_m) = fwd.forward(lon_out, lat_out)?;
337                    let (x, y) = self.projected_meters_to_target_native(x_m, y_m);
338                    Ok(Coord3D { x, y, z: c.z })
339                } else {
340                    Ok(Coord3D {
341                        x: lon_out.to_degrees(),
342                        y: lat_out.to_degrees(),
343                        z: c.z,
344                    })
345                }
346            }
347        }
348    }
349
350    fn source_projected_native_to_meters(&self, x: f64, y: f64) -> (f64, f64) {
351        match self.source {
352            CrsDef::Projected(p) => (p.linear_unit().to_meters(x), p.linear_unit().to_meters(y)),
353            CrsDef::Geographic(_) => (x, y),
354        }
355    }
356
357    fn projected_meters_to_target_native(&self, x: f64, y: f64) -> (f64, f64) {
358        match self.target {
359            CrsDef::Projected(p) => (
360                p.linear_unit().from_meters(x),
361                p.linear_unit().from_meters(y),
362            ),
363            CrsDef::Geographic(_) => (x, y),
364        }
365    }
366
367    /// Batch transform (sequential).
368    pub fn convert_batch<T: Transformable + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
369        coords.iter().map(|c| self.convert(c.clone())).collect()
370    }
371
372    /// Batch transform of 3D coordinates (sequential).
373    pub fn convert_batch_3d<T: Transformable3D + Clone>(&self, coords: &[T]) -> Result<Vec<T>> {
374        coords.iter().map(|c| self.convert_3d(c.clone())).collect()
375    }
376
377    /// Batch transform with Rayon parallelism.
378    ///
379    /// For smaller batches, this falls back to the sequential path to avoid
380    /// parallel overhead dominating the total runtime.
381    #[cfg(feature = "rayon")]
382    pub fn convert_batch_parallel<T: Transformable + Send + Sync + Clone>(
383        &self,
384        coords: &[T],
385    ) -> Result<Vec<T>> {
386        self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch(chunk))
387    }
388
389    /// Batch transform of 3D coordinates with adaptive Rayon parallelism.
390    #[cfg(feature = "rayon")]
391    pub fn convert_batch_parallel_3d<T: Transformable3D + Send + Sync + Clone>(
392        &self,
393        coords: &[T],
394    ) -> Result<Vec<T>> {
395        self.convert_batch_parallel_adaptive(coords, |this, chunk| this.convert_batch_3d(chunk))
396    }
397
398    #[cfg(feature = "rayon")]
399    fn convert_batch_parallel_adaptive<T, F>(&self, coords: &[T], convert: F) -> Result<Vec<T>>
400    where
401        T: Send + Sync + Clone,
402        F: Fn(&Self, &[T]) -> Result<Vec<T>> + Sync,
403    {
404        if !should_parallelize(coords.len()) {
405            return convert(self, coords);
406        }
407
408        use rayon::prelude::*;
409
410        let chunk_size = parallel_chunk_size(coords.len());
411        let chunk_results: Vec<Result<Vec<T>>> = coords
412            .par_chunks(chunk_size)
413            .map(|chunk| convert(self, chunk))
414            .collect();
415
416        let mut results = Vec::with_capacity(coords.len());
417        for chunk in chunk_results {
418            results.extend(chunk?);
419        }
420        Ok(results)
421    }
422}
423
424#[cfg(feature = "rayon")]
425fn should_parallelize(len: usize) -> bool {
426    if len == 0 {
427        return false;
428    }
429
430    let threads = rayon::current_num_threads().max(1);
431    len >= threads.saturating_mul(PARALLEL_MIN_ITEMS_PER_THREAD)
432}
433
434#[cfg(feature = "rayon")]
435fn parallel_chunk_size(len: usize) -> usize {
436    let threads = rayon::current_num_threads().max(1);
437    let target_chunks = threads.saturating_mul(PARALLEL_CHUNKS_PER_THREAD).max(1);
438    let chunk_size = len.div_ceil(target_chunks);
439    chunk_size.max(PARALLEL_MIN_CHUNK_SIZE)
440}
441
442fn same_crs_definition(from: &CrsDef, to: &CrsDef) -> bool {
443    match (from, to) {
444        (CrsDef::Geographic(a), CrsDef::Geographic(b)) => a.datum().same_datum(b.datum()),
445        (CrsDef::Projected(a), CrsDef::Projected(b)) => {
446            a.datum().same_datum(b.datum())
447                && approx_eq(a.linear_unit_to_meter(), b.linear_unit_to_meter())
448                && projection_methods_equivalent(&a.method(), &b.method())
449        }
450        _ => false,
451    }
452}
453
454fn projection_methods_equivalent(a: &ProjectionMethod, b: &ProjectionMethod) -> bool {
455    match (a, b) {
456        (ProjectionMethod::WebMercator, ProjectionMethod::WebMercator) => true,
457        (
458            ProjectionMethod::TransverseMercator {
459                lon0: a_lon0,
460                lat0: a_lat0,
461                k0: a_k0,
462                false_easting: a_false_easting,
463                false_northing: a_false_northing,
464            },
465            ProjectionMethod::TransverseMercator {
466                lon0: b_lon0,
467                lat0: b_lat0,
468                k0: b_k0,
469                false_easting: b_false_easting,
470                false_northing: b_false_northing,
471            },
472        ) => {
473            approx_eq(*a_lon0, *b_lon0)
474                && approx_eq(*a_lat0, *b_lat0)
475                && approx_eq(*a_k0, *b_k0)
476                && approx_eq(*a_false_easting, *b_false_easting)
477                && approx_eq(*a_false_northing, *b_false_northing)
478        }
479        (
480            ProjectionMethod::PolarStereographic {
481                lon0: a_lon0,
482                lat_ts: a_lat_ts,
483                k0: a_k0,
484                false_easting: a_false_easting,
485                false_northing: a_false_northing,
486            },
487            ProjectionMethod::PolarStereographic {
488                lon0: b_lon0,
489                lat_ts: b_lat_ts,
490                k0: b_k0,
491                false_easting: b_false_easting,
492                false_northing: b_false_northing,
493            },
494        ) => {
495            approx_eq(*a_lon0, *b_lon0)
496                && approx_eq(*a_lat_ts, *b_lat_ts)
497                && approx_eq(*a_k0, *b_k0)
498                && approx_eq(*a_false_easting, *b_false_easting)
499                && approx_eq(*a_false_northing, *b_false_northing)
500        }
501        (
502            ProjectionMethod::LambertConformalConic {
503                lon0: a_lon0,
504                lat0: a_lat0,
505                lat1: a_lat1,
506                lat2: a_lat2,
507                false_easting: a_false_easting,
508                false_northing: a_false_northing,
509            },
510            ProjectionMethod::LambertConformalConic {
511                lon0: b_lon0,
512                lat0: b_lat0,
513                lat1: b_lat1,
514                lat2: b_lat2,
515                false_easting: b_false_easting,
516                false_northing: b_false_northing,
517            },
518        ) => {
519            approx_eq(*a_lon0, *b_lon0)
520                && approx_eq(*a_lat0, *b_lat0)
521                && approx_eq(*a_lat1, *b_lat1)
522                && approx_eq(*a_lat2, *b_lat2)
523                && approx_eq(*a_false_easting, *b_false_easting)
524                && approx_eq(*a_false_northing, *b_false_northing)
525        }
526        (
527            ProjectionMethod::AlbersEqualArea {
528                lon0: a_lon0,
529                lat0: a_lat0,
530                lat1: a_lat1,
531                lat2: a_lat2,
532                false_easting: a_false_easting,
533                false_northing: a_false_northing,
534            },
535            ProjectionMethod::AlbersEqualArea {
536                lon0: b_lon0,
537                lat0: b_lat0,
538                lat1: b_lat1,
539                lat2: b_lat2,
540                false_easting: b_false_easting,
541                false_northing: b_false_northing,
542            },
543        ) => {
544            approx_eq(*a_lon0, *b_lon0)
545                && approx_eq(*a_lat0, *b_lat0)
546                && approx_eq(*a_lat1, *b_lat1)
547                && approx_eq(*a_lat2, *b_lat2)
548                && approx_eq(*a_false_easting, *b_false_easting)
549                && approx_eq(*a_false_northing, *b_false_northing)
550        }
551        (
552            ProjectionMethod::Mercator {
553                lon0: a_lon0,
554                lat_ts: a_lat_ts,
555                k0: a_k0,
556                false_easting: a_false_easting,
557                false_northing: a_false_northing,
558            },
559            ProjectionMethod::Mercator {
560                lon0: b_lon0,
561                lat_ts: b_lat_ts,
562                k0: b_k0,
563                false_easting: b_false_easting,
564                false_northing: b_false_northing,
565            },
566        ) => {
567            approx_eq(*a_lon0, *b_lon0)
568                && approx_eq(*a_lat_ts, *b_lat_ts)
569                && approx_eq(*a_k0, *b_k0)
570                && approx_eq(*a_false_easting, *b_false_easting)
571                && approx_eq(*a_false_northing, *b_false_northing)
572        }
573        (
574            ProjectionMethod::EquidistantCylindrical {
575                lon0: a_lon0,
576                lat_ts: a_lat_ts,
577                false_easting: a_false_easting,
578                false_northing: a_false_northing,
579            },
580            ProjectionMethod::EquidistantCylindrical {
581                lon0: b_lon0,
582                lat_ts: b_lat_ts,
583                false_easting: b_false_easting,
584                false_northing: b_false_northing,
585            },
586        ) => {
587            approx_eq(*a_lon0, *b_lon0)
588                && approx_eq(*a_lat_ts, *b_lat_ts)
589                && approx_eq(*a_false_easting, *b_false_easting)
590                && approx_eq(*a_false_northing, *b_false_northing)
591        }
592        _ => false,
593    }
594}
595
596fn approx_eq(a: f64, b: f64) -> bool {
597    (a - b).abs() < 1e-12
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603    use crate::crs::{CrsDef, LinearUnit, ProjectedCrsDef, ProjectionMethod};
604    use crate::datum;
605
606    const US_FOOT_TO_METER: f64 = 0.3048006096012192;
607
608    #[test]
609    fn identity_same_crs() {
610        let t = Transform::new("EPSG:4326", "EPSG:4326").unwrap();
611        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
612        assert_eq!(x, -74.006);
613        assert_eq!(y, 40.7128);
614    }
615
616    #[test]
617    fn wgs84_to_web_mercator() {
618        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
619        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
620        // NYC in Web Mercator
621        assert!((x - (-8238310.0)).abs() < 100.0, "x = {x}");
622        assert!((y - 4970072.0).abs() < 100.0, "y = {y}");
623    }
624
625    #[test]
626    fn web_mercator_to_wgs84() {
627        let t = Transform::new("EPSG:3857", "EPSG:4326").unwrap();
628        let (lon, lat) = t.convert((-8238310.0, 4970072.0)).unwrap();
629        assert!((lon - (-74.006)).abs() < 0.001, "lon = {lon}");
630        assert!((lat - 40.7128).abs() < 0.001, "lat = {lat}");
631    }
632
633    #[test]
634    fn roundtrip_4326_3857() {
635        let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
636        let inv = fwd.inverse().unwrap();
637
638        let original = (-74.0445, 40.6892);
639        let projected = fwd.convert(original).unwrap();
640        let back = inv.convert(projected).unwrap();
641
642        assert!(
643            (back.0 - original.0).abs() < 1e-8,
644            "lon: {} vs {}",
645            back.0,
646            original.0
647        );
648        assert!(
649            (back.1 - original.1).abs() < 1e-8,
650            "lat: {} vs {}",
651            back.1,
652            original.1
653        );
654    }
655
656    #[test]
657    fn wgs84_to_utm_18n() {
658        let t = Transform::new("EPSG:4326", "EPSG:32618").unwrap();
659        let (x, y) = t.convert((-74.006, 40.7128)).unwrap();
660        assert!((x - 583960.0).abs() < 1.0, "easting = {x}");
661        assert!(y > 4_500_000.0 && y < 4_510_000.0, "northing = {y}");
662    }
663
664    #[test]
665    fn equivalent_meter_and_foot_state_plane_crs_match_after_unit_conversion() {
666        let coord = (-80.8431, 35.2271); // Charlotte, NC
667        let meter_tx = Transform::new("EPSG:4326", "EPSG:32119").unwrap();
668        let foot_tx = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
669
670        let (mx, my) = meter_tx.convert(coord).unwrap();
671        let (fx, fy) = foot_tx.convert(coord).unwrap();
672
673        assert!(
674            (fx * US_FOOT_TO_METER - mx).abs() < 0.02,
675            "x mismatch: {fx} ft vs {mx} m"
676        );
677        assert!(
678            (fy * US_FOOT_TO_METER - my).abs() < 0.02,
679            "y mismatch: {fy} ft vs {my} m"
680        );
681    }
682
683    #[test]
684    fn inverse_transform_accepts_native_projected_units_for_foot_crs() {
685        let coord = (-80.8431, 35.2271); // Charlotte, NC
686        let forward = Transform::new("EPSG:4326", "EPSG:2264").unwrap();
687        let inverse = Transform::new("EPSG:2264", "EPSG:4326").unwrap();
688
689        let projected = forward.convert(coord).unwrap();
690        let roundtrip = inverse.convert(projected).unwrap();
691
692        assert!((roundtrip.0 - coord.0).abs() < 1e-8, "lon: {}", roundtrip.0);
693        assert!((roundtrip.1 - coord.1).abs() < 1e-8, "lat: {}", roundtrip.1);
694    }
695
696    #[test]
697    fn utm_to_web_mercator() {
698        // Projected → Projected (SameDatumBoth)
699        let t = Transform::new("EPSG:32618", "EPSG:3857").unwrap();
700        // NYC in UTM 18N
701        let (x, _y) = t.convert((583960.0, 4507523.0)).unwrap();
702        // Should be near NYC in Web Mercator
703        assert!((x - (-8238310.0)).abs() < 200.0, "x = {x}");
704    }
705
706    #[test]
707    fn wgs84_to_polar_stereo_3413() {
708        let t = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
709        let (x, y) = t.convert((-45.0, 90.0)).unwrap();
710        // North pole should be at origin for EPSG:3413
711        assert!(x.abs() < 1.0, "x = {x}");
712        assert!(y.abs() < 1.0, "y = {y}");
713    }
714
715    #[test]
716    fn roundtrip_4326_3413() {
717        let fwd = Transform::new("EPSG:4326", "EPSG:3413").unwrap();
718        let inv = fwd.inverse().unwrap();
719
720        let original = (-45.0, 75.0);
721        let projected = fwd.convert(original).unwrap();
722        let back = inv.convert(projected).unwrap();
723
724        assert!(
725            (back.0 - original.0).abs() < 1e-6,
726            "lon: {} vs {}",
727            back.0,
728            original.0
729        );
730        assert!(
731            (back.1 - original.1).abs() < 1e-6,
732            "lat: {} vs {}",
733            back.1,
734            original.1
735        );
736    }
737
738    #[test]
739    fn geographic_to_geographic_same_datum_is_identity() {
740        // NAD83 and WGS84 are both WGS84-compatible
741        let t = Transform::new("EPSG:4269", "EPSG:4326").unwrap();
742        let (lon, lat) = t.convert((-74.006, 40.7128)).unwrap();
743        assert_eq!(lon, -74.006);
744        assert_eq!(lat, 40.7128);
745    }
746
747    #[test]
748    fn unknown_crs_error() {
749        let result = Transform::new("EPSG:99999", "EPSG:4326");
750        assert!(result.is_err());
751    }
752
753    #[test]
754    fn cross_datum_nad27_to_wgs84() {
755        let t = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
756        let (lon, lat) = t.convert((-90.0, 45.0)).unwrap();
757        // NAD27→WGS84 shift is small (tens of meters → fraction of degree)
758        assert!((lon - (-90.0)).abs() < 0.01, "lon = {lon}");
759        assert!((lat - 45.0).abs() < 0.01, "lat = {lat}");
760    }
761
762    #[test]
763    fn cross_datum_roundtrip_nad27() {
764        let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
765        let inv = fwd.inverse().unwrap();
766        let original = (-90.0, 45.0);
767        let shifted = fwd.convert(original).unwrap();
768        let back = inv.convert(shifted).unwrap();
769        assert!(
770            (back.0 - original.0).abs() < 1e-6,
771            "lon: {} vs {}",
772            back.0,
773            original.0
774        );
775        assert!(
776            (back.1 - original.1).abs() < 1e-6,
777            "lat: {} vs {}",
778            back.1,
779            original.1
780        );
781    }
782
783    #[test]
784    fn cross_datum_osgb36_to_wgs84() {
785        let t = Transform::new("EPSG:4277", "EPSG:4326").unwrap();
786        let (lon, lat) = t.convert((-0.1278, 51.5074)).unwrap(); // London
787                                                                 // OSGB36→WGS84 shift is larger due to 7-parameter Helmert
788        assert!((lon - (-0.1278)).abs() < 0.01, "lon = {lon}");
789        assert!((lat - 51.5074).abs() < 0.01, "lat = {lat}");
790    }
791
792    #[test]
793    fn wgs84_to_web_mercator_3d_preserves_height() {
794        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
795        let (x, y, z) = t.convert_3d((-74.006, 40.7128, 123.45)).unwrap();
796        assert!((x - (-8238310.0)).abs() < 100.0, "x = {x}");
797        assert!((y - 4970072.0).abs() < 100.0, "y = {y}");
798        assert!((z - 123.45).abs() < 1e-12, "z = {z}");
799    }
800
801    #[test]
802    fn cross_datum_roundtrip_nad27_3d() {
803        let fwd = Transform::new("EPSG:4267", "EPSG:4326").unwrap();
804        let inv = fwd.inverse().unwrap();
805        let original = (-90.0, 45.0, 250.0);
806        let shifted = fwd.convert_3d(original).unwrap();
807        let back = inv.convert_3d(shifted).unwrap();
808        assert!(
809            (back.0 - original.0).abs() < 1e-6,
810            "lon: {} vs {}",
811            back.0,
812            original.0
813        );
814        assert!(
815            (back.1 - original.1).abs() < 1e-6,
816            "lat: {} vs {}",
817            back.1,
818            original.1
819        );
820        assert!(
821            (back.2 - original.2).abs() < 1e-12,
822            "h: {} vs {}",
823            back.2,
824            original.2
825        );
826    }
827
828    #[test]
829    fn identical_custom_projected_crs_is_identity() {
830        let from = CrsDef::Projected(ProjectedCrsDef::new(
831            0,
832            datum::WGS84,
833            ProjectionMethod::WebMercator,
834            LinearUnit::metre(),
835            "Custom Web Mercator A",
836        ));
837        let to = CrsDef::Projected(ProjectedCrsDef::new(
838            0,
839            datum::WGS84,
840            ProjectionMethod::WebMercator,
841            LinearUnit::metre(),
842            "Custom Web Mercator B",
843        ));
844
845        let t = Transform::from_crs_defs(&from, &to).unwrap();
846        assert!(matches!(t.pipeline, TransformPipeline::Identity));
847    }
848
849    #[test]
850    fn inverse_exposes_swapped_crs() {
851        let fwd = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
852        let inv = fwd.inverse().unwrap();
853
854        assert_eq!(fwd.source_crs().epsg(), 4326);
855        assert_eq!(fwd.target_crs().epsg(), 3857);
856        assert_eq!(inv.source_crs().epsg(), 3857);
857        assert_eq!(inv.target_crs().epsg(), 4326);
858    }
859
860    #[test]
861    fn transform_bounds_web_mercator() {
862        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
863        let bounds = Bounds::new(-74.3, 40.45, -73.65, 40.95);
864
865        let result = t.transform_bounds(bounds, 8).unwrap();
866
867        assert!(result.min_x < -8_200_000.0);
868        assert!(result.max_x < -8_100_000.0);
869        assert!(result.min_y > 4_900_000.0);
870        assert!(result.max_y > result.min_y);
871    }
872
873    #[test]
874    fn transform_bounds_rejects_invalid_input() {
875        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
876        let err = t
877            .transform_bounds(Bounds::new(10.0, 5.0, -10.0, 20.0), 0)
878            .unwrap_err();
879
880        assert!(matches!(err, Error::OutOfRange(_)));
881    }
882
883    #[test]
884    fn batch_transform() {
885        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
886        let coords: Vec<(f64, f64)> = (0..10)
887            .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1))
888            .collect();
889
890        let results = t.convert_batch(&coords).unwrap();
891        assert_eq!(results.len(), 10);
892        for (x, _y) in &results {
893            assert!(*x < 0.0); // all points are west of prime meridian
894        }
895    }
896
897    #[test]
898    fn batch_transform_3d() {
899        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
900        let coords: Vec<(f64, f64, f64)> = (0..10)
901            .map(|i| (-74.0 + i as f64 * 0.1, 40.0 + i as f64 * 0.1, i as f64))
902            .collect();
903
904        let results = t.convert_batch_3d(&coords).unwrap();
905        assert_eq!(results.len(), 10);
906        for (index, (x, _y, z)) in results.iter().enumerate() {
907            assert!(*x < 0.0);
908            assert!((*z - index as f64).abs() < 1e-12);
909        }
910    }
911
912    #[test]
913    fn coord_type() {
914        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
915        let c = Coord::new(-74.006, 40.7128);
916        let result = t.convert(c).unwrap();
917        assert!((result.x - (-8238310.0)).abs() < 100.0);
918    }
919
920    #[cfg(feature = "geo-types")]
921    #[test]
922    fn geo_types_coord() {
923        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
924        let c = geo_types::Coord {
925            x: -74.006,
926            y: 40.7128,
927        };
928        let result: geo_types::Coord<f64> = t.convert(c).unwrap();
929        assert!((result.x - (-8238310.0)).abs() < 100.0);
930    }
931
932    #[cfg(feature = "rayon")]
933    #[test]
934    fn parallel_batch_transform() {
935        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
936        let coords: Vec<(f64, f64)> = (0..100)
937            .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01))
938            .collect();
939
940        let results = t.convert_batch_parallel(&coords).unwrap();
941        assert_eq!(results.len(), 100);
942    }
943
944    #[cfg(feature = "rayon")]
945    #[test]
946    fn parallel_batch_transform_matches_sequential_on_large_input() {
947        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
948        let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
949        let coords: Vec<(f64, f64)> = (0..len)
950            .map(|i| (-179.0 + i as f64 * 0.0001, -80.0 + i as f64 * 0.00005))
951            .collect();
952
953        let sequential = t.convert_batch(&coords).unwrap();
954        let parallel = t.convert_batch_parallel(&coords).unwrap();
955
956        assert_eq!(parallel, sequential);
957    }
958
959    #[cfg(feature = "rayon")]
960    #[test]
961    fn parallel_batch_transform_3d() {
962        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
963        let coords: Vec<(f64, f64, f64)> = (0..100)
964            .map(|i| (-74.0 + i as f64 * 0.01, 40.0 + i as f64 * 0.01, i as f64))
965            .collect();
966
967        let results = t.convert_batch_parallel_3d(&coords).unwrap();
968        assert_eq!(results.len(), 100);
969    }
970
971    #[cfg(feature = "rayon")]
972    #[test]
973    fn parallel_batch_transform_3d_matches_sequential_on_large_input() {
974        let t = Transform::new("EPSG:4326", "EPSG:3857").unwrap();
975        let len = rayon::current_num_threads() * PARALLEL_MIN_ITEMS_PER_THREAD;
976        let coords: Vec<(f64, f64, f64)> = (0..len)
977            .map(|i| {
978                (
979                    -179.0 + i as f64 * 0.0001,
980                    -80.0 + i as f64 * 0.00005,
981                    i as f64,
982                )
983            })
984            .collect();
985
986        let sequential = t.convert_batch_3d(&coords).unwrap();
987        let parallel = t.convert_batch_parallel_3d(&coords).unwrap();
988
989        assert_eq!(parallel, sequential);
990    }
991}