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        /// False easting (meters).
642        false_easting: f64,
643        /// False northing (meters).
644        false_northing: f64,
645    },
646
647    /// Albers Equal Area Conic.
648    AlbersEqualArea {
649        /// Central meridian (degrees).
650        lon0: f64,
651        /// Latitude of origin (degrees).
652        lat0: f64,
653        /// First standard parallel (degrees).
654        lat1: f64,
655        /// Second standard parallel (degrees).
656        lat2: f64,
657        /// False easting (meters).
658        false_easting: f64,
659        /// False northing (meters).
660        false_northing: f64,
661    },
662
663    /// Lambert Azimuthal Equal Area.
664    LambertAzimuthalEqualArea {
665        /// Longitude of natural origin (degrees).
666        lon0: f64,
667        /// Latitude of natural origin (degrees).
668        lat0: f64,
669        /// False easting (meters).
670        false_easting: f64,
671        /// False northing (meters).
672        false_northing: f64,
673    },
674
675    /// Lambert Azimuthal Equal Area (spherical).
676    LambertAzimuthalEqualAreaSpherical {
677        /// Longitude of natural origin (degrees).
678        lon0: f64,
679        /// Latitude of natural origin (degrees).
680        lat0: f64,
681        /// False easting (meters).
682        false_easting: f64,
683        /// False northing (meters).
684        false_northing: f64,
685    },
686
687    /// EPSG Oblique Stereographic (Roussilhe / double stereographic).
688    ObliqueStereographic {
689        /// Longitude of natural origin (degrees).
690        lon0: f64,
691        /// Latitude of natural origin (degrees).
692        lat0: f64,
693        /// Scale factor at natural origin.
694        k0: f64,
695        /// False easting (meters).
696        false_easting: f64,
697        /// False northing (meters).
698        false_northing: f64,
699    },
700
701    /// Hotine Oblique Mercator / Rectified Skew Orthomorphic.
702    HotineObliqueMercator {
703        /// Latitude of projection centre (degrees).
704        latc: f64,
705        /// Longitude of projection centre (degrees).
706        lonc: f64,
707        /// Azimuth of central line at projection centre (degrees clockwise from north).
708        azimuth: f64,
709        /// Angle from rectified to skew grid (degrees).
710        rectified_grid_angle: f64,
711        /// Scale factor at projection centre.
712        k0: f64,
713        /// False easting or easting at projection centre (meters).
714        false_easting: f64,
715        /// False northing or northing at projection centre (meters).
716        false_northing: f64,
717        /// EPSG variant B offsets the natural origin to the projection centre.
718        variant_b: bool,
719    },
720
721    /// Cassini-Soldner.
722    CassiniSoldner {
723        /// Longitude of natural origin (degrees).
724        lon0: f64,
725        /// Latitude of natural origin (degrees).
726        lat0: f64,
727        /// False easting (meters).
728        false_easting: f64,
729        /// False northing (meters).
730        false_northing: f64,
731    },
732
733    /// Standard Mercator (ellipsoidal, distinct from Web Mercator).
734    Mercator {
735        /// Central meridian (degrees).
736        lon0: f64,
737        /// Latitude of true scale (degrees). 0 for 1SP variant.
738        lat_ts: f64,
739        /// Scale factor (for 1SP when lat_ts = 0).
740        k0: f64,
741        /// False easting (meters).
742        false_easting: f64,
743        /// False northing (meters).
744        false_northing: f64,
745    },
746
747    /// Equidistant Cylindrical / Plate Carrée.
748    EquidistantCylindrical {
749        /// Central meridian (degrees).
750        lon0: f64,
751        /// Latitude of true scale (degrees).
752        lat_ts: f64,
753        /// False easting (meters).
754        false_easting: f64,
755        /// False northing (meters).
756        false_northing: f64,
757    },
758}
759
760fn projection_methods_equivalent(a: &ProjectionMethod, b: &ProjectionMethod) -> bool {
761    match (a, b) {
762        (ProjectionMethod::WebMercator, ProjectionMethod::WebMercator) => true,
763        (
764            ProjectionMethod::TransverseMercator {
765                lon0: a_lon0,
766                lat0: a_lat0,
767                k0: a_k0,
768                false_easting: a_false_easting,
769                false_northing: a_false_northing,
770            },
771            ProjectionMethod::TransverseMercator {
772                lon0: b_lon0,
773                lat0: b_lat0,
774                k0: b_k0,
775                false_easting: b_false_easting,
776                false_northing: b_false_northing,
777            },
778        ) => {
779            approx_eq(*a_lon0, *b_lon0)
780                && approx_eq(*a_lat0, *b_lat0)
781                && approx_eq(*a_k0, *b_k0)
782                && approx_eq(*a_false_easting, *b_false_easting)
783                && approx_eq(*a_false_northing, *b_false_northing)
784        }
785        (
786            ProjectionMethod::PolarStereographic {
787                lon0: a_lon0,
788                lat_ts: a_lat_ts,
789                k0: a_k0,
790                false_easting: a_false_easting,
791                false_northing: a_false_northing,
792            },
793            ProjectionMethod::PolarStereographic {
794                lon0: b_lon0,
795                lat_ts: b_lat_ts,
796                k0: b_k0,
797                false_easting: b_false_easting,
798                false_northing: b_false_northing,
799            },
800        ) => {
801            approx_eq(*a_lon0, *b_lon0)
802                && approx_eq(*a_lat_ts, *b_lat_ts)
803                && approx_eq(*a_k0, *b_k0)
804                && approx_eq(*a_false_easting, *b_false_easting)
805                && approx_eq(*a_false_northing, *b_false_northing)
806        }
807        (
808            ProjectionMethod::LambertConformalConic {
809                lon0: a_lon0,
810                lat0: a_lat0,
811                lat1: a_lat1,
812                lat2: a_lat2,
813                false_easting: a_false_easting,
814                false_northing: a_false_northing,
815            },
816            ProjectionMethod::LambertConformalConic {
817                lon0: b_lon0,
818                lat0: b_lat0,
819                lat1: b_lat1,
820                lat2: b_lat2,
821                false_easting: b_false_easting,
822                false_northing: b_false_northing,
823            },
824        ) => {
825            approx_eq(*a_lon0, *b_lon0)
826                && approx_eq(*a_lat0, *b_lat0)
827                && approx_eq(*a_lat1, *b_lat1)
828                && approx_eq(*a_lat2, *b_lat2)
829                && approx_eq(*a_false_easting, *b_false_easting)
830                && approx_eq(*a_false_northing, *b_false_northing)
831        }
832        (
833            ProjectionMethod::AlbersEqualArea {
834                lon0: a_lon0,
835                lat0: a_lat0,
836                lat1: a_lat1,
837                lat2: a_lat2,
838                false_easting: a_false_easting,
839                false_northing: a_false_northing,
840            },
841            ProjectionMethod::AlbersEqualArea {
842                lon0: b_lon0,
843                lat0: b_lat0,
844                lat1: b_lat1,
845                lat2: b_lat2,
846                false_easting: b_false_easting,
847                false_northing: b_false_northing,
848            },
849        ) => {
850            approx_eq(*a_lon0, *b_lon0)
851                && approx_eq(*a_lat0, *b_lat0)
852                && approx_eq(*a_lat1, *b_lat1)
853                && approx_eq(*a_lat2, *b_lat2)
854                && approx_eq(*a_false_easting, *b_false_easting)
855                && approx_eq(*a_false_northing, *b_false_northing)
856        }
857        (
858            ProjectionMethod::LambertAzimuthalEqualArea {
859                lon0: a_lon0,
860                lat0: a_lat0,
861                false_easting: a_false_easting,
862                false_northing: a_false_northing,
863            },
864            ProjectionMethod::LambertAzimuthalEqualArea {
865                lon0: b_lon0,
866                lat0: b_lat0,
867                false_easting: b_false_easting,
868                false_northing: b_false_northing,
869            },
870        ) => {
871            approx_eq(*a_lon0, *b_lon0)
872                && approx_eq(*a_lat0, *b_lat0)
873                && approx_eq(*a_false_easting, *b_false_easting)
874                && approx_eq(*a_false_northing, *b_false_northing)
875        }
876        (
877            ProjectionMethod::LambertAzimuthalEqualAreaSpherical {
878                lon0: a_lon0,
879                lat0: a_lat0,
880                false_easting: a_false_easting,
881                false_northing: a_false_northing,
882            },
883            ProjectionMethod::LambertAzimuthalEqualAreaSpherical {
884                lon0: b_lon0,
885                lat0: b_lat0,
886                false_easting: b_false_easting,
887                false_northing: b_false_northing,
888            },
889        ) => {
890            approx_eq(*a_lon0, *b_lon0)
891                && approx_eq(*a_lat0, *b_lat0)
892                && approx_eq(*a_false_easting, *b_false_easting)
893                && approx_eq(*a_false_northing, *b_false_northing)
894        }
895        (
896            ProjectionMethod::ObliqueStereographic {
897                lon0: a_lon0,
898                lat0: a_lat0,
899                k0: a_k0,
900                false_easting: a_false_easting,
901                false_northing: a_false_northing,
902            },
903            ProjectionMethod::ObliqueStereographic {
904                lon0: b_lon0,
905                lat0: b_lat0,
906                k0: b_k0,
907                false_easting: b_false_easting,
908                false_northing: b_false_northing,
909            },
910        ) => {
911            approx_eq(*a_lon0, *b_lon0)
912                && approx_eq(*a_lat0, *b_lat0)
913                && approx_eq(*a_k0, *b_k0)
914                && approx_eq(*a_false_easting, *b_false_easting)
915                && approx_eq(*a_false_northing, *b_false_northing)
916        }
917        (
918            ProjectionMethod::HotineObliqueMercator {
919                latc: a_latc,
920                lonc: a_lonc,
921                azimuth: a_azimuth,
922                rectified_grid_angle: a_rectified_grid_angle,
923                k0: a_k0,
924                false_easting: a_false_easting,
925                false_northing: a_false_northing,
926                variant_b: a_variant_b,
927            },
928            ProjectionMethod::HotineObliqueMercator {
929                latc: b_latc,
930                lonc: b_lonc,
931                azimuth: b_azimuth,
932                rectified_grid_angle: b_rectified_grid_angle,
933                k0: b_k0,
934                false_easting: b_false_easting,
935                false_northing: b_false_northing,
936                variant_b: b_variant_b,
937            },
938        ) => {
939            a_variant_b == b_variant_b
940                && approx_eq(*a_latc, *b_latc)
941                && approx_eq(*a_lonc, *b_lonc)
942                && approx_eq(*a_azimuth, *b_azimuth)
943                && approx_eq(*a_rectified_grid_angle, *b_rectified_grid_angle)
944                && approx_eq(*a_k0, *b_k0)
945                && approx_eq(*a_false_easting, *b_false_easting)
946                && approx_eq(*a_false_northing, *b_false_northing)
947        }
948        (
949            ProjectionMethod::CassiniSoldner {
950                lon0: a_lon0,
951                lat0: a_lat0,
952                false_easting: a_false_easting,
953                false_northing: a_false_northing,
954            },
955            ProjectionMethod::CassiniSoldner {
956                lon0: b_lon0,
957                lat0: b_lat0,
958                false_easting: b_false_easting,
959                false_northing: b_false_northing,
960            },
961        ) => {
962            approx_eq(*a_lon0, *b_lon0)
963                && approx_eq(*a_lat0, *b_lat0)
964                && approx_eq(*a_false_easting, *b_false_easting)
965                && approx_eq(*a_false_northing, *b_false_northing)
966        }
967        (
968            ProjectionMethod::Mercator {
969                lon0: a_lon0,
970                lat_ts: a_lat_ts,
971                k0: a_k0,
972                false_easting: a_false_easting,
973                false_northing: a_false_northing,
974            },
975            ProjectionMethod::Mercator {
976                lon0: b_lon0,
977                lat_ts: b_lat_ts,
978                k0: b_k0,
979                false_easting: b_false_easting,
980                false_northing: b_false_northing,
981            },
982        ) => {
983            approx_eq(*a_lon0, *b_lon0)
984                && approx_eq(*a_lat_ts, *b_lat_ts)
985                && approx_eq(*a_k0, *b_k0)
986                && approx_eq(*a_false_easting, *b_false_easting)
987                && approx_eq(*a_false_northing, *b_false_northing)
988        }
989        (
990            ProjectionMethod::EquidistantCylindrical {
991                lon0: a_lon0,
992                lat_ts: a_lat_ts,
993                false_easting: a_false_easting,
994                false_northing: a_false_northing,
995            },
996            ProjectionMethod::EquidistantCylindrical {
997                lon0: b_lon0,
998                lat_ts: b_lat_ts,
999                false_easting: b_false_easting,
1000                false_northing: b_false_northing,
1001            },
1002        ) => {
1003            approx_eq(*a_lon0, *b_lon0)
1004                && approx_eq(*a_lat_ts, *b_lat_ts)
1005                && approx_eq(*a_false_easting, *b_false_easting)
1006                && approx_eq(*a_false_northing, *b_false_northing)
1007        }
1008        _ => false,
1009    }
1010}
1011
1012fn approx_eq(a: f64, b: f64) -> bool {
1013    (a - b).abs() < 1e-12
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018    use super::*;
1019    use crate::datum;
1020
1021    #[test]
1022    fn geographic_crs_is_geographic() {
1023        let crs = CrsDef::Geographic(GeographicCrsDef::new(4326, datum::WGS84, "WGS 84"));
1024        assert!(crs.is_geographic());
1025        assert!(!crs.is_projected());
1026        assert_eq!(crs.epsg(), 4326);
1027    }
1028
1029    #[test]
1030    fn projected_crs_is_projected() {
1031        let crs = CrsDef::Projected(ProjectedCrsDef::new(
1032            3857,
1033            datum::WGS84,
1034            ProjectionMethod::WebMercator,
1035            LinearUnit::metre(),
1036            "WGS 84 / Pseudo-Mercator",
1037        ));
1038        assert!(crs.is_projected());
1039        assert!(!crs.is_geographic());
1040        assert_eq!(crs.epsg(), 3857);
1041    }
1042
1043    #[test]
1044    fn compound_crs_exposes_horizontal_and_vertical_components() {
1045        let horizontal = GeographicCrsDef::new(4326, datum::WGS84, "WGS 84");
1046        let vertical = VerticalCrsDef::ellipsoidal_height(
1047            0,
1048            datum::WGS84,
1049            LinearUnit::metre(),
1050            "WGS 84 ellipsoidal height",
1051        );
1052        let crs = CrsDef::Compound(Box::new(CompoundCrsDef::new(
1053            4979,
1054            HorizontalCrsDef::Geographic(horizontal),
1055            vertical,
1056            "WGS 84",
1057        )));
1058
1059        assert!(crs.is_compound());
1060        assert!(crs.is_geographic());
1061        assert!(!crs.is_projected());
1062        assert_eq!(crs.epsg(), 4979);
1063        assert_eq!(crs.base_geographic_crs_epsg(), Some(4326));
1064        assert!(crs.vertical_crs().is_some());
1065    }
1066
1067    #[test]
1068    fn linear_unit_validates_positive_finite_conversion() {
1069        assert!(LinearUnit::from_meters_per_unit(0.3048).is_ok());
1070        assert!(LinearUnit::from_meters_per_unit(0.0).is_err());
1071        assert!(LinearUnit::from_meters_per_unit(f64::NAN).is_err());
1072    }
1073}