Skip to main content

oxiproj_engine/
distortion.rs

1//! Distortion-field rasters: grids of Tissot indicatrix values over a geographic extent.
2
3use crate::pj::Pj;
4use oxiproj_core::{Coord, IoUnits, ProjError, TissotParams};
5
6/// Whether Tissot values came from exact AD or numeric finite-difference.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum TissotMethod {
10    Exact,
11    Numeric,
12    Failed,
13}
14
15impl TissotMethod {
16    /// The canonical display name of this method (`"Exact"`, `"Numeric"`,
17    /// `"Failed"`).
18    ///
19    /// This is the exhaustive choke point for the method's string form so
20    /// downstream crates (which see [`TissotMethod`] as `#[non_exhaustive]`)
21    /// need not match every variant themselves.
22    pub fn name(&self) -> &'static str {
23        match self {
24            TissotMethod::Exact => "Exact",
25            TissotMethod::Numeric => "Numeric",
26            TissotMethod::Failed => "Failed",
27        }
28    }
29}
30
31/// One cell in the distortion raster.
32#[derive(Debug, Clone)]
33pub struct TissotCell {
34    pub lon: f64,
35    pub lat: f64,
36    pub semi_major: f64,
37    pub semi_minor: f64,
38    pub angular_distortion: f64,
39    pub areal_scale: f64,
40    pub meridian_scale: f64,
41    pub parallel_scale: f64,
42    pub meridian_convergence: f64,
43    pub is_conformal: bool,
44    pub is_equal_area: bool,
45    pub method: TissotMethod,
46}
47
48/// A distortion-field raster: Tissot parameters on a regular lon/lat grid.
49pub struct DistortionRaster {
50    pub lon_min: f64,
51    pub lon_max: f64,
52    pub lat_min: f64,
53    pub lat_max: f64,
54    pub ncols: usize,
55    pub nrows: usize,
56    /// Flattened row-major grid of TissotCell (len = nrows * ncols).
57    pub cells: Vec<TissotCell>,
58}
59
60impl DistortionRaster {
61    /// Compute a distortion raster for the given projection over the given extent.
62    ///
63    /// All lon/lat values are in degrees. The raster has:
64    /// - `ncols = ((lon_max - lon_min) / step_deg).ceil() as usize + 1`
65    /// - `nrows = ((lat_max - lat_min) / step_deg).ceil() as usize + 1`
66    pub fn compute(
67        pj: &Pj,
68        lon_min: f64,
69        lat_min: f64,
70        lon_max: f64,
71        lat_max: f64,
72        step_deg: f64,
73    ) -> Self {
74        let ncols = ((lon_max - lon_min) / step_deg).ceil() as usize + 1;
75        let nrows = ((lat_max - lat_min) / step_deg).ceil() as usize + 1;
76        let use_radians = pj.left == IoUnits::Radians;
77
78        let mut cells = Vec::with_capacity(nrows * ncols);
79
80        for r in 0..nrows {
81            let lat = lat_min + r as f64 * step_deg;
82            for c in 0..ncols {
83                let lon = lon_min + c as f64 * step_deg;
84
85                let coord = if use_radians {
86                    Coord::new(lon.to_radians(), lat.to_radians(), 0.0, 0.0)
87                } else {
88                    Coord::new(lon, lat, 0.0, 0.0)
89                };
90
91                let cell = compute_cell(pj, lon, lat, coord);
92                cells.push(cell);
93            }
94        }
95
96        DistortionRaster {
97            lon_min,
98            lon_max,
99            lat_min,
100            lat_max,
101            ncols,
102            nrows,
103            cells,
104        }
105    }
106
107    /// Get a cell by row and column index.
108    pub fn get(&self, row: usize, col: usize) -> Option<&TissotCell> {
109        if row < self.nrows && col < self.ncols {
110            Some(&self.cells[row * self.ncols + col])
111        } else {
112            None
113        }
114    }
115}
116
117fn failed_cell(lon: f64, lat: f64) -> TissotCell {
118    TissotCell {
119        lon,
120        lat,
121        semi_major: f64::NAN,
122        semi_minor: f64::NAN,
123        angular_distortion: f64::NAN,
124        areal_scale: f64::NAN,
125        meridian_scale: f64::NAN,
126        parallel_scale: f64::NAN,
127        meridian_convergence: f64::NAN,
128        is_conformal: false,
129        is_equal_area: false,
130        method: TissotMethod::Failed,
131    }
132}
133
134fn tissot_to_cell(lon: f64, lat: f64, t: TissotParams, method: TissotMethod) -> TissotCell {
135    TissotCell {
136        lon,
137        lat,
138        semi_major: t.semi_major,
139        semi_minor: t.semi_minor,
140        angular_distortion: t.angular_distortion,
141        areal_scale: t.areal_scale,
142        meridian_scale: t.meridian_scale,
143        parallel_scale: t.parallel_scale,
144        meridian_convergence: t.meridian_convergence,
145        is_conformal: t.is_conformal,
146        is_equal_area: t.is_equal_area,
147        method,
148    }
149}
150
151fn compute_cell(pj: &Pj, lon: f64, lat: f64, coord: Coord) -> TissotCell {
152    match pj.factors_exact(coord) {
153        Ok(fe) => {
154            let t = TissotParams::from_factors(&fe);
155            tissot_to_cell(lon, lat, t, TissotMethod::Exact)
156        }
157        Err(ProjError::UnsupportedOperation) => {
158            // Fall back to numeric finite-difference
159            match pj.factors(coord) {
160                Ok(f) => {
161                    // Factors struct fields: h (meridian scale), k (parallel scale),
162                    // s (areal scale), conv (meridian convergence), theta_prime
163                    let t = TissotParams::from_exact(f.h, f.k, f.s, f.theta_prime, f.conv);
164                    tissot_to_cell(lon, lat, t, TissotMethod::Numeric)
165                }
166                Err(_) => failed_cell(lon, lat),
167            }
168        }
169        Err(_) => failed_cell(lon, lat),
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::create::create;
177
178    #[test]
179    fn mercator_3x3_equator() {
180        let pj = create("+proj=merc +datum=WGS84").unwrap();
181        let raster = DistortionRaster::compute(&pj, -5.0, -5.0, 5.0, 5.0, 5.0);
182        assert_eq!(raster.ncols, 3);
183        assert_eq!(raster.nrows, 3);
184
185        // All cells must produce a valid (non-Failed) result.
186        // Exact is returned when an AD-capable projection is registered;
187        // Numeric is returned via finite-difference fallback.
188        // Both are correct; the raster must never be Failed.
189        for cell in &raster.cells {
190            assert_ne!(
191                cell.method,
192                TissotMethod::Failed,
193                "expected non-Failed at lon={} lat={}",
194                cell.lon,
195                cell.lat
196            );
197        }
198
199        // Center cell (row=1, col=1) at (0,0): conformal, angular_distortion ≈ 0
200        let center = raster.get(1, 1).expect("center cell");
201        assert!(
202            center.angular_distortion.abs() < 1e-6,
203            "angular_distortion at origin should be ~0, got {}",
204            center.angular_distortion
205        );
206        assert!(
207            (center.semi_major - center.semi_minor).abs() < 1e-4,
208            "semi_major ≈ semi_minor for conformal at origin, got {} vs {}",
209            center.semi_major,
210            center.semi_minor
211        );
212    }
213
214    #[test]
215    fn europe_5x5_raster() {
216        let pj = create("+proj=merc +datum=WGS84").unwrap();
217        let raster = DistortionRaster::compute(&pj, -10.0, 35.0, 30.0, 75.0, 10.0);
218        assert_eq!(raster.ncols, 5);
219        assert_eq!(raster.nrows, 5);
220
221        for cell in &raster.cells {
222            assert_ne!(
223                cell.method,
224                TissotMethod::Failed,
225                "cell at lon={} lat={} should not fail",
226                cell.lon,
227                cell.lat
228            );
229            // Mercator is conformal so angular_distortion ≈ 0, but general check: >= 0
230            assert!(
231                cell.angular_distortion >= 0.0,
232                "angular_distortion should be >= 0 at lon={} lat={}",
233                cell.lon,
234                cell.lat
235            );
236        }
237    }
238
239    #[test]
240    fn get_out_of_bounds_returns_none() {
241        let pj = create("+proj=merc +datum=WGS84").unwrap();
242        let raster = DistortionRaster::compute(&pj, -5.0, -5.0, 5.0, 5.0, 5.0);
243        assert!(raster.get(10, 10).is_none());
244        assert!(raster.get(0, 0).is_some());
245    }
246
247    #[test]
248    fn tissot_method_name_matches_variants() {
249        // Regression for the `#[non_exhaustive]` future-proofing sweep:
250        // `TissotMethod::name` is the in-crate exhaustive choke point that
251        // downstream crates (the CLI) use instead of matching every variant.
252        assert_eq!(TissotMethod::Exact.name(), "Exact");
253        assert_eq!(TissotMethod::Numeric.name(), "Numeric");
254        assert_eq!(TissotMethod::Failed.name(), "Failed");
255    }
256}