Skip to main content

geotiff_core/
crs.rs

1//! Coordinate Reference System extraction from GeoKeys.
2
3use crate::geokeys::{self, GeoKeyDirectory, GeoKeyValue};
4
5/// GeoTIFF model type.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ModelType {
8    Projected,
9    Geographic,
10    Geocentric,
11    Unknown(u16),
12}
13
14impl ModelType {
15    pub fn from_code(code: u16) -> Self {
16        match code {
17            1 => Self::Projected,
18            2 => Self::Geographic,
19            3 => Self::Geocentric,
20            other => Self::Unknown(other),
21        }
22    }
23
24    pub fn code(&self) -> u16 {
25        match self {
26            Self::Projected => 1,
27            Self::Geographic => 2,
28            Self::Geocentric => 3,
29            Self::Unknown(v) => *v,
30        }
31    }
32}
33
34/// GeoTIFF raster-space interpretation.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum RasterType {
37    PixelIsArea,
38    PixelIsPoint,
39    Unknown(u16),
40}
41
42impl RasterType {
43    pub fn from_code(code: u16) -> Self {
44        match code {
45            1 => Self::PixelIsArea,
46            2 => Self::PixelIsPoint,
47            other => Self::Unknown(other),
48        }
49    }
50
51    pub fn code(&self) -> u16 {
52        match self {
53            Self::PixelIsArea => 1,
54            Self::PixelIsPoint => 2,
55            Self::Unknown(v) => *v,
56        }
57    }
58}
59
60/// Horizontal CRS component extracted from GeoKeys.
61#[derive(Debug, Clone, PartialEq, Eq, Default)]
62pub struct HorizontalCrs {
63    /// EPSG code for a projected CRS (ProjectedCRSGeoKey / 3072).
64    pub projected_epsg: Option<u16>,
65    /// EPSG code for a geodetic CRS (GeodeticCRSGeoKey / 2048).
66    pub geodetic_epsg: Option<u16>,
67    /// Citation string for the projected CRS.
68    pub projection_citation: Option<String>,
69    /// Citation string for the geodetic CRS.
70    pub geodetic_citation: Option<String>,
71}
72
73/// Vertical CRS component extracted from GeoKeys.
74#[derive(Debug, Clone, PartialEq, Eq, Default)]
75pub struct VerticalCrs {
76    /// EPSG code for a vertical CRS (VerticalCSTypeGeoKey / 4096).
77    pub epsg: Option<u16>,
78    /// Vertical datum code (VerticalDatumGeoKey / 4098).
79    pub datum: Option<u16>,
80    /// Vertical units code (VerticalUnitsGeoKey / 4099).
81    pub units: Option<u16>,
82    /// Citation string for the vertical CRS.
83    pub citation: Option<String>,
84}
85
86/// Structured CRS interpretation synthesized from GeoKeys.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum CrsKind {
89    /// No usable CRS keys were present.
90    Unspecified,
91    /// A horizontal-only CRS, parameterized by GeoTIFF model type.
92    Horizontal {
93        model_type: ModelType,
94        horizontal: HorizontalCrs,
95    },
96    /// A vertical-only CRS.
97    Vertical(VerticalCrs),
98    /// A compound CRS composed of horizontal and vertical components.
99    Compound {
100        model_type: ModelType,
101        horizontal: HorizontalCrs,
102        vertical: VerticalCrs,
103    },
104}
105
106/// Extracted CRS information from GeoKeys.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct CrsInfo {
109    /// Model type: 1 = Projected, 2 = Geographic, 3 = Geocentric.
110    pub model_type: u16,
111    /// Raster type: 1 = PixelIsArea, 2 = PixelIsPoint.
112    pub raster_type: u16,
113    /// Horizontal CRS component derived from projected/geodetic keys.
114    pub horizontal: Option<HorizontalCrs>,
115    /// Vertical CRS component derived from vertical GeoKeys.
116    pub vertical: Option<VerticalCrs>,
117}
118
119impl CrsInfo {
120    /// Extract CRS information from a GeoKey directory.
121    pub fn from_geokeys(geokeys: &GeoKeyDirectory) -> Self {
122        let model_type = geokeys.get_short(geokeys::GT_MODEL_TYPE).unwrap_or(0);
123        let horizontal = HorizontalCrs {
124            projected_epsg: geokeys.get_short(geokeys::PROJECTED_CRS_TYPE),
125            geodetic_epsg: geokeys.get_short(geokeys::GEODETIC_CRS_TYPE),
126            projection_citation: geokeys.get_ascii(geokeys::PROJ_CITATION).map(String::from),
127            geodetic_citation: geokeys
128                .get_ascii(geokeys::GEODETIC_CITATION)
129                .map(String::from),
130        };
131        let vertical = VerticalCrs {
132            epsg: geokeys.get_short(geokeys::VERTICAL_CS_TYPE),
133            datum: geokeys.get_short(geokeys::VERTICAL_DATUM),
134            units: geokeys.get_short(geokeys::VERTICAL_UNITS),
135            citation: geokeys
136                .get_ascii(geokeys::VERTICAL_CITATION)
137                .map(String::from),
138        };
139
140        Self {
141            model_type,
142            raster_type: geokeys.get_short(geokeys::GT_RASTER_TYPE).unwrap_or(1),
143            horizontal: horizontal.is_present(model_type).then_some(horizontal),
144            vertical: vertical.is_present().then_some(vertical),
145        }
146    }
147
148    /// Returns the most specific EPSG code available.
149    pub fn epsg(&self) -> Option<u32> {
150        self.primary_horizontal_epsg()
151            .or(self.vertical_epsg())
152            .map(|epsg| epsg as u32)
153    }
154
155    /// Returns the GeoTIFF raster-space interpretation.
156    pub fn raster_type_enum(&self) -> RasterType {
157        RasterType::from_code(self.raster_type)
158    }
159
160    /// Returns the GeoTIFF model type.
161    pub fn model_type_enum(&self) -> ModelType {
162        ModelType::from_code(self.model_type)
163    }
164
165    /// Returns the structured horizontal/vertical CRS interpretation.
166    pub fn crs_kind(&self) -> CrsKind {
167        match (&self.horizontal, &self.vertical) {
168            (Some(horizontal), Some(vertical)) => CrsKind::Compound {
169                model_type: self.model_type_enum(),
170                horizontal: horizontal.clone(),
171                vertical: vertical.clone(),
172            },
173            (Some(horizontal), None) => CrsKind::Horizontal {
174                model_type: self.model_type_enum(),
175                horizontal: horizontal.clone(),
176            },
177            (None, Some(vertical)) => CrsKind::Vertical(vertical.clone()),
178            (None, None) => CrsKind::Unspecified,
179        }
180    }
181
182    /// Returns the horizontal CRS component, if present.
183    pub fn horizontal(&self) -> Option<&HorizontalCrs> {
184        self.horizontal.as_ref()
185    }
186
187    /// Returns the vertical CRS component, if present.
188    pub fn vertical(&self) -> Option<&VerticalCrs> {
189        self.vertical.as_ref()
190    }
191
192    /// Returns the projected CRS EPSG code, if present.
193    pub fn projected_epsg(&self) -> Option<u16> {
194        self.horizontal
195            .as_ref()
196            .and_then(|horizontal| horizontal.projected_epsg)
197    }
198
199    /// Returns the geodetic CRS EPSG code from GeoKey 2048, if present.
200    pub fn geodetic_epsg(&self) -> Option<u16> {
201        self.horizontal
202            .as_ref()
203            .and_then(|horizontal| horizontal.geodetic_epsg)
204    }
205
206    /// Returns the geographic CRS EPSG code when the model type is geographic.
207    pub fn geographic_epsg(&self) -> Option<u16> {
208        matches!(self.model_type_enum(), ModelType::Geographic)
209            .then(|| self.geodetic_epsg())
210            .flatten()
211    }
212
213    /// Returns the geocentric CRS EPSG code when the model type is geocentric.
214    pub fn geocentric_epsg(&self) -> Option<u16> {
215        matches!(self.model_type_enum(), ModelType::Geocentric)
216            .then(|| self.geodetic_epsg())
217            .flatten()
218    }
219
220    /// Returns the vertical CRS EPSG code, if present.
221    pub fn vertical_epsg(&self) -> Option<u16> {
222        self.vertical.as_ref().and_then(|vertical| vertical.epsg)
223    }
224
225    /// Returns the projected CRS citation, if present.
226    pub fn projection_citation(&self) -> Option<&str> {
227        self.horizontal
228            .as_ref()
229            .and_then(|horizontal| horizontal.projection_citation.as_deref())
230    }
231
232    /// Returns the geodetic CRS citation, if present.
233    pub fn geodetic_citation(&self) -> Option<&str> {
234        self.horizontal
235            .as_ref()
236            .and_then(|horizontal| horizontal.geodetic_citation.as_deref())
237    }
238
239    /// Returns the vertical CRS citation, if present.
240    pub fn vertical_citation(&self) -> Option<&str> {
241        self.vertical
242            .as_ref()
243            .and_then(|vertical| vertical.citation.as_deref())
244    }
245
246    /// Returns the vertical datum code, if present.
247    pub fn vertical_datum(&self) -> Option<u16> {
248        self.vertical.as_ref().and_then(|vertical| vertical.datum)
249    }
250
251    /// Returns the vertical units code, if present.
252    pub fn vertical_units(&self) -> Option<u16> {
253        self.vertical.as_ref().and_then(|vertical| vertical.units)
254    }
255
256    /// Populate a GeoKeyDirectory from this CRS info.
257    pub fn apply_to_geokeys(&self, geokeys: &mut GeoKeyDirectory) {
258        set_optional_short(
259            geokeys,
260            geokeys::GT_MODEL_TYPE,
261            (self.model_type != 0).then_some(self.model_type),
262        );
263        set_optional_short(
264            geokeys,
265            geokeys::GT_RASTER_TYPE,
266            (self.raster_type != 0).then_some(self.raster_type),
267        );
268
269        if let Some(horizontal) = &self.horizontal {
270            set_optional_short(
271                geokeys,
272                geokeys::PROJECTED_CRS_TYPE,
273                horizontal.projected_epsg,
274            );
275            set_optional_short(
276                geokeys,
277                geokeys::GEODETIC_CRS_TYPE,
278                horizontal.geodetic_epsg,
279            );
280            set_optional_ascii(
281                geokeys,
282                geokeys::PROJ_CITATION,
283                horizontal.projection_citation.as_deref(),
284            );
285            set_optional_ascii(
286                geokeys,
287                geokeys::GEODETIC_CITATION,
288                horizontal.geodetic_citation.as_deref(),
289            );
290        } else {
291            clear_horizontal_geokeys(geokeys);
292        }
293
294        if let Some(vertical) = &self.vertical {
295            set_optional_short(geokeys, geokeys::VERTICAL_CS_TYPE, vertical.epsg);
296            set_optional_short(geokeys, geokeys::VERTICAL_DATUM, vertical.datum);
297            set_optional_short(geokeys, geokeys::VERTICAL_UNITS, vertical.units);
298            set_optional_ascii(
299                geokeys,
300                geokeys::VERTICAL_CITATION,
301                vertical.citation.as_deref(),
302            );
303        } else {
304            clear_vertical_geokeys(geokeys);
305        }
306    }
307
308    fn primary_horizontal_epsg(&self) -> Option<u16> {
309        let horizontal = self.horizontal.as_ref()?;
310        match self.model_type_enum() {
311            ModelType::Projected => horizontal.projected_epsg.or(horizontal.geodetic_epsg),
312            ModelType::Geographic | ModelType::Geocentric | ModelType::Unknown(_) => {
313                horizontal.geodetic_epsg.or(horizontal.projected_epsg)
314            }
315        }
316    }
317}
318
319impl HorizontalCrs {
320    fn is_present(&self, model_type: u16) -> bool {
321        model_type != 0
322            || self.projected_epsg.is_some()
323            || self.geodetic_epsg.is_some()
324            || self.projection_citation.is_some()
325            || self.geodetic_citation.is_some()
326    }
327}
328
329impl VerticalCrs {
330    fn is_present(&self) -> bool {
331        self.epsg.is_some()
332            || self.datum.is_some()
333            || self.units.is_some()
334            || self.citation.is_some()
335    }
336}
337
338fn set_optional_short(geokeys: &mut GeoKeyDirectory, id: u16, value: Option<u16>) {
339    if let Some(value) = value {
340        geokeys.set(id, GeoKeyValue::Short(value));
341    } else {
342        geokeys.remove(id);
343    }
344}
345
346fn set_optional_ascii(geokeys: &mut GeoKeyDirectory, id: u16, value: Option<&str>) {
347    if let Some(value) = value {
348        geokeys.set(id, GeoKeyValue::Ascii(value.to_string()));
349    } else {
350        geokeys.remove(id);
351    }
352}
353
354fn clear_horizontal_geokeys(geokeys: &mut GeoKeyDirectory) {
355    geokeys.remove(geokeys::PROJECTED_CRS_TYPE);
356    geokeys.remove(geokeys::GEODETIC_CRS_TYPE);
357    geokeys.remove(geokeys::PROJ_CITATION);
358    geokeys.remove(geokeys::GEODETIC_CITATION);
359}
360
361fn clear_vertical_geokeys(geokeys: &mut GeoKeyDirectory) {
362    geokeys.remove(geokeys::VERTICAL_CS_TYPE);
363    geokeys.remove(geokeys::VERTICAL_DATUM);
364    geokeys.remove(geokeys::VERTICAL_UNITS);
365    geokeys.remove(geokeys::VERTICAL_CITATION);
366}
367
368#[cfg(test)]
369mod tests {
370    use super::{CrsInfo, CrsKind, GeoKeyDirectory, GeoKeyValue, ModelType, RasterType};
371    use crate::geokeys;
372
373    #[test]
374    fn parses_geocentric_horizontal_crs() {
375        let mut geokeys = GeoKeyDirectory::new();
376        geokeys.set(
377            geokeys::GT_MODEL_TYPE,
378            GeoKeyValue::Short(ModelType::Geocentric.code()),
379        );
380        geokeys.set(
381            geokeys::GT_RASTER_TYPE,
382            GeoKeyValue::Short(RasterType::PixelIsArea.code()),
383        );
384        geokeys.set(geokeys::GEODETIC_CRS_TYPE, GeoKeyValue::Short(4978));
385
386        let crs = CrsInfo::from_geokeys(&geokeys);
387        assert_eq!(crs.geocentric_epsg(), Some(4978));
388        assert_eq!(crs.geographic_epsg(), None);
389        assert!(matches!(
390            crs.crs_kind(),
391            CrsKind::Horizontal {
392                model_type: ModelType::Geocentric,
393                ..
394            }
395        ));
396    }
397
398    #[test]
399    fn parses_compound_projected_vertical_crs() {
400        let mut geokeys = GeoKeyDirectory::new();
401        geokeys.set(
402            geokeys::GT_MODEL_TYPE,
403            GeoKeyValue::Short(ModelType::Projected.code()),
404        );
405        geokeys.set(geokeys::PROJECTED_CRS_TYPE, GeoKeyValue::Short(32616));
406        geokeys.set(geokeys::VERTICAL_CS_TYPE, GeoKeyValue::Short(5703));
407        geokeys.set(
408            geokeys::VERTICAL_CITATION,
409            GeoKeyValue::Ascii("NAVD88 height".into()),
410        );
411        geokeys.set(geokeys::VERTICAL_UNITS, GeoKeyValue::Short(9001));
412
413        let crs = CrsInfo::from_geokeys(&geokeys);
414        assert_eq!(crs.projected_epsg(), Some(32616));
415        assert_eq!(crs.vertical_epsg(), Some(5703));
416        assert_eq!(crs.vertical_units(), Some(9001));
417        assert_eq!(crs.vertical_citation(), Some("NAVD88 height"));
418        assert!(matches!(
419            crs.crs_kind(),
420            CrsKind::Compound {
421                model_type: ModelType::Projected,
422                ..
423            }
424        ));
425    }
426
427    #[test]
428    fn apply_to_geokeys_roundtrips_vertical_and_horizontal_components() {
429        let original = CrsInfo {
430            model_type: ModelType::Projected.code(),
431            raster_type: RasterType::PixelIsPoint.code(),
432            horizontal: Some(super::HorizontalCrs {
433                projected_epsg: Some(26916),
434                geodetic_epsg: Some(4269),
435                projection_citation: Some("NAD83 / UTM zone 16N".into()),
436                geodetic_citation: Some("NAD83".into()),
437            }),
438            vertical: Some(super::VerticalCrs {
439                epsg: Some(5703),
440                datum: Some(5103),
441                units: Some(9001),
442                citation: Some("NAVD88 height".into()),
443            }),
444        };
445
446        let mut geokeys = GeoKeyDirectory::new();
447        original.apply_to_geokeys(&mut geokeys);
448        let roundtrip = CrsInfo::from_geokeys(&geokeys);
449
450        assert_eq!(roundtrip, original);
451        assert_eq!(roundtrip.epsg(), Some(26916));
452    }
453}