Skip to main content

geonative_core/
crs.rs

1//! Coordinate reference system. Carried through verbatim from the source so
2//! each writer can serialize it in its own preferred form.
3
4/// Coordinate reference system. Marked `#[non_exhaustive]` because future
5/// versions may add `Wkt2(String)`, a structured `Authority { name, code }`,
6/// or pre-parsed PROJJSON without that counting as a SemVer-breaking change.
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum Crs {
10    /// CRS unknown or unspecified.
11    #[default]
12    Unknown,
13    /// An EPSG authority code (e.g. 4326, 7844).
14    Epsg(u32),
15    /// Well-Known Text (ESRI-WKT or OGC-WKT). Stored verbatim.
16    Wkt(String),
17    /// PROJJSON. Stored verbatim (GeoParquet's preferred form).
18    Projjson(String),
19}
20
21impl Crs {
22    pub fn is_unknown(&self) -> bool {
23        matches!(self, Crs::Unknown)
24    }
25
26    /// Return the CRS as an EPSG authority code, if it can be cheaply
27    /// determined. Resolution order:
28    /// 1. [`Crs::Epsg`] returns directly.
29    /// 2. [`Crs::Wkt`] looks for `AUTHORITY["EPSG","NNNN"]` (WKT1) or
30    ///    `ID["EPSG",NNNN]` (WKT2) on the outermost CRS node.
31    /// 3. If no authority is present, fall back to a small inline lookup of
32    ///    common datum/CRS names ("GDA2020", "WGS 84", "NAD83", "Web
33    ///    Mercator", etc.) — handles the ESRI File-Geodatabase case where
34    ///    the WKT has just a name and a datum, no AUTHORITY.
35    ///
36    /// Returns `None` for [`Crs::Unknown`], [`Crs::Projjson`], or WKT we
37    /// can't resolve. Full WKT → EPSG resolution (every CRS) requires PROJ
38    /// and is the job of the future `geonative-proj` crate.
39    pub fn epsg_code(&self) -> Option<u32> {
40        match self {
41            Crs::Epsg(n) => Some(*n),
42            Crs::Wkt(s) => extract_trailing_epsg(s).or_else(|| epsg_from_wkt_name(s)),
43            Crs::Unknown | Crs::Projjson(_) => None,
44        }
45    }
46
47    /// Render the CRS as PROJJSON — the form GeoParquet stores in its `geo`
48    /// metadata. v0.1 produces a minimal PROJJSON that just references an
49    /// EPSG code when one is detectable:
50    ///
51    /// ```json
52    /// { "$schema": "...", "type": "GeographicCRS", "id": { "authority": "EPSG", "code": 4326 } }
53    /// ```
54    ///
55    /// Returns `None` if no EPSG code is detectable. Full WKT → PROJJSON
56    /// conversion (preserving every parameter) requires PROJ and is deferred
57    /// to the optional `geonative-proj` crate; until then, the GeoParquet
58    /// spec also accepts WKT in the `crs` field as a string fallback.
59    pub fn to_projjson(&self) -> Option<String> {
60        let code = self.epsg_code()?;
61        Some(format!(
62            r#"{{"$schema":"https://proj.org/schemas/v0.7/projjson.schema.json","id":{{"authority":"EPSG","code":{code}}}}}"#
63        ))
64    }
65}
66
67/// Find an `AUTHORITY["EPSG","NNNN"]` (or `ID["EPSG",NNNN]` in WKT2) clause
68/// that terminates the WKT, returning the numeric code. Scans from the right
69/// to prefer the outermost authority over any inner ones (e.g. an inner
70/// datum's authority).
71fn extract_trailing_epsg(wkt: &str) -> Option<u32> {
72    // Look for both forms; AUTHORITY is WKT1 (most ESRI .prj sidecars), ID is WKT2.
73    let candidates = [
74        find_clause_value(wkt, "AUTHORITY[\"EPSG\",\""),
75        find_clause_value(wkt, "ID[\"EPSG\","),
76    ];
77    candidates.into_iter().flatten().last()
78}
79
80fn find_clause_value(wkt: &str, opener: &str) -> Option<u32> {
81    // Find the LAST occurrence of `opener`, then read digits up to the next
82    // `"` or `]`. Iterating from the right ensures we match the outer authority.
83    let pos = wkt.rfind(opener)?;
84    let rest = &wkt[pos + opener.len()..];
85    let end = rest.find(['"', ']', ',', ' ']).unwrap_or(rest.len());
86    rest[..end].parse::<u32>().ok()
87}
88
89/// Best-effort EPSG lookup by recognising the outermost CRS name in a WKT
90/// that has no AUTHORITY clause (typical of ESRI File-Geodatabase WKTs).
91///
92/// Extracts the first quoted string after `GEOGCS[`, `GEOGCRS[`, `PROJCS[`,
93/// or `PROJCRS[` and matches it against a small hardcoded table covering the
94/// CRSes most commonly seen in Australian and global data.
95fn epsg_from_wkt_name(wkt: &str) -> Option<u32> {
96    let name = extract_outer_crs_name(wkt)?;
97    epsg_for_common_name(&name)
98}
99
100fn extract_outer_crs_name(wkt: &str) -> Option<String> {
101    // Find the earliest occurrence of any CRS opener (the outer one comes first).
102    let openers = ["PROJCS[\"", "PROJCRS[\"", "GEOGCS[\"", "GEOGCRS[\""];
103    let opener_pos = openers
104        .iter()
105        .filter_map(|op| wkt.find(op).map(|p| (p + op.len(), op)))
106        .min_by_key(|(p, _)| *p)?;
107    let start = opener_pos.0;
108    let rest = &wkt[start..];
109    let end = rest.find('"')?;
110    Some(rest[..end].to_string())
111}
112
113fn epsg_for_common_name(name: &str) -> Option<u32> {
114    // Match-on-trimmed: some ESRI WKTs use underscores instead of spaces.
115    let normalized = name.trim().replace('_', " ").to_ascii_uppercase();
116    Some(match normalized.as_str() {
117        // GDA2020 (Australia)
118        "GDA2020" | "GCS GDA 2020" | "GDA 2020" => 7844,
119        // GDA94 (Australia, older)
120        "GDA94" | "GCS GDA 1994" | "GDA 1994" => 4283,
121        // WGS 84
122        "WGS 84" | "WGS84" | "WGS 1984" | "GCS WGS 1984" => 4326,
123        // NAD83 (North America)
124        "NAD83" | "NAD 83" | "GCS NORTH AMERICAN 1983" => 4269,
125        // NAD27
126        "NAD27" | "NAD 27" | "GCS NORTH AMERICAN 1927" => 4267,
127        // Web Mercator (the projection web maps use)
128        "WGS 84 / PSEUDO-MERCATOR" | "WGS 1984 WEB MERCATOR AUXILIARY SPHERE" | "WEB MERCATOR" => {
129            3857
130        }
131        // British National Grid
132        "OSGB 1936 / BRITISH NATIONAL GRID"
133        | "BRITISH NATIONAL GRID"
134        | "OSGB36 / BRITISH NATIONAL GRID" => 27700,
135        _ => return None,
136    })
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn epsg_code_from_epsg_variant() {
145        assert_eq!(Crs::Epsg(4326).epsg_code(), Some(4326));
146        assert_eq!(Crs::Epsg(7844).epsg_code(), Some(7844));
147    }
148
149    #[test]
150    fn epsg_code_from_wkt_authority_clause() {
151        let wkt = r#"GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4326"]]"#;
152        assert_eq!(Crs::Wkt(wkt.into()).epsg_code(), Some(4326));
153    }
154
155    #[test]
156    fn epsg_code_from_wkt2_id_clause() {
157        let wkt = r#"GEOGCRS["GDA2020",DATUM["GDA2020"],PRIMEM["Greenwich",0],ID["EPSG",7844]]"#;
158        assert_eq!(Crs::Wkt(wkt.into()).epsg_code(), Some(7844));
159    }
160
161    #[test]
162    fn epsg_code_none_for_unknown_or_projjson() {
163        assert_eq!(Crs::Unknown.epsg_code(), None);
164        assert_eq!(Crs::Projjson("{}".into()).epsg_code(), None);
165        // WKT without AUTHORITY clause and an unrecognized name
166        assert_eq!(Crs::Wkt("LOCAL_CS[\"custom\"]".into()).epsg_code(), None);
167    }
168
169    #[test]
170    fn epsg_code_from_wkt_name_when_no_authority() {
171        // The exact WKT we get from VicMap's FileGDB — no AUTHORITY, just the name.
172        let wkt = r#"GEOGCS["GDA2020",DATUM["GDA2020",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]"#;
173        assert_eq!(Crs::Wkt(wkt.into()).epsg_code(), Some(7844));
174
175        // WGS84 variant ESRI sometimes emits with underscores.
176        let wkt = r#"GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]"#;
177        assert_eq!(Crs::Wkt(wkt.into()).epsg_code(), Some(4326));
178
179        // GDA94
180        let wkt = r#"GEOGCS["GCS_GDA_1994",DATUM["D_GDA_1994",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]"#;
181        assert_eq!(Crs::Wkt(wkt.into()).epsg_code(), Some(4283));
182    }
183
184    #[test]
185    fn authority_takes_precedence_over_name_lookup() {
186        // If both are present, the trailing AUTHORITY wins (matches GDAL semantics).
187        let wkt = r#"GEOGCS["GDA2020",DATUM["GDA2020"],AUTHORITY["EPSG","9999"]]"#;
188        assert_eq!(Crs::Wkt(wkt.into()).epsg_code(), Some(9999));
189    }
190
191    #[test]
192    fn projjson_minimal_form() {
193        let s = Crs::Epsg(4326).to_projjson().unwrap();
194        assert!(s.contains("\"authority\":\"EPSG\""));
195        assert!(s.contains("\"code\":4326"));
196        assert!(s.contains("$schema"));
197    }
198}