Skip to main content

oxigdal_proj/transform/
mod.rs

1//! Coordinate transformation operations.
2//!
3//! This module provides coordinate transformation capabilities between different CRS
4//! using the proj4rs library for pure Rust implementations, as well as native pure-Rust
5//! implementations of many map projections.
6//!
7//! # Module Structure
8//!
9//! - `cylindrical`   — Cylindrical projections (Mercator, Transverse Mercator, Cassini, etc.)
10//! - `pseudocylindrical` — Pseudo-cylindrical projections (Sinusoidal, Mollweide, Robinson, Eckert IV/VI)
11//! - `conic`         — Conic projections (Lambert Conic, Equidistant Conic, Albers)
12//! - `azimuthal`     — Azimuthal projections (Lambert Azimuthal Equal Area, Azimuthal Equidistant, Gnomonic)
13
14#[cfg(feature = "std")]
15pub mod azimuthal;
16#[cfg(feature = "std")]
17pub mod conic;
18#[cfg(feature = "std")]
19pub mod cylindrical;
20#[cfg(feature = "std")]
21pub mod pseudocylindrical;
22#[cfg(feature = "std")]
23pub mod simd;
24
25#[cfg(feature = "std")]
26use crate::area_of_use::area_of_use_for_epsg;
27#[cfg(feature = "std")]
28use crate::crs::{Crs, CrsSource};
29use crate::error::{Error, Result};
30#[cfg(not(feature = "std"))]
31use alloc::format;
32use core::fmt;
33#[cfg(feature = "std")]
34use std::sync::Mutex;
35
36#[cfg(feature = "std")]
37use crate::proj_string::ProjString;
38
39// Re-export projection types for easy access (std only — require transcendental float math)
40#[cfg(feature = "std")]
41pub use azimuthal::{AzimuthalEquidistant, Gnomonic, LambertAzimuthalEqualArea};
42#[cfg(feature = "std")]
43pub use conic::{EquidistantConic, LambertConformalConic};
44#[cfg(feature = "std")]
45pub use cylindrical::{CassineSoldner, GaussKruger, TransverseMercator};
46#[cfg(feature = "std")]
47pub use pseudocylindrical::{EckertIV, EckertVI, Mollweide, Robinson, Sinusoidal};
48
49/// A 2D coordinate (x, y) or (longitude, latitude).
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct Coordinate {
52    /// X coordinate (or longitude in geographic CRS)
53    pub x: f64,
54    /// Y coordinate (or latitude in geographic CRS)
55    pub y: f64,
56}
57
58impl Coordinate {
59    /// Creates a new coordinate.
60    pub fn new(x: f64, y: f64) -> Self {
61        Self { x, y }
62    }
63
64    /// Creates a coordinate from longitude and latitude (in degrees).
65    pub fn from_lon_lat(lon: f64, lat: f64) -> Self {
66        Self::new(lon, lat)
67    }
68
69    /// Returns the longitude (assumes geographic CRS).
70    pub fn lon(&self) -> f64 {
71        self.x
72    }
73
74    /// Returns the latitude (assumes geographic CRS).
75    pub fn lat(&self) -> f64 {
76        self.y
77    }
78
79    /// Validates that the coordinate is within valid bounds for a geographic CRS.
80    pub fn validate_geographic(&self) -> Result<()> {
81        if !(-180.0..=180.0).contains(&self.x) {
82            return Err(Error::coordinate_out_of_bounds(self.x, self.y));
83        }
84        if !(-90.0..=90.0).contains(&self.y) {
85            return Err(Error::coordinate_out_of_bounds(self.x, self.y));
86        }
87        Ok(())
88    }
89
90    /// Checks if the coordinate contains valid (finite) values.
91    pub fn is_valid(&self) -> bool {
92        self.x.is_finite() && self.y.is_finite()
93    }
94}
95
96impl fmt::Display for Coordinate {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(f, "({}, {})", self.x, self.y)
99    }
100}
101
102/// A 3D coordinate (x, y, z).
103#[derive(Debug, Clone, Copy, PartialEq)]
104pub struct Coordinate3D {
105    /// X coordinate
106    pub x: f64,
107    /// Y coordinate
108    pub y: f64,
109    /// Z coordinate (elevation/height)
110    pub z: f64,
111}
112
113impl Coordinate3D {
114    /// Creates a new 3D coordinate.
115    pub fn new(x: f64, y: f64, z: f64) -> Self {
116        Self { x, y, z }
117    }
118
119    /// Converts to 2D coordinate (drops Z).
120    pub fn to_2d(&self) -> Coordinate {
121        Coordinate::new(self.x, self.y)
122    }
123
124    /// Checks if the coordinate contains valid (finite) values.
125    pub fn is_valid(&self) -> bool {
126        self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
127    }
128}
129
130impl From<Coordinate> for Coordinate3D {
131    fn from(coord: Coordinate) -> Self {
132        Self::new(coord.x, coord.y, 0.0)
133    }
134}
135
136/// A bounding box defined by minimum and maximum coordinates.
137#[derive(Debug, Clone, Copy, PartialEq)]
138pub struct BoundingBox {
139    /// Minimum X coordinate
140    pub min_x: f64,
141    /// Minimum Y coordinate
142    pub min_y: f64,
143    /// Maximum X coordinate
144    pub max_x: f64,
145    /// Maximum Y coordinate
146    pub max_y: f64,
147}
148
149impl BoundingBox {
150    /// Creates a new bounding box.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if min > max for any dimension.
155    pub fn new(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Result<Self> {
156        if min_x > max_x {
157            return Err(Error::invalid_bounding_box(format!(
158                "min_x ({}) > max_x ({})",
159                min_x, max_x
160            )));
161        }
162        if min_y > max_y {
163            return Err(Error::invalid_bounding_box(format!(
164                "min_y ({}) > max_y ({})",
165                min_y, max_y
166            )));
167        }
168
169        Ok(Self {
170            min_x,
171            min_y,
172            max_x,
173            max_y,
174        })
175    }
176
177    /// Creates a bounding box from two coordinates.
178    pub fn from_coordinates(c1: Coordinate, c2: Coordinate) -> Result<Self> {
179        let min_x = c1.x.min(c2.x);
180        let min_y = c1.y.min(c2.y);
181        let max_x = c1.x.max(c2.x);
182        let max_y = c1.y.max(c2.y);
183        Self::new(min_x, min_y, max_x, max_y)
184    }
185
186    /// Returns the width of the bounding box.
187    pub fn width(&self) -> f64 {
188        self.max_x - self.min_x
189    }
190
191    /// Returns the height of the bounding box.
192    pub fn height(&self) -> f64 {
193        self.max_y - self.min_y
194    }
195
196    /// Returns the center coordinate of the bounding box.
197    pub fn center(&self) -> Coordinate {
198        Coordinate::new(
199            (self.min_x + self.max_x) / 2.0,
200            (self.min_y + self.max_y) / 2.0,
201        )
202    }
203
204    /// Returns the four corner coordinates.
205    pub fn corners(&self) -> [Coordinate; 4] {
206        [
207            Coordinate::new(self.min_x, self.min_y),
208            Coordinate::new(self.max_x, self.min_y),
209            Coordinate::new(self.max_x, self.max_y),
210            Coordinate::new(self.min_x, self.max_y),
211        ]
212    }
213
214    /// Checks if a coordinate is within the bounding box.
215    pub fn contains(&self, coord: &Coordinate) -> bool {
216        coord.x >= self.min_x
217            && coord.x <= self.max_x
218            && coord.y >= self.min_y
219            && coord.y <= self.max_y
220    }
221
222    /// Expands the bounding box to include a coordinate.
223    pub fn expand_to_include(&mut self, coord: &Coordinate) {
224        self.min_x = self.min_x.min(coord.x);
225        self.min_y = self.min_y.min(coord.y);
226        self.max_x = self.max_x.max(coord.x);
227        self.max_y = self.max_y.max(coord.y);
228    }
229}
230
231/// Opt-in mode controlling how a [`Transformer`] reacts to coordinates that
232/// fall outside the source EPSG's registered area-of-use bounding box.
233///
234/// The check consults [`area_of_use_for_epsg`]; if the lookup returns `None`
235/// (no entry registered for that EPSG code) the check is skipped regardless of
236/// mode.
237#[cfg(feature = "std")]
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
239pub enum AreaOfUseCheck {
240    /// Validation is disabled; out-of-area coordinates pass through silently.
241    #[default]
242    Off,
243    /// Out-of-area coordinates are recorded via [`Transformer::last_warning`]
244    /// but the transformation still proceeds.
245    Warn,
246    /// Out-of-area coordinates abort the transformation with
247    /// [`Error::OutsideAreaOfUse`].
248    Strict,
249}
250
251/// A diagnostic record describing a single point that fell outside the
252/// registered area-of-use for the source EPSG.
253///
254/// Produced by [`Transformer`] when [`AreaOfUseCheck::Warn`] mode is enabled
255/// and accessed via [`Transformer::last_warning`].
256#[cfg(feature = "std")]
257#[derive(Debug, Clone, Copy, PartialEq)]
258pub struct AreaOfUseWarning {
259    /// Longitude of the offending point (degrees, WGS84).
260    pub lon: f64,
261    /// Latitude of the offending point (degrees, WGS84).
262    pub lat: f64,
263    /// Source EPSG code whose area-of-use was violated.
264    pub epsg: u32,
265    /// Western bound of the registered area-of-use (degrees).
266    pub west: f64,
267    /// Southern bound of the registered area-of-use (degrees).
268    pub south: f64,
269    /// Eastern bound of the registered area-of-use (degrees).
270    pub east: f64,
271    /// Northern bound of the registered area-of-use (degrees).
272    pub north: f64,
273}
274
275/// Coordinate transformer that handles transformations between CRS.
276#[cfg(feature = "std")]
277pub struct Transformer {
278    source_crs: Crs,
279    target_crs: Crs,
280    proj: Option<proj4rs::Proj>,
281    /// When `true` (the default), `transform` rejects points that lie outside
282    /// the source CRS's declared area of use by returning
283    /// [`Error::OutOfAreaOfUse`].  When `false`, the check is skipped and the
284    /// underlying proj4rs transform is attempted unconditionally.
285    strict: bool,
286    /// Opt-in per-instance area-of-use validation mode (orthogonal to the
287    /// legacy `strict` boolean above which only fires for geographic source
288    /// CRS).  Default: [`AreaOfUseCheck::Off`].
289    area_of_use_check: AreaOfUseCheck,
290    /// Most recent warning recorded under [`AreaOfUseCheck::Warn`] mode.
291    ///
292    /// Stored in a `Mutex` so that [`Transformer::transform`] can take
293    /// `&self` (matching the existing API surface) while still mutating
294    /// diagnostic state, and so the whole `Transformer` is `Sync` and
295    /// can be safely shared across threads via [`TransformerCache`].
296    /// `AreaOfUseWarning` is `Copy`, so locks are held only for the
297    /// trivial duration of a load/store.
298    last_warning: Mutex<Option<AreaOfUseWarning>>,
299    /// Source observation epoch (decimal year) for ITRF epoch-aware transforms.
300    source_epoch: Option<f64>,
301    /// Target observation epoch (decimal year) for ITRF epoch-aware transforms.
302    target_epoch: Option<f64>,
303    /// ITRF transformation parameters and reference epoch when `with_epoch` is active.
304    ///
305    /// Tuple: (params, ref_epoch_decimal_year).
306    itrf_params: Option<(crate::datum_transform::ItrfTransformParams, f64)>,
307    /// Optional geoid model used by [`Transformer::transform_3d`] when the
308    /// source and target compound CRS have vertical components of different
309    /// kinds (e.g. orthometric ↔ ellipsoidal).  When `None`, the compound
310    /// vertical branch falls through silently (back-compatible behaviour).
311    geoid: Option<std::sync::Arc<crate::geoid::GeoidGrid>>,
312}
313
314#[cfg(feature = "std")]
315impl Transformer {
316    /// Creates a new transformer.
317    ///
318    /// # Arguments
319    ///
320    /// * `source_crs` - Source coordinate reference system
321    /// * `target_crs` - Target coordinate reference system
322    ///
323    /// # Errors
324    ///
325    /// Returns an error if the transformation cannot be initialized.
326    pub fn new(source_crs: Crs, target_crs: Crs) -> Result<Self> {
327        // Compound CRS transformations are handled entirely inside `transform_3d`
328        // via a dedicated sub-transformer, so no proj4rs initialisation is needed
329        // at the outer level.  Skipping it also avoids the "WKT to PROJ conversion
330        // not yet implemented" error that WKT-backed sub-CRS would otherwise raise.
331        let is_compound = matches!(source_crs.source(), CrsSource::Compound { .. })
332            || matches!(target_crs.source(), CrsSource::Compound { .. });
333
334        // Engineering (local) CRS has no geodetic datum: no spatial conversion
335        // is possible without user-supplied parameters.  Return a pass-through
336        // (identity) transformer so callers receive a usable object rather than
337        // an opaque WKT-conversion error.
338        let either_engineering = source_crs.is_engineering() || target_crs.is_engineering();
339
340        let proj = if is_compound || either_engineering || source_crs.is_equivalent(&target_crs) {
341            None
342        } else {
343            // Initialize proj4rs transformation for non-compound CRS pairs.
344            let source_proj_str = source_crs.to_proj_string()?;
345            let target_proj_str = target_crs.to_proj_string()?;
346
347            let _source_proj = proj4rs::Proj::from_proj_string(&source_proj_str)
348                .map_err(|e| Error::projection_init_error(format!("Source CRS: {:?}", e)))?;
349
350            let target_proj = proj4rs::Proj::from_proj_string(&target_proj_str)
351                .map_err(|e| Error::projection_init_error(format!("Target CRS: {:?}", e)))?;
352
353            // We'll store the target proj for now, and use proj4rs::transform later
354            Some(target_proj)
355        };
356
357        Ok(Self {
358            source_crs,
359            target_crs,
360            proj,
361            strict: true,
362            area_of_use_check: AreaOfUseCheck::default(),
363            last_warning: Mutex::new(None),
364            source_epoch: None,
365            target_epoch: None,
366            itrf_params: None,
367            geoid: None,
368        })
369    }
370
371    /// Attaches a geoid model to this transformer.
372    ///
373    /// When both the source and target CRS are [`CrsSource::Compound`] and
374    /// their vertical components are of different kinds (one ellipsoidal,
375    /// one orthometric), [`Transformer::transform_3d`] uses the attached
376    /// grid to apply the corresponding height correction (`N` added or
377    /// subtracted depending on direction).  Without a geoid attached the
378    /// vertical conversion silently passes through, preserving the legacy
379    /// behaviour of pre-Slice-14 builds.
380    ///
381    /// Returns the transformer by value so the call may be chained:
382    ///
383    /// ```no_run
384    /// use std::sync::Arc;
385    /// use oxigdal_proj::{Crs, Transformer};
386    /// use oxigdal_proj::geoid::{GeoidModel, synthetic_grid};
387    ///
388    /// let t = Transformer::new(Crs::wgs84(), Crs::wgs84())
389    ///     .expect("ok")
390    ///     .with_geoid(Arc::new(synthetic_grid(GeoidModel::Egm96)));
391    /// assert!(t.geoid().is_some());
392    /// ```
393    pub fn with_geoid(mut self, grid: std::sync::Arc<crate::geoid::GeoidGrid>) -> Self {
394        self.geoid = Some(grid);
395        self
396    }
397
398    /// Returns the attached geoid model, if any.
399    pub fn geoid(&self) -> Option<&std::sync::Arc<crate::geoid::GeoidGrid>> {
400        self.geoid.as_ref()
401    }
402
403    /// Sets strict area-of-use validation.
404    ///
405    /// When `strict` is `true` (the default), [`Transformer::transform`] returns
406    /// [`Error::OutOfAreaOfUse`] for any point that lies outside the source CRS's
407    /// declared area of use.  When `false`, the check is skipped silently.
408    pub fn with_strict(mut self, strict: bool) -> Self {
409        self.strict = strict;
410        self
411    }
412
413    /// Returns whether strict area-of-use validation is enabled.
414    pub fn is_strict(&self) -> bool {
415        self.strict
416    }
417
418    /// Configures the per-instance area-of-use validation mode.
419    ///
420    /// This is independent of the legacy `with_strict`/`is_strict` switch and
421    /// applies in [`transform`](Self::transform) and
422    /// [`transform_batch`](Self::transform_batch) **before** the underlying
423    /// projection runs.  The bounds are looked up via
424    /// [`area_of_use_for_epsg`] on the **source EPSG**; if that lookup
425    /// returns `None`, the check is skipped silently regardless of mode.
426    ///
427    /// Behaviour:
428    /// * [`AreaOfUseCheck::Off`] (default) — no validation is performed.
429    /// * [`AreaOfUseCheck::Warn`] — out-of-area points are recorded via
430    ///   [`last_warning`](Self::last_warning) but the transform proceeds.
431    /// * [`AreaOfUseCheck::Strict`] — out-of-area points abort the transform
432    ///   with [`Error::OutsideAreaOfUse`].
433    pub fn with_area_of_use_check(mut self, mode: AreaOfUseCheck) -> Self {
434        self.area_of_use_check = mode;
435        if let Ok(mut slot) = self.last_warning.lock() {
436            *slot = None;
437        }
438        self
439    }
440
441    /// Returns the currently configured area-of-use check mode.
442    pub fn area_of_use_check(&self) -> AreaOfUseCheck {
443        self.area_of_use_check
444    }
445
446    /// Returns the most recent area-of-use warning recorded under
447    /// [`AreaOfUseCheck::Warn`] mode, if any.
448    ///
449    /// Returns `None` when the check mode is [`AreaOfUseCheck::Off`], when no
450    /// out-of-area point has been seen yet, or when the source EPSG has no
451    /// registered area-of-use entry.
452    pub fn last_warning(&self) -> Option<AreaOfUseWarning> {
453        // `AreaOfUseWarning` is `Copy`, so we can simply dereference
454        // the guard.  Poisoning is treated as "no warning" — the
455        // accessor never panics on a poisoned lock.
456        self.last_warning.lock().ok().and_then(|guard| *guard)
457    }
458
459    /// Clears any previously-recorded area-of-use warning.
460    pub fn clear_warning(&self) {
461        if let Ok(mut slot) = self.last_warning.lock() {
462            *slot = None;
463        }
464    }
465
466    /// Internal helper: runs the area-of-use check for `(lon, lat)` against the
467    /// source EPSG (only if one is available).
468    ///
469    /// * In [`AreaOfUseCheck::Strict`] mode, returns
470    ///   `Err(Error::OutsideAreaOfUse { .. })` on violation.
471    /// * In [`AreaOfUseCheck::Warn`] mode, records the violation via
472    ///   `self.last_warning` and returns `Ok(())`.
473    /// * In [`AreaOfUseCheck::Off`] mode, returns `Ok(())` immediately.
474    /// * When the source CRS has no EPSG code or no registered area-of-use,
475    ///   returns `Ok(())` regardless of mode.
476    fn check_area_of_use(&self, lon: f64, lat: f64) -> Result<()> {
477        if self.area_of_use_check == AreaOfUseCheck::Off {
478            return Ok(());
479        }
480        let epsg = match self.source_crs.epsg_code() {
481            Some(c) => c,
482            None => return Ok(()),
483        };
484        let aou = match area_of_use_for_epsg(epsg) {
485            Some(a) => a,
486            None => return Ok(()),
487        };
488        if aou.contains(lon, lat) {
489            return Ok(());
490        }
491        match self.area_of_use_check {
492            AreaOfUseCheck::Off => Ok(()),
493            AreaOfUseCheck::Warn => {
494                if let Ok(mut slot) = self.last_warning.lock() {
495                    *slot = Some(AreaOfUseWarning {
496                        lon,
497                        lat,
498                        epsg,
499                        west: aou.west,
500                        south: aou.south,
501                        east: aou.east,
502                        north: aou.north,
503                    });
504                }
505                Ok(())
506            }
507            AreaOfUseCheck::Strict => Err(Error::OutsideAreaOfUse {
508                lon,
509                lat,
510                epsg,
511                west: aou.west,
512                south: aou.south,
513                east: aou.east,
514                north: aou.north,
515            }),
516        }
517    }
518
519    /// Configure a time-dependent ITRF epoch transformation.
520    ///
521    /// Both the source and target CRS must be ITRF-based (recognised by EPSG code or
522    /// by frame name in the datum/CRS name), and a preset must exist for that pair in
523    /// the built-in IERS table.
524    ///
525    /// The Bursa-Wolf parameters are extrapolated linearly from the published reference
526    /// epoch to the requested observation epochs before the Helmert transformation is
527    /// applied.  When `source_epoch == target_epoch` the correction is zero and the
528    /// output equals the input.
529    ///
530    /// # Parameters
531    /// * `source_epoch` – observation epoch of the input coordinates (decimal year, e.g. 2015.0)
532    /// * `target_epoch` – desired output epoch (decimal year, e.g. 2020.75)
533    ///
534    /// # Errors
535    ///
536    /// Returns `Err` if:
537    /// - either CRS is not an ITRF realisation, or
538    /// - no registered IERS preset exists for the source→target frame pair.
539    pub fn with_epoch(mut self, source_epoch: f64, target_epoch: f64) -> Result<Self> {
540        let src_itrf = self.source_crs.itrf_name().ok_or_else(|| {
541            Error::transformation_error(
542                "with_epoch requires the source CRS to be an ITRF realisation",
543            )
544        })?;
545        let dst_itrf = self.target_crs.itrf_name().ok_or_else(|| {
546            Error::transformation_error(
547                "with_epoch requires the target CRS to be an ITRF realisation",
548            )
549        })?;
550
551        let params_ref = crate::datum_transform::find_itrf_params(&src_itrf, &dst_itrf)
552            .ok_or_else(|| {
553                Error::transformation_error(format!(
554                    "no ITRF parameters registered for {src_itrf} \u{2192} {dst_itrf}"
555                ))
556            })?;
557
558        self.source_epoch = Some(source_epoch);
559        self.target_epoch = Some(target_epoch);
560        self.itrf_params = Some(params_ref);
561        Ok(self)
562    }
563
564    /// Returns the configured source epoch, if any.
565    pub fn source_epoch(&self) -> Option<f64> {
566        self.source_epoch
567    }
568
569    /// Returns the configured target epoch, if any.
570    pub fn target_epoch(&self) -> Option<f64> {
571        self.target_epoch
572    }
573
574    /// Creates a transformer from EPSG codes.
575    ///
576    /// # Arguments
577    ///
578    /// * `source_epsg` - Source EPSG code
579    /// * `target_epsg` - Target EPSG code
580    ///
581    /// # Errors
582    ///
583    /// Returns an error if the EPSG codes are invalid or transformation cannot be initialized.
584    pub fn from_epsg(source_epsg: u32, target_epsg: u32) -> Result<Self> {
585        let source_crs = Crs::from_epsg(source_epsg)?;
586        let target_crs = Crs::from_epsg(target_epsg)?;
587        Self::new(source_crs, target_crs)
588    }
589
590    /// Returns the source CRS.
591    pub fn source_crs(&self) -> &Crs {
592        &self.source_crs
593    }
594
595    /// Returns the target CRS.
596    pub fn target_crs(&self) -> &Crs {
597        &self.target_crs
598    }
599
600    /// Transforms a single coordinate.
601    ///
602    /// # Arguments
603    ///
604    /// * `coord` - Input coordinate in source CRS
605    ///
606    /// # Errors
607    ///
608    /// Returns an error if the transformation fails, or if `self.strict` is
609    /// `true` and the point lies outside the source CRS's declared area of use.
610    pub fn transform(&self, coord: &Coordinate) -> Result<Coordinate> {
611        // Opt-in per-instance area-of-use check (independent of the legacy
612        // `strict` flag below): runs first so that even no-op same-CRS pairs
613        // honour the configured policy.
614        self.check_area_of_use(coord.x, coord.y)?;
615
616        // If no transformation needed, return as-is
617        if self.proj.is_none() {
618            return Ok(*coord);
619        }
620
621        // Validate input
622        if !coord.is_valid() {
623            return Err(Error::invalid_coordinate(
624                "Coordinate contains non-finite values",
625            ));
626        }
627
628        // Area-of-use check: only when strict mode is active, the source CRS
629        // actually declares bounds, AND the source CRS is geographic (lon/lat in
630        // degrees).  For projected CRS, the input coordinates are in metres, so
631        // comparing them against degree-based AoU bounds is meaningless.
632        // `area_of_use()` returns None for CRS created from PROJ strings, WKT, or
633        // custom definitions, in which case the check is skipped silently.
634        if self.strict && self.source_crs.is_geographic() {
635            if let Some(aou) = self.source_crs.area_of_use() {
636                if !aou.contains(coord.x, coord.y) {
637                    return Err(Error::out_of_area_of_use(
638                        coord.x,
639                        coord.y,
640                        self.source_crs.to_string(),
641                    ));
642                }
643            }
644        }
645
646        // Perform transformation using proj4rs
647        self.transform_impl(coord)
648    }
649
650    /// Transforms a 3D coordinate.
651    ///
652    /// When both the source and target CRS are `CrsSource::Compound`, the
653    /// horizontal pair (x, y) is transformed independently using a sub-
654    /// transformer, and the vertical component (z) is handled as follows:
655    ///
656    /// * If the source and target vertical CRS are equivalent, `z` is passed
657    ///   through unchanged.
658    /// * Otherwise, the vertical datums of source and target are classified
659    ///   via [`crate::geoid::classify_vertical_datum`].  When a geoid model
660    ///   has been attached via [`Transformer::with_geoid`] and the pair is
661    ///   `orthometric ↔ ellipsoidal`, the undulation correction is applied
662    ///   (`h_ellip = h_ortho + N` or `h_ortho = h_ellip − N`).
663    /// * In any other case (no geoid attached, or one side `Unknown`), `z`
664    ///   is silently passed through unchanged to preserve back-compat with
665    ///   pre-Slice-14 builds.
666    ///
667    /// When `with_epoch` has been called, the ITRF epoch correction is applied
668    /// using the Bursa-Wolf parameters extrapolated to the requested epochs.
669    /// The coordinate convention is: `coord.x` = geodetic longitude (degrees),
670    /// `coord.y` = geodetic latitude (degrees), `coord.z` = ellipsoidal height
671    /// (metres).
672    pub fn transform_3d(&self, coord: &Coordinate3D) -> Result<Coordinate3D> {
673        // ITRF epoch-aware branch: applies before all other transformations.
674        if let (Some((params, ref_epoch)), Some(t0), Some(t1)) =
675            (&self.itrf_params, self.source_epoch, self.target_epoch)
676        {
677            if !coord.is_valid() {
678                return Err(Error::invalid_coordinate(
679                    "Coordinate contains non-finite values",
680                ));
681            }
682
683            // Short-circuit: zero epoch difference → exact identity (avoids
684            // floating-point rounding from ECEF round-trips).
685            if (t1 - t0).abs() < f64::EPSILON {
686                return Ok(*coord);
687            }
688
689            // `EpochTransformArgs` expects lat/lon in **radians**.
690            // `Coordinate3D` stores geographic degrees: x=lon, y=lat, z=height.
691            let lat_rad = coord.y.to_radians();
692            let lon_rad = coord.x.to_radians();
693
694            // Use the GRS80 ellipsoid (shared by all ITRF realisations).
695            let ellipsoid = crate::datum_transform::Ellipsoid::GRS80;
696
697            // Extrapolate Bursa-Wolf params to the observation epoch, then transform.
698            // For epoch-aware ITRF transforms we interpret:
699            //   source_epoch = t0 (epoch of input coords)
700            //   target_epoch = t1 (desired output epoch)
701            // The delta epoch used for extrapolation is (t1 − ref_epoch).
702            // The source epoch is used to interpolate a second Helmert set which is
703            // then composed: net displacement = bw(t1) − bw(t0), applied in one pass.
704            let bw_t1 = params.params_at_epoch(t1, *ref_epoch);
705            let bw_t0 = params.params_at_epoch(t0, *ref_epoch);
706
707            // Net Bursa-Wolf parameters representing the coordinate change
708            // from epoch t0 to epoch t1.
709            let net_bw = crate::datum_transform::BursaWolfParams {
710                tx: bw_t1.tx - bw_t0.tx,
711                ty: bw_t1.ty - bw_t0.ty,
712                tz: bw_t1.tz - bw_t0.tz,
713                rx: bw_t1.rx - bw_t0.rx,
714                ry: bw_t1.ry - bw_t0.ry,
715                rz: bw_t1.rz - bw_t0.rz,
716                ds: bw_t1.ds - bw_t0.ds,
717            };
718
719            // Apply the net Bursa-Wolf correction.  Source and target share the same
720            // GRS80 ellipsoid (all ITRF realisations are defined on GRS80).
721            let (lat_out_rad, lon_out_rad, h_out) =
722                net_bw.transform_geodetic(lat_rad, lon_rad, coord.z, &ellipsoid, &ellipsoid);
723
724            return Ok(Coordinate3D::new(
725                lon_out_rad.to_degrees(),
726                lat_out_rad.to_degrees(),
727                h_out,
728            ));
729        }
730
731        // Compound-CRS branch: split horizontal and vertical transformations.
732        if let (
733            CrsSource::Compound {
734                horizontal: src_h,
735                vertical: src_v,
736            },
737            CrsSource::Compound {
738                horizontal: dst_h,
739                vertical: dst_v,
740            },
741        ) = (self.source_crs.source(), self.target_crs.source())
742        {
743            if !coord.is_valid() {
744                return Err(Error::invalid_coordinate(
745                    "Coordinate contains non-finite values",
746                ));
747            }
748
749            // Transform the horizontal (x, y) pair.
750            let h_transformer = Transformer::new((**src_h).clone(), (**dst_h).clone())?;
751            let xy_2d = Coordinate::new(coord.x, coord.y);
752            let transformed_xy = h_transformer.transform(&xy_2d)?;
753
754            // Transform the vertical (z):
755            //   1. If source and target vertical datums match, passthrough.
756            //   2. Otherwise classify each vertical CRS and, if an undulation
757            //      shift is required AND a geoid model is attached, apply it.
758            //   3. When the shift is required but no geoid is attached, fall
759            //      through silently with z unchanged.  This preserves the
760            //      pre-Slice-14 contract that `transform_3d` does not fail in
761            //      the default (no-geoid) configuration; callers that need a
762            //      hard guarantee should attach a geoid via `with_geoid`.
763            let z = if src_v.is_equivalent(dst_v) {
764                coord.z
765            } else {
766                use crate::geoid::{VerticalDatumKind, classify_vertical_datum};
767                let src_kind = classify_vertical_datum(src_v.name().unwrap_or(""));
768                let dst_kind = classify_vertical_datum(dst_v.name().unwrap_or(""));
769                match (src_kind, dst_kind, self.geoid.as_ref()) {
770                    // Orthometric → ellipsoidal: h_ellip = h_ortho + N
771                    (
772                        VerticalDatumKind::Orthometric,
773                        VerticalDatumKind::Ellipsoidal,
774                        Some(grid),
775                    ) => grid.orthometric_to_ellipsoidal(coord.y, coord.x, coord.z),
776                    // Ellipsoidal → orthometric: h_ortho = h_ellip − N
777                    (
778                        VerticalDatumKind::Ellipsoidal,
779                        VerticalDatumKind::Orthometric,
780                        Some(grid),
781                    ) => grid.ellipsoidal_to_orthometric(coord.y, coord.x, coord.z),
782                    // Any other case: passthrough (back-compat behaviour).
783                    _ => coord.z,
784                }
785            };
786
787            return Ok(Coordinate3D::new(transformed_xy.x, transformed_xy.y, z));
788        }
789
790        if self.proj.is_none() {
791            return Ok(*coord);
792        }
793
794        if !coord.is_valid() {
795            return Err(Error::invalid_coordinate(
796                "Coordinate contains non-finite values",
797            ));
798        }
799
800        // Transform 2D part
801        let coord_2d = coord.to_2d();
802        let transformed_2d = self.transform_impl(&coord_2d)?;
803
804        // Keep Z coordinate (proper 3D transformation would require more complex logic)
805        Ok(Coordinate3D::new(
806            transformed_2d.x,
807            transformed_2d.y,
808            coord.z,
809        ))
810    }
811
812    /// Transforms multiple coordinates in batch.
813    ///
814    /// Attempts SIMD-accelerated kernels for Transverse Mercator, Mercator, and
815    /// Lambert Conformal Conic projections first.  Falls back to scalar
816    /// point-by-point transformation for any other projection or when the
817    /// projection parameters cannot be extracted from the PROJ string.
818    ///
819    /// # Arguments
820    ///
821    /// * `coords` - Input coordinates in source CRS
822    ///
823    /// # Errors
824    ///
825    /// Returns an error if any transformation fails.
826    pub fn transform_batch(&self, coords: &[Coordinate]) -> Result<Vec<Coordinate>> {
827        // Opt-in area-of-use check runs once over the whole batch so that the
828        // SIMD fast-path also honours the configured policy.  In `Warn` mode
829        // `last_warning` reflects the *last* offending point in the input
830        // sequence (consistent with the scalar `transform` path).
831        if self.area_of_use_check != AreaOfUseCheck::Off {
832            for c in coords {
833                self.check_area_of_use(c.x, c.y)?;
834            }
835        }
836        if let Some(result) = self.try_simd_batch(coords) {
837            return result;
838        }
839        coords.iter().map(|c| self.transform(c)).collect()
840    }
841
842    /// Attempts to run SIMD-accelerated batch projection.
843    ///
844    /// Returns `Some(Result<…>)` if the source→target pair maps to a supported
845    /// fast-path kernel (TM/UTM forward, Mercator forward, LCC forward).
846    /// Returns `None` to signal that the caller should use the scalar fallback.
847    fn try_simd_batch(&self, coords: &[Coordinate]) -> Option<Result<Vec<Coordinate>>> {
848        if coords.is_empty() {
849            return Some(Ok(Vec::new()));
850        }
851
852        // We only accelerate Geographic → Projected (forward) transforms.
853        // Projected → Geographic (inverse) falls back to proj4rs.
854        if !self.source_crs.is_geographic() || !self.target_crs.is_projected() {
855            return None;
856        }
857
858        // Obtain the PROJ string for the projected (target) CRS.
859        let proj_str = match self.target_crs.to_proj_string() {
860            Ok(s) => s,
861            Err(_) => return None,
862        };
863
864        let parsed = match ProjString::parse(&proj_str) {
865            Ok(p) => p,
866            Err(_) => return None,
867        };
868
869        let proj_type = parsed.proj()?;
870
871        match proj_type {
872            "tmerc" | "utm" => Some(self.simd_tmerc_forward(coords, &parsed)),
873            "merc" => Some(self.simd_merc_forward(coords, &parsed)),
874            "lcc" => Some(self.simd_lcc_forward(coords, &parsed)),
875            _ => None,
876        }
877    }
878
879    /// SIMD-accelerated Transverse Mercator / UTM forward batch.
880    fn simd_tmerc_forward(
881        &self,
882        coords: &[Coordinate],
883        parsed: &ProjString,
884    ) -> Result<Vec<Coordinate>> {
885        use simd::{WGS84_A, WGS84_E2, tmerc_forward_batch};
886
887        // Extract parameters from the PROJ string.
888        // `+proj=utm +zone=N` is a shorthand for tmerc with standard parameters.
889        let proj_type = parsed.proj().unwrap_or("tmerc");
890
891        let (lon0_rad, k0, false_easting, false_northing, a, e2) = if proj_type == "utm" {
892            // UTM shorthand: zone → central meridian, k0=0.9996, FE=500000, FN=0/10000000
893            let zone = parsed.zone().unwrap_or(32) as f64;
894            let lon0_deg = zone * 6.0 - 183.0;
895            let false_northing = if parsed.has("south") {
896                10_000_000.0
897            } else {
898                0.0
899            };
900            (
901                lon0_deg.to_radians(),
902                0.9996,
903                500_000.0,
904                false_northing,
905                WGS84_A,
906                WGS84_E2,
907            )
908        } else {
909            // Generic tmerc — read all parameters explicitly.
910            let lon0_deg = parsed
911                .get("lon_0")
912                .and_then(|s| s.parse::<f64>().ok())
913                .unwrap_or(0.0);
914            let k0 = parsed
915                .get("k")
916                .or_else(|| parsed.get("k_0"))
917                .and_then(|s| s.parse::<f64>().ok())
918                .unwrap_or(1.0);
919            let fe = parsed
920                .get("x_0")
921                .and_then(|s| s.parse::<f64>().ok())
922                .unwrap_or(0.0);
923            let fn_ = parsed
924                .get("y_0")
925                .and_then(|s| s.parse::<f64>().ok())
926                .unwrap_or(0.0);
927
928            // Ellipsoid: prefer explicit +a/+b or +ellps; default to WGS84.
929            let (a, e2) = parse_ellipsoid(parsed);
930            (lon0_deg.to_radians(), k0, fe, fn_, a, e2)
931        };
932
933        // Decompose coordinates into separate lon/lat arrays (degrees → radians).
934        let lons: Vec<f64> = coords.iter().map(|c| c.x.to_radians()).collect();
935        let lats: Vec<f64> = coords.iter().map(|c| c.y.to_radians()).collect();
936
937        let (xs, ys) = tmerc_forward_batch(
938            &lons,
939            &lats,
940            k0,
941            lon0_rad,
942            false_easting,
943            false_northing,
944            a,
945            e2,
946        );
947
948        let result: Vec<Coordinate> = xs
949            .into_iter()
950            .zip(ys)
951            .map(|(x, y)| {
952                let c = Coordinate::new(x, y);
953                if c.is_valid() {
954                    Ok(c)
955                } else {
956                    Err(Error::transformation_error(
957                        "tmerc_batch: non-finite result",
958                    ))
959                }
960            })
961            .collect::<Result<Vec<_>>>()?;
962
963        Ok(result)
964    }
965
966    /// SIMD-accelerated Mercator forward batch.
967    fn simd_merc_forward(
968        &self,
969        coords: &[Coordinate],
970        parsed: &ProjString,
971    ) -> Result<Vec<Coordinate>> {
972        use simd::{WGS84_A, WGS84_E, merc_forward_batch};
973
974        let lon0_deg = parsed
975            .get("lon_0")
976            .and_then(|s| s.parse::<f64>().ok())
977            .unwrap_or(0.0);
978        let k0 = parsed
979            .get("k")
980            .or_else(|| parsed.get("k_0"))
981            .and_then(|s| s.parse::<f64>().ok())
982            .unwrap_or(1.0);
983
984        let (a, e2) = parse_ellipsoid(parsed);
985        let e = e2.sqrt();
986
987        let lons: Vec<f64> = coords.iter().map(|c| c.x.to_radians()).collect();
988        let lats: Vec<f64> = coords.iter().map(|c| c.y.to_radians()).collect();
989
990        // Pseudo-Mercator (EPSG:3857) uses a sphere: a=b=6378137, so e=0.
991        // When the PROJ string contains `+a` and `+b` with equal values, treat as sphere.
992        let e_eff = if e < 1e-10 { 0.0 } else { e };
993        let _ = (WGS84_E, WGS84_A); // keep imports used
994
995        let (xs, ys) = merc_forward_batch(&lons, &lats, lon0_deg.to_radians(), k0, a, e_eff);
996
997        let result: Vec<Coordinate> = xs
998            .into_iter()
999            .zip(ys)
1000            .map(|(x, y)| {
1001                let c = Coordinate::new(x, y);
1002                if c.is_valid() {
1003                    Ok(c)
1004                } else {
1005                    Err(Error::transformation_error("merc_batch: non-finite result"))
1006                }
1007            })
1008            .collect::<Result<Vec<_>>>()?;
1009
1010        Ok(result)
1011    }
1012
1013    /// SIMD-accelerated Lambert Conformal Conic forward batch.
1014    fn simd_lcc_forward(
1015        &self,
1016        coords: &[Coordinate],
1017        parsed: &ProjString,
1018    ) -> Result<Vec<Coordinate>> {
1019        use simd::{WGS84_A, lcc_cone_params, lcc_forward_batch};
1020
1021        let lon0_deg = parsed
1022            .get("lon_0")
1023            .and_then(|s| s.parse::<f64>().ok())
1024            .unwrap_or(0.0);
1025        let lat0_deg = parsed
1026            .get("lat_0")
1027            .and_then(|s| s.parse::<f64>().ok())
1028            .unwrap_or(0.0);
1029        // lat_1 / lat_2: standard parallels.  If only lat_1 is given, use it twice.
1030        let lat1_deg = parsed
1031            .get("lat_1")
1032            .and_then(|s| s.parse::<f64>().ok())
1033            .unwrap_or(lat0_deg);
1034        let lat2_deg = parsed
1035            .get("lat_2")
1036            .and_then(|s| s.parse::<f64>().ok())
1037            .unwrap_or(lat1_deg);
1038        let fe = parsed
1039            .get("x_0")
1040            .and_then(|s| s.parse::<f64>().ok())
1041            .unwrap_or(0.0);
1042        let fn_ = parsed
1043            .get("y_0")
1044            .and_then(|s| s.parse::<f64>().ok())
1045            .unwrap_or(0.0);
1046
1047        let (a, _e2) = parse_ellipsoid(parsed);
1048        let _ = WGS84_A; // used via parse_ellipsoid default
1049
1050        let lat0_rad = lat0_deg.to_radians();
1051        let lat1_rad = lat1_deg.to_radians();
1052        let lat2_rad = lat2_deg.to_radians();
1053
1054        let (n, big_f, rho0_normalised) = match lcc_cone_params(lat0_rad, lat1_rad, lat2_rad) {
1055            Some(p) => p,
1056            None => {
1057                return Err(Error::projection_init_error(
1058                    "lcc_batch: degenerate cone constant",
1059                ));
1060            }
1061        };
1062        let rho0 = rho0_normalised * a;
1063
1064        let lons: Vec<f64> = coords.iter().map(|c| c.x.to_radians()).collect();
1065        let lats: Vec<f64> = coords.iter().map(|c| c.y.to_radians()).collect();
1066
1067        let (xs, ys) = lcc_forward_batch(
1068            &lons,
1069            &lats,
1070            n,
1071            big_f,
1072            rho0,
1073            lon0_deg.to_radians(),
1074            fe,
1075            fn_,
1076            a,
1077        );
1078
1079        let result: Vec<Coordinate> = xs
1080            .into_iter()
1081            .zip(ys)
1082            .map(|(x, y)| {
1083                let c = Coordinate::new(x, y);
1084                if c.is_valid() {
1085                    Ok(c)
1086                } else {
1087                    Err(Error::transformation_error("lcc_batch: non-finite result"))
1088                }
1089            })
1090            .collect::<Result<Vec<_>>>()?;
1091
1092        Ok(result)
1093    }
1094
1095    /// Transforms a bounding box.
1096    ///
1097    /// This transforms all four corners and creates a new bounding box from the results.
1098    ///
1099    /// # Arguments
1100    ///
1101    /// * `bbox` - Input bounding box in source CRS
1102    ///
1103    /// # Errors
1104    ///
1105    /// Returns an error if the transformation fails.
1106    pub fn transform_bbox(&self, bbox: &BoundingBox) -> Result<BoundingBox> {
1107        if self.proj.is_none() {
1108            return Ok(*bbox);
1109        }
1110
1111        // Transform all four corners
1112        let corners = bbox.corners();
1113        let transformed_corners = self.transform_batch(&corners)?;
1114
1115        // Find new bounds
1116        let mut min_x = f64::INFINITY;
1117        let mut min_y = f64::INFINITY;
1118        let mut max_x = f64::NEG_INFINITY;
1119        let mut max_y = f64::NEG_INFINITY;
1120
1121        for corner in &transformed_corners {
1122            min_x = min_x.min(corner.x);
1123            min_y = min_y.min(corner.y);
1124            max_x = max_x.max(corner.x);
1125            max_y = max_y.max(corner.y);
1126        }
1127
1128        BoundingBox::new(min_x, min_y, max_x, max_y)
1129    }
1130
1131    /// Internal implementation of coordinate transformation using proj4rs.
1132    fn transform_impl(&self, coord: &Coordinate) -> Result<Coordinate> {
1133        let source_proj_str = self.source_crs.to_proj_string()?;
1134        let target_proj_str = self.target_crs.to_proj_string()?;
1135
1136        let source_proj = proj4rs::Proj::from_proj_string(&source_proj_str)
1137            .map_err(|e| Error::from_proj4rs(format!("{:?}", e)))?;
1138
1139        let target_proj = proj4rs::Proj::from_proj_string(&target_proj_str)
1140            .map_err(|e| Error::from_proj4rs(format!("{:?}", e)))?;
1141
1142        // Convert to radians if source is geographic
1143        let mut x = coord.x;
1144        let mut y = coord.y;
1145
1146        if self.source_crs.is_geographic() {
1147            x = x.to_radians();
1148            y = y.to_radians();
1149        }
1150
1151        // Perform transformation using a mutable array (proj4rs requires slice)
1152        let mut points = [(x, y)];
1153        proj4rs::transform::transform(&source_proj, &target_proj, &mut points[..])
1154            .map_err(|e| Error::transformation_error(format!("{:?}", e)))?;
1155
1156        let (mut result_x, mut result_y) = points[0];
1157
1158        // Convert from radians if target is geographic
1159        if self.target_crs.is_geographic() {
1160            result_x = result_x.to_degrees();
1161            result_y = result_y.to_degrees();
1162        }
1163
1164        let transformed = Coordinate::new(result_x, result_y);
1165
1166        if !transformed.is_valid() {
1167            return Err(Error::transformation_error(
1168                "Transformation resulted in non-finite values",
1169            ));
1170        }
1171
1172        Ok(transformed)
1173    }
1174}
1175
1176/// Parse ellipsoid parameters from a `ProjString`.
1177///
1178/// Returns `(a, e2)` — semi-major axis in metres and first eccentricity squared.
1179///
1180/// Priority order:
1181/// 1. Explicit `+a` and (`+b` or `+f` or `+rf`).
1182/// 2. Named ellipsoid `+ellps` (only GRS80 and WGS84 are recognised here).
1183/// 3. Named datum `+datum` (only WGS84 is recognised here).
1184/// 4. Default: WGS84.
1185#[cfg(feature = "std")]
1186fn parse_ellipsoid(parsed: &ProjString) -> (f64, f64) {
1187    use simd::{WGS84_A, WGS84_E2};
1188
1189    // 1. Explicit semi-major axis.
1190    if let Some(a_val) = parsed.get("a").and_then(|s| s.parse::<f64>().ok()) {
1191        // Try explicit semi-minor axis.
1192        if let Some(b_val) = parsed.get("b").and_then(|s| s.parse::<f64>().ok()) {
1193            let f = 1.0 - b_val / a_val;
1194            let e2 = 2.0 * f - f * f;
1195            return (a_val, e2);
1196        }
1197        // Try flattening.
1198        if let Some(f_val) = parsed.get("f").and_then(|s| s.parse::<f64>().ok()) {
1199            let e2 = 2.0 * f_val - f_val * f_val;
1200            return (a_val, e2);
1201        }
1202        // Try reciprocal flattening.
1203        if let Some(rf) = parsed.get("rf").and_then(|s| s.parse::<f64>().ok()) {
1204            let f = 1.0 / rf;
1205            let e2 = 2.0 * f - f * f;
1206            return (a_val, e2);
1207        }
1208        // Semi-major only (treat as sphere).
1209        return (a_val, 0.0);
1210    }
1211
1212    // 2. Named ellipsoid.
1213    if let Some(ellps) = parsed.get("ellps") {
1214        match ellps {
1215            "WGS84" | "wgs84" => return (WGS84_A, WGS84_E2),
1216            "GRS80" | "grs80" => {
1217                // GRS80: a=6378137, f=1/298.257222101
1218                let a = 6_378_137.0_f64;
1219                let f = 1.0_f64 / 298.257_222_101;
1220                let e2 = 2.0 * f - f * f;
1221                return (a, e2);
1222            }
1223            "bessel" => {
1224                // Bessel 1841: a=6377397.155, f=1/299.1528128
1225                let a = 6_377_397.155_f64;
1226                let f = 1.0_f64 / 299.152_812_8;
1227                let e2 = 2.0 * f - f * f;
1228                return (a, e2);
1229            }
1230            "airy" => {
1231                // Airy 1830: a=6377563.396, b=6356256.910
1232                let a = 6_377_563.396_f64;
1233                let b = 6_356_256.910_f64;
1234                let f = 1.0 - b / a;
1235                let e2 = 2.0 * f - f * f;
1236                return (a, e2);
1237            }
1238            _ => {}
1239        }
1240    }
1241
1242    // 3. Named datum.
1243    if let Some(datum) = parsed.get("datum") {
1244        match datum {
1245            "WGS84" | "wgs84" => return (WGS84_A, WGS84_E2),
1246            "NAD83" | "nad83" => {
1247                // NAD83 uses GRS80
1248                let a = 6_378_137.0_f64;
1249                let f = 1.0_f64 / 298.257_222_101;
1250                let e2 = 2.0 * f - f * f;
1251                return (a, e2);
1252            }
1253            _ => {}
1254        }
1255    }
1256
1257    // 4. Default: WGS84
1258    (WGS84_A, WGS84_E2)
1259}
1260
1261/// Transforms a coordinate from one CRS to another (convenience function).
1262#[cfg(feature = "std")]
1263///
1264/// # Arguments
1265///
1266/// * `coord` - Input coordinate
1267/// * `source_crs` - Source CRS
1268/// * `target_crs` - Target CRS
1269///
1270/// # Errors
1271///
1272/// Returns an error if the transformation fails.
1273pub fn transform_coordinate(
1274    coord: &Coordinate,
1275    source_crs: &Crs,
1276    target_crs: &Crs,
1277) -> Result<Coordinate> {
1278    let transformer = Transformer::new(source_crs.clone(), target_crs.clone())?;
1279    transformer.transform(coord)
1280}
1281
1282/// Transforms coordinates from one EPSG code to another (convenience function).
1283#[cfg(feature = "std")]
1284///
1285/// # Arguments
1286///
1287/// * `coord` - Input coordinate
1288/// * `source_epsg` - Source EPSG code
1289/// * `target_epsg` - Target EPSG code
1290///
1291/// # Errors
1292///
1293/// Returns an error if the transformation fails.
1294pub fn transform_epsg(
1295    coord: &Coordinate,
1296    source_epsg: u32,
1297    target_epsg: u32,
1298) -> Result<Coordinate> {
1299    let transformer = Transformer::from_epsg(source_epsg, target_epsg)?;
1300    transformer.transform(coord)
1301}
1302
1303#[cfg(test)]
1304#[allow(clippy::expect_used)]
1305mod tests {
1306    use super::*;
1307    use approx::assert_relative_eq;
1308
1309    #[test]
1310    fn test_coordinate_creation() {
1311        let coord = Coordinate::new(10.0, 20.0);
1312        assert_eq!(coord.x, 10.0);
1313        assert_eq!(coord.y, 20.0);
1314    }
1315
1316    #[test]
1317    fn test_coordinate_from_lon_lat() {
1318        let coord = Coordinate::from_lon_lat(-122.4194, 37.7749);
1319        assert_eq!(coord.lon(), -122.4194);
1320        assert_eq!(coord.lat(), 37.7749);
1321    }
1322
1323    #[test]
1324    fn test_coordinate_validation() {
1325        let valid = Coordinate::new(0.0, 0.0);
1326        assert!(valid.validate_geographic().is_ok());
1327
1328        let invalid_lon = Coordinate::new(200.0, 0.0);
1329        assert!(invalid_lon.validate_geographic().is_err());
1330
1331        let invalid_lat = Coordinate::new(0.0, 100.0);
1332        assert!(invalid_lat.validate_geographic().is_err());
1333    }
1334
1335    #[test]
1336    fn test_coordinate_is_valid() {
1337        let valid = Coordinate::new(1.0, 2.0);
1338        assert!(valid.is_valid());
1339
1340        let invalid = Coordinate::new(f64::NAN, 2.0);
1341        assert!(!invalid.is_valid());
1342
1343        let infinite = Coordinate::new(f64::INFINITY, 2.0);
1344        assert!(!infinite.is_valid());
1345    }
1346
1347    #[test]
1348    fn test_coordinate3d() {
1349        let coord = Coordinate3D::new(1.0, 2.0, 3.0);
1350        assert_eq!(coord.x, 1.0);
1351        assert_eq!(coord.y, 2.0);
1352        assert_eq!(coord.z, 3.0);
1353
1354        let coord_2d = coord.to_2d();
1355        assert_eq!(coord_2d.x, 1.0);
1356        assert_eq!(coord_2d.y, 2.0);
1357    }
1358
1359    #[test]
1360    fn test_bounding_box() {
1361        let bbox = BoundingBox::new(0.0, 0.0, 10.0, 20.0);
1362        assert!(bbox.is_ok());
1363
1364        let bbox = bbox.expect("should be valid");
1365        assert_eq!(bbox.width(), 10.0);
1366        assert_eq!(bbox.height(), 20.0);
1367
1368        let center = bbox.center();
1369        assert_eq!(center.x, 5.0);
1370        assert_eq!(center.y, 10.0);
1371    }
1372
1373    #[test]
1374    fn test_bounding_box_invalid() {
1375        let result = BoundingBox::new(10.0, 0.0, 0.0, 20.0);
1376        assert!(result.is_err());
1377
1378        let result = BoundingBox::new(0.0, 20.0, 10.0, 0.0);
1379        assert!(result.is_err());
1380    }
1381
1382    #[test]
1383    fn test_bounding_box_contains() {
1384        let bbox = BoundingBox::new(0.0, 0.0, 10.0, 10.0).expect("valid bbox");
1385
1386        assert!(bbox.contains(&Coordinate::new(5.0, 5.0)));
1387        assert!(bbox.contains(&Coordinate::new(0.0, 0.0)));
1388        assert!(bbox.contains(&Coordinate::new(10.0, 10.0)));
1389        assert!(!bbox.contains(&Coordinate::new(-1.0, 5.0)));
1390        assert!(!bbox.contains(&Coordinate::new(5.0, 11.0)));
1391    }
1392
1393    #[test]
1394    fn test_bounding_box_expand() {
1395        let mut bbox = BoundingBox::new(0.0, 0.0, 10.0, 10.0).expect("valid bbox");
1396
1397        bbox.expand_to_include(&Coordinate::new(15.0, 5.0));
1398        assert_eq!(bbox.max_x, 15.0);
1399
1400        bbox.expand_to_include(&Coordinate::new(5.0, -5.0));
1401        assert_eq!(bbox.min_y, -5.0);
1402    }
1403
1404    #[test]
1405    fn test_transformer_same_crs() {
1406        let wgs84 = Crs::wgs84();
1407        let transformer = Transformer::new(wgs84.clone(), wgs84.clone());
1408        assert!(transformer.is_ok());
1409
1410        let transformer = transformer.expect("should create transformer");
1411        let coord = Coordinate::new(10.0, 20.0);
1412        let result = transformer.transform(&coord);
1413        assert!(result.is_ok());
1414
1415        let result = result.expect("should transform");
1416        assert_eq!(result, coord);
1417    }
1418
1419    #[test]
1420    fn test_transformer_wgs84_to_web_mercator() {
1421        let transformer = Transformer::from_epsg(4326, 3857);
1422        assert!(transformer.is_ok());
1423
1424        let transformer = transformer.expect("should create transformer");
1425
1426        // Transform London coordinates (0.0, 51.5)
1427        let london = Coordinate::from_lon_lat(0.0, 51.5);
1428        let result = transformer.transform(&london);
1429        assert!(result.is_ok());
1430
1431        let result = result.expect("should transform");
1432        // Web Mercator should give us meters from equator
1433        // X should be close to 0 (prime meridian)
1434        assert_relative_eq!(result.x, 0.0, epsilon = 1.0);
1435        // Y should be positive (northern hemisphere)
1436        assert!(result.y > 6_000_000.0 && result.y < 7_000_000.0);
1437    }
1438
1439    #[test]
1440    fn test_transform_batch() {
1441        let transformer = Transformer::from_epsg(4326, 4326).expect("same CRS");
1442
1443        let coords = vec![
1444            Coordinate::new(0.0, 0.0),
1445            Coordinate::new(10.0, 10.0),
1446            Coordinate::new(20.0, 20.0),
1447        ];
1448
1449        let result = transformer.transform_batch(&coords);
1450        assert!(result.is_ok());
1451
1452        let result = result.expect("should transform");
1453        assert_eq!(result.len(), 3);
1454        assert_eq!(result[0], coords[0]);
1455        assert_eq!(result[1], coords[1]);
1456        assert_eq!(result[2], coords[2]);
1457    }
1458
1459    #[test]
1460    fn test_transform_bbox() {
1461        let transformer = Transformer::from_epsg(4326, 4326).expect("same CRS");
1462
1463        let bbox = BoundingBox::new(0.0, 0.0, 10.0, 10.0).expect("valid bbox");
1464        let result = transformer.transform_bbox(&bbox);
1465        assert!(result.is_ok());
1466
1467        let result = result.expect("should transform");
1468        assert_eq!(result, bbox);
1469    }
1470
1471    #[test]
1472    fn test_convenience_functions() {
1473        let wgs84 = Crs::wgs84();
1474        let coord = Coordinate::new(0.0, 0.0);
1475
1476        let result = transform_coordinate(&coord, &wgs84, &wgs84);
1477        assert!(result.is_ok());
1478        assert_eq!(result.expect("should transform"), coord);
1479
1480        let result = transform_epsg(&coord, 4326, 4326);
1481        assert!(result.is_ok());
1482        assert_eq!(result.expect("should transform"), coord);
1483    }
1484
1485    #[test]
1486    fn test_transform_invalid_coordinate() {
1487        let transformer = Transformer::from_epsg(4326, 3857).expect("should create");
1488
1489        let invalid = Coordinate::new(f64::NAN, 0.0);
1490        let result = transformer.transform(&invalid);
1491        assert!(result.is_err());
1492    }
1493
1494    // =========================================================================
1495    // Compound CRS transform_3d tests
1496    // =========================================================================
1497
1498    /// Build a compound CRS using programmatic constructor.  Reused across several tests.
1499    ///
1500    /// Using `Crs::compound` with an EPSG-backed horizontal ensures the sub-transformer
1501    /// can obtain a PROJ string from the horizontal component.
1502    fn make_compound_wgs84_egm96() -> crate::crs::Crs {
1503        let horiz = Crs::wgs84(); // EPSG:4326
1504        let vert_wkt = r#"VERTCRS["EGM96 height",VDATUM["EGM96 geoid"],UNIT["metre",1]]"#;
1505        let vert = crate::crs::Crs::from_wkt(vert_wkt).expect("vert parse");
1506        crate::crs::Crs::compound(horiz, vert).expect("compound CRS should build")
1507    }
1508
1509    #[test]
1510    fn test_transform_3d_compound_same_vertical_datum_passes_z_through() {
1511        // When source == target compound CRS (same vertical datum), z must come through unchanged.
1512        let crs = make_compound_wgs84_egm96();
1513        let transformer = Transformer::new(crs.clone(), crs).expect("same-CRS transformer");
1514        let input = Coordinate3D::new(13.4050, 52.5200, 34.567);
1515        let output = transformer
1516            .transform_3d(&input)
1517            .expect("transform should succeed");
1518        // Horizontal: same CRS → passthrough.
1519        assert!((output.x - input.x).abs() < 1e-9, "x should be unchanged");
1520        assert!((output.y - input.y).abs() < 1e-9, "y should be unchanged");
1521        // Vertical: same vertical datum → z unchanged.
1522        assert!(
1523            (output.z - input.z).abs() < 1e-9,
1524            "z should be passed through"
1525        );
1526    }
1527
1528    #[test]
1529    fn test_transform_3d_compound_different_vertical_datum_silently_passes_through_when_no_geoid() {
1530        // Slice-14 W1 contract: when the vertical datums differ AND no geoid model
1531        // has been attached via `with_geoid`, `transform_3d` must silently let `z`
1532        // pass through unchanged (back-compat with pre-Slice-14 builds).  A hard
1533        // error is only emitted by the explicit `Error::geoid_not_available`
1534        // constructor or via callers that opt-in to a stricter validation path.
1535        let horiz = Crs::wgs84(); // EPSG:4326 — has a PROJ string
1536
1537        let vert1_wkt = r#"VERTCRS["EGM96 height",VDATUM["EGM96 geoid"],UNIT["metre",1]]"#;
1538        let vert2_wkt = r#"VERTCRS["EGM2008 height",VDATUM["EGM2008 geoid"],UNIT["metre",1]]"#;
1539
1540        let vert1 = Crs::from_wkt(vert1_wkt).expect("vert1 parse");
1541        let vert2 = Crs::from_wkt(vert2_wkt).expect("vert2 parse");
1542
1543        let crs1 = Crs::compound(horiz.clone(), vert1).expect("compound crs1");
1544        let crs2 = Crs::compound(horiz, vert2).expect("compound crs2");
1545
1546        let transformer = Transformer::new(crs1, crs2).expect("different-vertical transformer");
1547        let input = Coordinate3D::new(0.0, 51.5, 50.0);
1548        let output = transformer
1549            .transform_3d(&input)
1550            .expect("must succeed without geoid (silent passthrough)");
1551        assert!((output.x - input.x).abs() < 1e-9);
1552        assert!((output.y - input.y).abs() < 1e-9);
1553        assert!(
1554            (output.z - input.z).abs() < 1e-9,
1555            "z must pass through when no geoid attached"
1556        );
1557    }
1558
1559    #[test]
1560    fn test_transform_3d_simple_non_compound_crs_unaffected() {
1561        // Ordinary EPSG-based transformer must still work exactly as before.
1562        let transformer = Transformer::from_epsg(4326, 4326).expect("same EPSG");
1563        let input = Coordinate3D::new(10.0, 50.0, 100.0);
1564        let output = transformer.transform_3d(&input).expect("should transform");
1565        assert!((output.x - input.x).abs() < 1e-9);
1566        assert!((output.y - input.y).abs() < 1e-9);
1567        assert!((output.z - input.z).abs() < 1e-9);
1568    }
1569
1570    #[test]
1571    fn test_transform_3d_compound_to_horizontal_still_works() {
1572        // When only one side is Compound (non-compound target), the code should
1573        // fall through to the normal transform_impl path.
1574        // We just verify no panic / unexpected error for a same-EPSG pair.
1575        let non_compound = Crs::wgs84();
1576        let transformer = Transformer::new(non_compound.clone(), non_compound).expect("same CRS");
1577        let input = Coordinate3D::new(5.0, 45.0, 200.0);
1578        let output = transformer.transform_3d(&input).expect("should transform");
1579        assert!((output.x - input.x).abs() < 1e-9);
1580        assert!((output.y - input.y).abs() < 1e-9);
1581        assert!((output.z - input.z).abs() < 1e-9);
1582    }
1583}