Skip to main content

proj_core/
crs.rs

1use crate::datum::Datum;
2use crate::error::{Error, Result};
3
4/// A coordinate system's projected linear unit.
5///
6/// The stored value is the conversion factor from one native unit to meters.
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub struct LinearUnit {
9    meters_per_unit: f64,
10}
11
12impl LinearUnit {
13    /// Metre-based projected coordinates.
14    pub const fn metre() -> Self {
15        Self {
16            meters_per_unit: 1.0,
17        }
18    }
19
20    /// Alias for [`LinearUnit::metre`].
21    pub const fn meter() -> Self {
22        Self::metre()
23    }
24
25    /// Kilometer-based projected coordinates.
26    pub const fn kilometre() -> Self {
27        Self {
28            meters_per_unit: 1000.0,
29        }
30    }
31
32    /// Alias for [`LinearUnit::kilometre`].
33    pub const fn kilometer() -> Self {
34        Self::kilometre()
35    }
36
37    /// International foot-based projected coordinates.
38    pub const fn foot() -> Self {
39        Self {
40            meters_per_unit: 0.3048,
41        }
42    }
43
44    /// US survey foot-based projected coordinates.
45    pub const fn us_survey_foot() -> Self {
46        Self {
47            meters_per_unit: 0.3048006096012192,
48        }
49    }
50
51    /// Construct a custom projected linear unit from its meter conversion factor.
52    pub fn from_meters_per_unit(meters_per_unit: f64) -> Result<Self> {
53        if !meters_per_unit.is_finite() || meters_per_unit <= 0.0 {
54            return Err(Error::InvalidDefinition(
55                "linear unit conversion factor must be a finite positive number".into(),
56            ));
57        }
58
59        Ok(Self { meters_per_unit })
60    }
61
62    /// Return the number of meters represented by one native projected unit.
63    pub const fn meters_per_unit(self) -> f64 {
64        self.meters_per_unit
65    }
66
67    /// Convert a native projected coordinate value into meters.
68    pub const fn to_meters(self, value: f64) -> f64 {
69        value * self.meters_per_unit
70    }
71
72    /// Convert a meter value into the native projected unit.
73    pub const fn from_meters(self, value: f64) -> f64 {
74        value / self.meters_per_unit
75    }
76}
77
78/// A Coordinate Reference System definition.
79#[derive(Debug, Clone)]
80pub enum CrsDef {
81    /// Geographic CRS (lon/lat in degrees).
82    Geographic(GeographicCrsDef),
83    /// Projected CRS (easting/northing in the CRS's native linear unit).
84    Projected(ProjectedCrsDef),
85    /// Compound horizontal + vertical CRS.
86    Compound(Box<CompoundCrsDef>),
87}
88
89impl CrsDef {
90    /// Get the horizontal datum for this CRS.
91    pub fn datum(&self) -> &Datum {
92        match self {
93            CrsDef::Geographic(g) => g.datum(),
94            CrsDef::Projected(p) => p.datum(),
95            CrsDef::Compound(c) => c.horizontal_datum(),
96        }
97    }
98
99    /// Get the EPSG code for this CRS.
100    pub fn epsg(&self) -> u32 {
101        match self {
102            CrsDef::Geographic(g) => g.epsg(),
103            CrsDef::Projected(p) => p.epsg(),
104            CrsDef::Compound(c) => c.epsg(),
105        }
106    }
107
108    /// Get the CRS name.
109    pub fn name(&self) -> &str {
110        match self {
111            CrsDef::Geographic(g) => g.name(),
112            CrsDef::Projected(p) => p.name(),
113            CrsDef::Compound(c) => c.name(),
114        }
115    }
116
117    /// Returns true if this CRS's horizontal component is geographic.
118    pub fn is_geographic(&self) -> bool {
119        self.as_geographic().is_some()
120    }
121
122    /// Returns true if this CRS's horizontal component is projected.
123    pub fn is_projected(&self) -> bool {
124        self.as_projected().is_some()
125    }
126
127    /// Returns true if this is a compound horizontal + vertical CRS.
128    pub fn is_compound(&self) -> bool {
129        matches!(self, CrsDef::Compound(_))
130    }
131
132    /// Return the geographic horizontal component, when present.
133    pub fn as_geographic(&self) -> Option<&GeographicCrsDef> {
134        match self {
135            CrsDef::Geographic(g) => Some(g),
136            CrsDef::Projected(_) => None,
137            CrsDef::Compound(c) => c.as_geographic(),
138        }
139    }
140
141    /// Return the projected horizontal component, when present.
142    pub fn as_projected(&self) -> Option<&ProjectedCrsDef> {
143        match self {
144            CrsDef::Geographic(_) => None,
145            CrsDef::Projected(p) => Some(p),
146            CrsDef::Compound(c) => c.as_projected(),
147        }
148    }
149
150    /// Return the explicit vertical CRS component, when this is compound.
151    pub fn vertical_crs(&self) -> Option<&VerticalCrsDef> {
152        match self {
153            CrsDef::Compound(c) => Some(c.vertical_crs()),
154            CrsDef::Geographic(_) | CrsDef::Projected(_) => None,
155        }
156    }
157
158    /// Return this CRS's horizontal component as a standalone CRS definition.
159    ///
160    /// This intentionally drops an explicit vertical component. Use it only for
161    /// horizontal-only workflows such as AOI filtering, footprint reprojection,
162    /// and 2D previews where `z` is outside the operation contract.
163    pub fn horizontal_crs(&self) -> Option<CrsDef> {
164        match self {
165            CrsDef::Geographic(_) | CrsDef::Projected(_) => Some(self.clone()),
166            CrsDef::Compound(c) => Some(c.horizontal().to_crs_def()),
167        }
168    }
169
170    /// Returns the geographic CRS EPSG code used for operation selection, when known.
171    pub fn base_geographic_crs_epsg(&self) -> Option<u32> {
172        match self {
173            CrsDef::Geographic(g) if g.epsg() != 0 => Some(g.epsg()),
174            CrsDef::Projected(p) if p.base_geographic_crs_epsg() != 0 => {
175                Some(p.base_geographic_crs_epsg())
176            }
177            CrsDef::Compound(c) => c.base_geographic_crs_epsg(),
178            _ => None,
179        }
180    }
181
182    /// Returns true when two CRS definitions map to the same internal semantics.
183    pub fn semantically_equivalent(&self, other: &Self) -> bool {
184        match (self, other) {
185            (CrsDef::Geographic(a), CrsDef::Geographic(b)) => a.datum().same_datum(b.datum()),
186            (CrsDef::Projected(a), CrsDef::Projected(b)) => {
187                a.datum().same_datum(b.datum())
188                    && approx_eq(a.linear_unit_to_meter(), b.linear_unit_to_meter())
189                    && projection_methods_equivalent(&a.method(), &b.method())
190            }
191            (CrsDef::Compound(a), CrsDef::Compound(b)) => a.semantically_equivalent(b),
192            _ => false,
193        }
194    }
195}
196
197/// Definition of a geographic CRS (longitude, latitude in degrees).
198#[derive(Debug, Clone)]
199pub struct GeographicCrsDef {
200    epsg: u32,
201    datum: Datum,
202    name: &'static str,
203}
204
205impl GeographicCrsDef {
206    pub const fn new(epsg: u32, datum: Datum, name: &'static str) -> Self {
207        Self { epsg, datum, name }
208    }
209
210    pub const fn epsg(&self) -> u32 {
211        self.epsg
212    }
213
214    pub const fn datum(&self) -> &Datum {
215        &self.datum
216    }
217
218    pub const fn name(&self) -> &'static str {
219        self.name
220    }
221}
222
223/// Definition of a projected CRS (easting, northing in the CRS's native linear unit).
224#[derive(Debug, Clone)]
225pub struct ProjectedCrsDef {
226    epsg: u32,
227    base_geographic_crs_epsg: u32,
228    datum: Datum,
229    method: ProjectionMethod,
230    linear_unit: LinearUnit,
231    name: &'static str,
232}
233
234impl ProjectedCrsDef {
235    pub const fn new(
236        epsg: u32,
237        datum: Datum,
238        method: ProjectionMethod,
239        linear_unit: LinearUnit,
240        name: &'static str,
241    ) -> Self {
242        Self::new_with_base_geographic_crs(epsg, 0, datum, method, linear_unit, name)
243    }
244
245    pub const fn new_with_base_geographic_crs(
246        epsg: u32,
247        base_geographic_crs_epsg: u32,
248        datum: Datum,
249        method: ProjectionMethod,
250        linear_unit: LinearUnit,
251        name: &'static str,
252    ) -> Self {
253        Self {
254            epsg,
255            base_geographic_crs_epsg,
256            datum,
257            method,
258            linear_unit,
259            name,
260        }
261    }
262
263    pub const fn epsg(&self) -> u32 {
264        self.epsg
265    }
266
267    pub const fn datum(&self) -> &Datum {
268        &self.datum
269    }
270
271    pub const fn base_geographic_crs_epsg(&self) -> u32 {
272        self.base_geographic_crs_epsg
273    }
274
275    pub const fn method(&self) -> ProjectionMethod {
276        self.method
277    }
278
279    pub const fn linear_unit(&self) -> LinearUnit {
280        self.linear_unit
281    }
282
283    pub const fn linear_unit_to_meter(&self) -> f64 {
284        self.linear_unit.meters_per_unit()
285    }
286
287    pub const fn name(&self) -> &'static str {
288        self.name
289    }
290}
291
292/// A compound CRS made from one horizontal CRS and one vertical CRS.
293#[derive(Debug, Clone)]
294pub struct CompoundCrsDef {
295    epsg: u32,
296    horizontal: HorizontalCrsDef,
297    vertical: VerticalCrsDef,
298    name: &'static str,
299}
300
301impl CompoundCrsDef {
302    pub fn new(
303        epsg: u32,
304        horizontal: HorizontalCrsDef,
305        vertical: VerticalCrsDef,
306        name: &'static str,
307    ) -> Self {
308        Self {
309            epsg,
310            horizontal,
311            vertical,
312            name,
313        }
314    }
315
316    pub fn from_crs_def(
317        epsg: u32,
318        horizontal: CrsDef,
319        vertical: VerticalCrsDef,
320        name: &'static str,
321    ) -> Result<Self> {
322        let horizontal = HorizontalCrsDef::try_from(horizontal)?;
323        Ok(Self::new(epsg, horizontal, vertical, name))
324    }
325
326    pub const fn epsg(&self) -> u32 {
327        self.epsg
328    }
329
330    pub const fn horizontal(&self) -> &HorizontalCrsDef {
331        &self.horizontal
332    }
333
334    pub const fn vertical_crs(&self) -> &VerticalCrsDef {
335        &self.vertical
336    }
337
338    pub const fn name(&self) -> &'static str {
339        self.name
340    }
341
342    pub fn as_geographic(&self) -> Option<&GeographicCrsDef> {
343        self.horizontal.as_geographic()
344    }
345
346    pub fn as_projected(&self) -> Option<&ProjectedCrsDef> {
347        self.horizontal.as_projected()
348    }
349
350    pub fn horizontal_datum(&self) -> &Datum {
351        self.horizontal.datum()
352    }
353
354    pub fn base_geographic_crs_epsg(&self) -> Option<u32> {
355        self.horizontal.base_geographic_crs_epsg()
356    }
357
358    pub fn semantically_equivalent(&self, other: &Self) -> bool {
359        self.horizontal.semantically_equivalent(&other.horizontal)
360            && self.vertical.semantically_equivalent(&other.vertical)
361    }
362}
363
364/// Horizontal component of a compound CRS.
365#[derive(Debug, Clone)]
366pub enum HorizontalCrsDef {
367    Geographic(GeographicCrsDef),
368    Projected(ProjectedCrsDef),
369}
370
371impl HorizontalCrsDef {
372    pub fn datum(&self) -> &Datum {
373        match self {
374            Self::Geographic(g) => g.datum(),
375            Self::Projected(p) => p.datum(),
376        }
377    }
378
379    pub fn epsg(&self) -> u32 {
380        match self {
381            Self::Geographic(g) => g.epsg(),
382            Self::Projected(p) => p.epsg(),
383        }
384    }
385
386    pub fn name(&self) -> &str {
387        match self {
388            Self::Geographic(g) => g.name(),
389            Self::Projected(p) => p.name(),
390        }
391    }
392
393    pub fn as_geographic(&self) -> Option<&GeographicCrsDef> {
394        match self {
395            Self::Geographic(g) => Some(g),
396            Self::Projected(_) => None,
397        }
398    }
399
400    pub fn as_projected(&self) -> Option<&ProjectedCrsDef> {
401        match self {
402            Self::Geographic(_) => None,
403            Self::Projected(p) => Some(p),
404        }
405    }
406
407    pub fn base_geographic_crs_epsg(&self) -> Option<u32> {
408        match self {
409            Self::Geographic(g) if g.epsg() != 0 => Some(g.epsg()),
410            Self::Projected(p) if p.base_geographic_crs_epsg() != 0 => {
411                Some(p.base_geographic_crs_epsg())
412            }
413            _ => None,
414        }
415    }
416
417    pub fn semantically_equivalent(&self, other: &Self) -> bool {
418        match (self, other) {
419            (Self::Geographic(a), Self::Geographic(b)) => a.datum().same_datum(b.datum()),
420            (Self::Projected(a), Self::Projected(b)) => {
421                a.datum().same_datum(b.datum())
422                    && approx_eq(a.linear_unit_to_meter(), b.linear_unit_to_meter())
423                    && projection_methods_equivalent(&a.method(), &b.method())
424            }
425            _ => false,
426        }
427    }
428
429    pub fn to_crs_def(&self) -> CrsDef {
430        match self {
431            Self::Geographic(g) => CrsDef::Geographic(g.clone()),
432            Self::Projected(p) => CrsDef::Projected(p.clone()),
433        }
434    }
435}
436
437impl TryFrom<CrsDef> for HorizontalCrsDef {
438    type Error = Error;
439
440    fn try_from(value: CrsDef) -> Result<Self> {
441        match value {
442            CrsDef::Geographic(g) => Ok(Self::Geographic(g)),
443            CrsDef::Projected(p) => Ok(Self::Projected(p)),
444            CrsDef::Compound(_) => Err(Error::InvalidDefinition(
445                "compound CRS horizontal component cannot itself be compound".into(),
446            )),
447        }
448    }
449}
450
451impl From<GeographicCrsDef> for HorizontalCrsDef {
452    fn from(value: GeographicCrsDef) -> Self {
453        Self::Geographic(value)
454    }
455}
456
457impl From<ProjectedCrsDef> for HorizontalCrsDef {
458    fn from(value: ProjectedCrsDef) -> Self {
459        Self::Projected(value)
460    }
461}
462
463/// Definition of an explicit vertical CRS component.
464#[derive(Debug, Clone)]
465pub struct VerticalCrsDef {
466    epsg: u32,
467    kind: VerticalCrsKind,
468    linear_unit: LinearUnit,
469    name: &'static str,
470}
471
472impl VerticalCrsDef {
473    /// Construct an ellipsoidal-height vertical CRS tied to a geodetic datum.
474    pub fn ellipsoidal_height(
475        epsg: u32,
476        datum: Datum,
477        linear_unit: LinearUnit,
478        name: &'static str,
479    ) -> Self {
480        Self {
481            epsg,
482            kind: VerticalCrsKind::EllipsoidalHeight {
483                datum: Box::new(datum),
484            },
485            linear_unit,
486            name,
487        }
488    }
489
490    /// Construct a gravity-related vertical CRS by vertical datum EPSG code.
491    pub fn gravity_related_height(
492        epsg: u32,
493        vertical_datum_epsg: u32,
494        linear_unit: LinearUnit,
495        name: &'static str,
496    ) -> Result<Self> {
497        if vertical_datum_epsg == 0 {
498            return Err(Error::InvalidDefinition(
499                "gravity-related vertical CRS requires a vertical datum EPSG code".into(),
500            ));
501        }
502
503        Ok(Self {
504            epsg,
505            kind: VerticalCrsKind::GravityRelatedHeight {
506                vertical_datum_epsg,
507            },
508            linear_unit,
509            name,
510        })
511    }
512
513    pub const fn epsg(&self) -> u32 {
514        self.epsg
515    }
516
517    pub const fn kind(&self) -> &VerticalCrsKind {
518        &self.kind
519    }
520
521    pub const fn linear_unit(&self) -> LinearUnit {
522        self.linear_unit
523    }
524
525    pub const fn linear_unit_to_meter(&self) -> f64 {
526        self.linear_unit.meters_per_unit()
527    }
528
529    pub const fn name(&self) -> &'static str {
530        self.name
531    }
532
533    pub fn semantically_equivalent(&self, other: &Self) -> bool {
534        approx_eq(self.linear_unit_to_meter(), other.linear_unit_to_meter())
535            && self.kind.semantically_equivalent(&other.kind)
536    }
537
538    /// Returns true when two vertical CRS definitions use the same vertical
539    /// reference frame, ignoring the coordinate unit.
540    pub fn same_vertical_reference(&self, other: &Self) -> bool {
541        self.kind.semantically_equivalent(&other.kind)
542    }
543
544    pub fn vertical_datum_epsg(&self) -> Option<u32> {
545        self.kind.vertical_datum_epsg()
546    }
547}
548
549/// Supported vertical CRS kinds.
550#[derive(Debug, Clone)]
551pub enum VerticalCrsKind {
552    /// Height above the ellipsoid of the referenced geodetic datum.
553    EllipsoidalHeight { datum: Box<Datum> },
554    /// Height relative to a gravity-related vertical datum.
555    GravityRelatedHeight { vertical_datum_epsg: u32 },
556}
557
558impl VerticalCrsKind {
559    pub fn semantically_equivalent(&self, other: &Self) -> bool {
560        match (self, other) {
561            (Self::EllipsoidalHeight { datum: a }, Self::EllipsoidalHeight { datum: b }) => {
562                a.same_datum(b)
563            }
564            (
565                Self::GravityRelatedHeight {
566                    vertical_datum_epsg: a,
567                },
568                Self::GravityRelatedHeight {
569                    vertical_datum_epsg: b,
570                },
571            ) => a == b,
572            _ => false,
573        }
574    }
575
576    pub const fn vertical_datum_epsg(&self) -> Option<u32> {
577        match self {
578            Self::EllipsoidalHeight { .. } => None,
579            Self::GravityRelatedHeight {
580                vertical_datum_epsg,
581            } => Some(*vertical_datum_epsg),
582        }
583    }
584
585    pub const fn is_ellipsoidal_height(&self) -> bool {
586        matches!(self, Self::EllipsoidalHeight { .. })
587    }
588
589    pub const fn is_gravity_related_height(&self) -> bool {
590        matches!(self, Self::GravityRelatedHeight { .. })
591    }
592}
593
594/// All supported projection methods with their parameters.
595///
596/// Angle parameters are stored in **degrees**. Conversion to radians happens
597/// at projection construction time (once), not per-transform.
598#[derive(Debug, Clone, Copy, PartialEq)]
599pub enum ProjectionMethod {
600    /// Web Mercator (EPSG:3857) — spherical Mercator on WGS84 semi-major axis.
601    WebMercator,
602
603    /// Transverse Mercator (includes UTM zones).
604    TransverseMercator {
605        /// Central meridian (degrees).
606        lon0: f64,
607        /// Latitude of origin (degrees).
608        lat0: f64,
609        /// Scale factor on central meridian.
610        k0: f64,
611        /// False easting (meters).
612        false_easting: f64,
613        /// False northing (meters).
614        false_northing: f64,
615    },
616
617    /// Polar Stereographic.
618    PolarStereographic {
619        /// Central meridian / straight vertical longitude (degrees).
620        lon0: f64,
621        /// Latitude of true scale (degrees). Determines the hemisphere.
622        lat_ts: f64,
623        /// Scale factor (used when lat_ts = ±90°, otherwise derived from lat_ts).
624        k0: f64,
625        /// False easting (meters).
626        false_easting: f64,
627        /// False northing (meters).
628        false_northing: f64,
629    },
630
631    /// Lambert Conformal Conic (1SP or 2SP).
632    LambertConformalConic {
633        /// Central meridian (degrees).
634        lon0: f64,
635        /// Latitude of origin (degrees).
636        lat0: f64,
637        /// First standard parallel (degrees).
638        lat1: f64,
639        /// Second standard parallel (degrees). Set equal to lat1 for 1SP variant.
640        lat2: f64,
641        /// Scale factor at the natural origin (1SP variant); 1.0 for 2SP.
642        k0: f64,
643        /// False easting (meters).
644        false_easting: f64,
645        /// False northing (meters).
646        false_northing: f64,
647    },
648
649    /// Lambert Conic Conformal (2SP Michigan), EPSG method 1051: LCC 2SP
650    /// with an ellipsoid scaling factor applied to the semi-major axis.
651    LambertConformalConicMichigan {
652        /// Central meridian (degrees).
653        lon0: f64,
654        /// Latitude of false origin (degrees).
655        lat0: f64,
656        /// First standard parallel (degrees).
657        lat1: f64,
658        /// Second standard parallel (degrees).
659        lat2: f64,
660        /// Ellipsoid scaling factor (EPSG parameter 1038).
661        ellipsoid_scaling_factor: f64,
662        /// False easting (meters).
663        false_easting: f64,
664        /// False northing (meters).
665        false_northing: f64,
666    },
667
668    /// Lambert Conic Conformal (1SP variant B), EPSG method 1102: the cone
669    /// is defined at the natural origin with a scale factor, while false
670    /// easting/northing apply at a separate false origin.
671    LambertConformalConic1SPVariantB {
672        /// Longitude of false origin (degrees).
673        lon0: f64,
674        /// Latitude of natural origin (degrees).
675        lat0: f64,
676        /// Scale factor at the natural origin.
677        k0: f64,
678        /// Latitude of false origin (degrees).
679        lat_false_origin: f64,
680        /// False easting (meters).
681        false_easting: f64,
682        /// False northing (meters).
683        false_northing: f64,
684    },
685
686    /// Albers Equal Area Conic.
687    AlbersEqualArea {
688        /// Central meridian (degrees).
689        lon0: f64,
690        /// Latitude of origin (degrees).
691        lat0: f64,
692        /// First standard parallel (degrees).
693        lat1: f64,
694        /// Second standard parallel (degrees).
695        lat2: f64,
696        /// False easting (meters).
697        false_easting: f64,
698        /// False northing (meters).
699        false_northing: f64,
700    },
701
702    /// Lambert Azimuthal Equal Area.
703    LambertAzimuthalEqualArea {
704        /// Longitude of natural origin (degrees).
705        lon0: f64,
706        /// Latitude of natural origin (degrees).
707        lat0: f64,
708        /// False easting (meters).
709        false_easting: f64,
710        /// False northing (meters).
711        false_northing: f64,
712    },
713
714    /// Lambert Azimuthal Equal Area (spherical).
715    LambertAzimuthalEqualAreaSpherical {
716        /// Longitude of natural origin (degrees).
717        lon0: f64,
718        /// Latitude of natural origin (degrees).
719        lat0: f64,
720        /// False easting (meters).
721        false_easting: f64,
722        /// False northing (meters).
723        false_northing: f64,
724    },
725
726    /// EPSG Oblique Stereographic (Roussilhe / double stereographic).
727    ObliqueStereographic {
728        /// Longitude of natural origin (degrees).
729        lon0: f64,
730        /// Latitude of natural origin (degrees).
731        lat0: f64,
732        /// Scale factor at natural origin.
733        k0: f64,
734        /// False easting (meters).
735        false_easting: f64,
736        /// False northing (meters).
737        false_northing: f64,
738    },
739
740    /// Hotine Oblique Mercator / Rectified Skew Orthomorphic.
741    HotineObliqueMercator {
742        /// Latitude of projection centre (degrees).
743        latc: f64,
744        /// Longitude of projection centre (degrees).
745        lonc: f64,
746        /// Azimuth of central line at projection centre (degrees clockwise from north).
747        azimuth: f64,
748        /// Angle from rectified to skew grid (degrees).
749        rectified_grid_angle: f64,
750        /// Scale factor at projection centre.
751        k0: f64,
752        /// False easting or easting at projection centre (meters).
753        false_easting: f64,
754        /// False northing or northing at projection centre (meters).
755        false_northing: f64,
756        /// EPSG variant B offsets the natural origin to the projection centre.
757        variant_b: bool,
758    },
759
760    /// Cassini-Soldner.
761    CassiniSoldner {
762        /// Longitude of natural origin (degrees).
763        lon0: f64,
764        /// Latitude of natural origin (degrees).
765        lat0: f64,
766        /// False easting (meters).
767        false_easting: f64,
768        /// False northing (meters).
769        false_northing: f64,
770    },
771
772    /// Standard Mercator (ellipsoidal, distinct from Web Mercator).
773    Mercator {
774        /// Central meridian (degrees).
775        lon0: f64,
776        /// Latitude of true scale (degrees). 0 for 1SP variant.
777        lat_ts: f64,
778        /// Scale factor (for 1SP when lat_ts = 0).
779        k0: f64,
780        /// False easting (meters).
781        false_easting: f64,
782        /// False northing (meters).
783        false_northing: f64,
784    },
785
786    /// Equidistant Cylindrical / Plate Carrée.
787    EquidistantCylindrical {
788        /// Central meridian (degrees).
789        lon0: f64,
790        /// Latitude of true scale (degrees).
791        lat_ts: f64,
792        /// False easting (meters).
793        false_easting: f64,
794        /// False northing (meters).
795        false_northing: f64,
796    },
797    /// Colombia Urban (EPSG method 1052): plane projection at the elevation
798    /// of the mapped city. `h0` is the projection plane origin height in
799    /// meters.
800    ColombiaUrban {
801        lon0: f64,
802        lat0: f64,
803        h0: f64,
804        false_easting: f64,
805        false_northing: f64,
806    },
807
808    /// Krovak (North Orientated), EPSG method 1041: oblique conformal conic
809    /// with east/north axis orientation, so Czech and Slovak coordinates are
810    /// negative. Longitudes are referenced to the base CRS's prime meridian.
811    KrovakNorthOrientated {
812        /// Longitude of origin (degrees).
813        lon0: f64,
814        /// Latitude of projection centre (degrees).
815        lat0: f64,
816        /// Co-latitude of cone axis (degrees).
817        co_latitude_cone_axis: f64,
818        /// Latitude of pseudo standard parallel (degrees).
819        lat_pseudo_standard_parallel: f64,
820        /// Scale factor on pseudo standard parallel.
821        k0: f64,
822        /// False easting (meters), applied on the native westing axis.
823        false_easting: f64,
824        /// False northing (meters), applied on the native southing axis.
825        false_northing: f64,
826    },
827
828    /// Krovak Modified (North Orientated), EPSG method 1043: Krovak plus the
829    /// S-JTSK/05 polynomial distortion correction whose coefficients are
830    /// defining constants of the EPSG method.
831    KrovakModifiedNorthOrientated {
832        /// Longitude of origin (degrees).
833        lon0: f64,
834        /// Latitude of projection centre (degrees).
835        lat0: f64,
836        /// Co-latitude of cone axis (degrees).
837        co_latitude_cone_axis: f64,
838        /// Latitude of pseudo standard parallel (degrees).
839        lat_pseudo_standard_parallel: f64,
840        /// Scale factor on pseudo standard parallel.
841        k0: f64,
842        /// False easting (meters), applied on the native westing axis.
843        false_easting: f64,
844        /// False northing (meters), applied on the native southing axis.
845        false_northing: f64,
846    },
847
848    /// Equal Earth (EPSG method 1078): pseudocylindrical equal-area world
849    /// projection through the authalic latitude.
850    EqualEarth {
851        /// Longitude of natural origin (degrees).
852        lon0: f64,
853        /// False easting (meters).
854        false_easting: f64,
855        /// False northing (meters).
856        false_northing: f64,
857    },
858
859    /// American Polyconic (EPSG method 9818): each parallel projected as the
860    /// arc of a tangent cone.
861    AmericanPolyconic {
862        /// Longitude of natural origin (degrees).
863        lon0: f64,
864        /// Latitude of natural origin (degrees).
865        lat0: f64,
866        /// False easting (meters).
867        false_easting: f64,
868        /// False northing (meters).
869        false_northing: f64,
870    },
871
872    /// Azimuthal Equidistant (EPSG methods 1125 and 9832): true distance and
873    /// azimuth from the projection centre.
874    AzimuthalEquidistant {
875        /// Longitude of natural origin (degrees).
876        lon0: f64,
877        /// Latitude of natural origin (degrees).
878        lat0: f64,
879        /// False easting (meters).
880        false_easting: f64,
881        /// False northing (meters).
882        false_northing: f64,
883    },
884
885    /// Guam Projection (EPSG method 9831): the simplified azimuthal
886    /// equidistant used by the Guam and Yap island grids.
887    GuamProjection {
888        /// Longitude of natural origin (degrees).
889        lon0: f64,
890        /// Latitude of natural origin (degrees).
891        lat0: f64,
892        /// False easting (meters).
893        false_easting: f64,
894        /// False northing (meters).
895        false_northing: f64,
896    },
897
898    /// Polar Stereographic variant C (EPSG method 9830): defined by a
899    /// standard parallel, with the false origin on that parallel instead of
900    /// at the pole (Terre Adelie grids).
901    PolarStereographicVariantC {
902        /// Longitude of origin (degrees).
903        lon0: f64,
904        /// Latitude of standard parallel (degrees).
905        lat_ts: f64,
906        /// Easting at false origin (meters).
907        easting_false_origin: f64,
908        /// Northing at false origin (meters).
909        northing_false_origin: f64,
910    },
911
912    /// Laborde Oblique Mercator (EPSG method 9813): the Madagascar grid's
913    /// conformal-sphere oblique construction.
914    LabordeObliqueMercator {
915        /// Longitude of projection centre (degrees).
916        lon0: f64,
917        /// Latitude of projection centre (degrees).
918        lat0: f64,
919        /// Azimuth at projection centre (degrees).
920        azimuth: f64,
921        /// Scale factor at projection centre.
922        k0: f64,
923        /// False easting (meters).
924        false_easting: f64,
925        /// False northing (meters).
926        false_northing: f64,
927    },
928}
929
930impl ProjectionMethod {
931    /// Canonical numeric parameters for definitional equivalence: a fixed
932    /// slot array whose layout is private to this comparison (booleans fold
933    /// to 0/1, unused slots stay 0). The match is exhaustive on purpose so
934    /// adding a method without wiring equivalence is a compile error.
935    fn canonical_params(&self) -> [f64; 8] {
936        match *self {
937            ProjectionMethod::WebMercator => [0.0; 8],
938            ProjectionMethod::TransverseMercator {
939                lon0,
940                lat0,
941                k0,
942                false_easting,
943                false_northing,
944            } => [lon0, lat0, k0, false_easting, false_northing, 0.0, 0.0, 0.0],
945            ProjectionMethod::PolarStereographic {
946                lon0,
947                lat_ts,
948                k0,
949                false_easting,
950                false_northing,
951            } => [
952                lon0,
953                lat_ts,
954                k0,
955                false_easting,
956                false_northing,
957                0.0,
958                0.0,
959                0.0,
960            ],
961            ProjectionMethod::LambertConformalConic {
962                lon0,
963                lat0,
964                lat1,
965                lat2,
966                k0,
967                false_easting,
968                false_northing,
969            } => [
970                lon0,
971                lat0,
972                lat1,
973                lat2,
974                k0,
975                false_easting,
976                false_northing,
977                0.0,
978            ],
979            ProjectionMethod::LambertConformalConicMichigan {
980                lon0,
981                lat0,
982                lat1,
983                lat2,
984                ellipsoid_scaling_factor,
985                false_easting,
986                false_northing,
987            } => [
988                lon0,
989                lat0,
990                lat1,
991                lat2,
992                ellipsoid_scaling_factor,
993                false_easting,
994                false_northing,
995                0.0,
996            ],
997            ProjectionMethod::LambertConformalConic1SPVariantB {
998                lon0,
999                lat0,
1000                k0,
1001                lat_false_origin,
1002                false_easting,
1003                false_northing,
1004            } => [
1005                lon0,
1006                lat0,
1007                k0,
1008                lat_false_origin,
1009                false_easting,
1010                false_northing,
1011                0.0,
1012                0.0,
1013            ],
1014            ProjectionMethod::AlbersEqualArea {
1015                lon0,
1016                lat0,
1017                lat1,
1018                lat2,
1019                false_easting,
1020                false_northing,
1021            } => [
1022                lon0,
1023                lat0,
1024                lat1,
1025                lat2,
1026                false_easting,
1027                false_northing,
1028                0.0,
1029                0.0,
1030            ],
1031            ProjectionMethod::LambertAzimuthalEqualArea {
1032                lon0,
1033                lat0,
1034                false_easting,
1035                false_northing,
1036            }
1037            | ProjectionMethod::LambertAzimuthalEqualAreaSpherical {
1038                lon0,
1039                lat0,
1040                false_easting,
1041                false_northing,
1042            }
1043            | ProjectionMethod::CassiniSoldner {
1044                lon0,
1045                lat0,
1046                false_easting,
1047                false_northing,
1048            } => [
1049                lon0,
1050                lat0,
1051                false_easting,
1052                false_northing,
1053                0.0,
1054                0.0,
1055                0.0,
1056                0.0,
1057            ],
1058            ProjectionMethod::ObliqueStereographic {
1059                lon0,
1060                lat0,
1061                k0,
1062                false_easting,
1063                false_northing,
1064            } => [lon0, lat0, k0, false_easting, false_northing, 0.0, 0.0, 0.0],
1065            ProjectionMethod::HotineObliqueMercator {
1066                latc,
1067                lonc,
1068                azimuth,
1069                rectified_grid_angle,
1070                k0,
1071                false_easting,
1072                false_northing,
1073                variant_b,
1074            } => [
1075                latc,
1076                lonc,
1077                azimuth,
1078                rectified_grid_angle,
1079                k0,
1080                false_easting,
1081                false_northing,
1082                if variant_b { 1.0 } else { 0.0 },
1083            ],
1084            ProjectionMethod::Mercator {
1085                lon0,
1086                lat_ts,
1087                k0,
1088                false_easting,
1089                false_northing,
1090            } => [
1091                lon0,
1092                lat_ts,
1093                k0,
1094                false_easting,
1095                false_northing,
1096                0.0,
1097                0.0,
1098                0.0,
1099            ],
1100            ProjectionMethod::EquidistantCylindrical {
1101                lon0,
1102                lat_ts,
1103                false_easting,
1104                false_northing,
1105            } => [
1106                lon0,
1107                lat_ts,
1108                false_easting,
1109                false_northing,
1110                0.0,
1111                0.0,
1112                0.0,
1113                0.0,
1114            ],
1115            ProjectionMethod::ColombiaUrban {
1116                lon0,
1117                lat0,
1118                h0,
1119                false_easting,
1120                false_northing,
1121            } => [lon0, lat0, h0, false_easting, false_northing, 0.0, 0.0, 0.0],
1122            ProjectionMethod::KrovakNorthOrientated {
1123                lon0,
1124                lat0,
1125                co_latitude_cone_axis,
1126                lat_pseudo_standard_parallel,
1127                k0,
1128                false_easting,
1129                false_northing,
1130            }
1131            | ProjectionMethod::KrovakModifiedNorthOrientated {
1132                lon0,
1133                lat0,
1134                co_latitude_cone_axis,
1135                lat_pseudo_standard_parallel,
1136                k0,
1137                false_easting,
1138                false_northing,
1139            } => [
1140                lon0,
1141                lat0,
1142                co_latitude_cone_axis,
1143                lat_pseudo_standard_parallel,
1144                k0,
1145                false_easting,
1146                false_northing,
1147                0.0,
1148            ],
1149            ProjectionMethod::EqualEarth {
1150                lon0,
1151                false_easting,
1152                false_northing,
1153            } => [lon0, false_easting, false_northing, 0.0, 0.0, 0.0, 0.0, 0.0],
1154            ProjectionMethod::AmericanPolyconic {
1155                lon0,
1156                lat0,
1157                false_easting,
1158                false_northing,
1159            }
1160            | ProjectionMethod::AzimuthalEquidistant {
1161                lon0,
1162                lat0,
1163                false_easting,
1164                false_northing,
1165            }
1166            | ProjectionMethod::GuamProjection {
1167                lon0,
1168                lat0,
1169                false_easting,
1170                false_northing,
1171            } => [
1172                lon0,
1173                lat0,
1174                false_easting,
1175                false_northing,
1176                0.0,
1177                0.0,
1178                0.0,
1179                0.0,
1180            ],
1181            ProjectionMethod::PolarStereographicVariantC {
1182                lon0,
1183                lat_ts,
1184                easting_false_origin,
1185                northing_false_origin,
1186            } => [
1187                lon0,
1188                lat_ts,
1189                easting_false_origin,
1190                northing_false_origin,
1191                0.0,
1192                0.0,
1193                0.0,
1194                0.0,
1195            ],
1196            ProjectionMethod::LabordeObliqueMercator {
1197                lon0,
1198                lat0,
1199                azimuth,
1200                k0,
1201                false_easting,
1202                false_northing,
1203            } => [
1204                lon0,
1205                lat0,
1206                azimuth,
1207                k0,
1208                false_easting,
1209                false_northing,
1210                0.0,
1211                0.0,
1212            ],
1213        }
1214    }
1215}
1216
1217fn projection_methods_equivalent(a: &ProjectionMethod, b: &ProjectionMethod) -> bool {
1218    if std::mem::discriminant(a) != std::mem::discriminant(b) {
1219        return false;
1220    }
1221    let (a_params, b_params) = (a.canonical_params(), b.canonical_params());
1222    a_params
1223        .iter()
1224        .zip(b_params.iter())
1225        .all(|(x, y)| approx_eq(*x, *y))
1226}
1227
1228fn approx_eq(a: f64, b: f64) -> bool {
1229    (a - b).abs() < 1e-12
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234    use super::*;
1235    use crate::datum;
1236
1237    /// Regression: the old field-ladder comparison had no arms for methods
1238    /// added after it was written, so identical definitions compared as not
1239    /// equivalent. The canonical-params form covers every variant by
1240    /// construction.
1241    #[test]
1242    fn every_projection_method_is_self_equivalent() {
1243        let methods = [
1244            ProjectionMethod::ColombiaUrban {
1245                lon0: -74.15,
1246                lat0: 4.68,
1247                h0: 2550.0,
1248                false_easting: 92334.879,
1249                false_northing: 109320.965,
1250            },
1251            ProjectionMethod::LambertConformalConicMichigan {
1252                lon0: -84.33,
1253                lat0: 43.32,
1254                lat1: 44.18,
1255                lat2: 45.7,
1256                ellipsoid_scaling_factor: 1.0000382,
1257                false_easting: 609601.2192,
1258                false_northing: 0.0,
1259            },
1260            ProjectionMethod::LambertConformalConic1SPVariantB {
1261                lon0: 6.9,
1262                lat0: 45.15,
1263                k0: 1.0000398,
1264                lat_false_origin: 45.15,
1265                false_easting: 700000.0,
1266                false_northing: 300000.0,
1267            },
1268            ProjectionMethod::KrovakNorthOrientated {
1269                lon0: 24.833333333333332,
1270                lat0: 49.5,
1271                co_latitude_cone_axis: 30.288139752777778,
1272                lat_pseudo_standard_parallel: 78.5,
1273                k0: 0.9999,
1274                false_easting: 0.0,
1275                false_northing: 0.0,
1276            },
1277            ProjectionMethod::KrovakModifiedNorthOrientated {
1278                lon0: 24.833333333333332,
1279                lat0: 49.5,
1280                co_latitude_cone_axis: 30.288139752777778,
1281                lat_pseudo_standard_parallel: 78.5,
1282                k0: 0.9999,
1283                false_easting: 5_000_000.0,
1284                false_northing: 5_000_000.0,
1285            },
1286            ProjectionMethod::AmericanPolyconic {
1287                lon0: -54.0,
1288                lat0: 0.0,
1289                false_easting: 5_000_000.0,
1290                false_northing: 10_000_000.0,
1291            },
1292            ProjectionMethod::AzimuthalEquidistant {
1293                lon0: 21.5,
1294                lat0: 8.5,
1295                false_easting: 5_621_452.02,
1296                false_northing: 5_990_638.423,
1297            },
1298            ProjectionMethod::GuamProjection {
1299                lon0: 144.748_750_694_444_45,
1300                lat0: 13.472_466_333_333_33,
1301                false_easting: 50_000.0,
1302                false_northing: 50_000.0,
1303            },
1304            ProjectionMethod::PolarStereographicVariantC {
1305                lon0: 140.0,
1306                lat_ts: -67.0,
1307                easting_false_origin: 300_000.0,
1308                northing_false_origin: 200_000.0,
1309            },
1310            ProjectionMethod::LabordeObliqueMercator {
1311                lon0: 46.437_229_166_666_67,
1312                lat0: -18.9,
1313                azimuth: 18.9,
1314                k0: 0.9995,
1315                false_easting: 400_000.0,
1316                false_northing: 800_000.0,
1317            },
1318        ];
1319        for method in &methods {
1320            assert!(
1321                projection_methods_equivalent(method, method),
1322                "{method:?} must be self-equivalent"
1323            );
1324        }
1325        // Same numbers, different method: the discriminant must decide.
1326        let modified_with_same_numbers = ProjectionMethod::KrovakModifiedNorthOrientated {
1327            lon0: 24.833333333333332,
1328            lat0: 49.5,
1329            co_latitude_cone_axis: 30.288139752777778,
1330            lat_pseudo_standard_parallel: 78.5,
1331            k0: 0.9999,
1332            false_easting: 0.0,
1333            false_northing: 0.0,
1334        };
1335        assert!(!projection_methods_equivalent(
1336            &methods[3],
1337            &modified_with_same_numbers
1338        ));
1339    }
1340
1341    #[test]
1342    fn geographic_crs_is_geographic() {
1343        let crs = CrsDef::Geographic(GeographicCrsDef::new(4326, datum::WGS84, "WGS 84"));
1344        assert!(crs.is_geographic());
1345        assert!(!crs.is_projected());
1346        assert_eq!(crs.epsg(), 4326);
1347    }
1348
1349    #[test]
1350    fn projected_crs_is_projected() {
1351        let crs = CrsDef::Projected(ProjectedCrsDef::new(
1352            3857,
1353            datum::WGS84,
1354            ProjectionMethod::WebMercator,
1355            LinearUnit::metre(),
1356            "WGS 84 / Pseudo-Mercator",
1357        ));
1358        assert!(crs.is_projected());
1359        assert!(!crs.is_geographic());
1360        assert_eq!(crs.epsg(), 3857);
1361    }
1362
1363    #[test]
1364    fn compound_crs_exposes_horizontal_and_vertical_components() {
1365        let horizontal = GeographicCrsDef::new(4326, datum::WGS84, "WGS 84");
1366        let vertical = VerticalCrsDef::ellipsoidal_height(
1367            0,
1368            datum::WGS84,
1369            LinearUnit::metre(),
1370            "WGS 84 ellipsoidal height",
1371        );
1372        let crs = CrsDef::Compound(Box::new(CompoundCrsDef::new(
1373            4979,
1374            HorizontalCrsDef::Geographic(horizontal),
1375            vertical,
1376            "WGS 84",
1377        )));
1378
1379        assert!(crs.is_compound());
1380        assert!(crs.is_geographic());
1381        assert!(!crs.is_projected());
1382        assert_eq!(crs.epsg(), 4979);
1383        assert_eq!(crs.base_geographic_crs_epsg(), Some(4326));
1384        assert!(crs.vertical_crs().is_some());
1385    }
1386
1387    #[test]
1388    fn linear_unit_validates_positive_finite_conversion() {
1389        assert!(LinearUnit::from_meters_per_unit(0.3048).is_ok());
1390        assert!(LinearUnit::from_meters_per_unit(0.0).is_err());
1391        assert!(LinearUnit::from_meters_per_unit(f64::NAN).is_err());
1392    }
1393}