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