oxiproj-transformations 0.1.1

Datum transformations and coordinate conversions for OxiProj.
Documentation
#![forbid(unsafe_code)]
//! TIN-based coordinate shift (`tinshift`) — port of PROJ `src/transformations/tinshift.cpp`.
//! Reads a TIN-format JSON file and interpolates coordinate shifts using barycentric
//! interpolation within triangles of an irregular triangulated network.
//! Inverse is not supported (forward-only operation).

use crate::{TransBuild, TransParams};
use oxiproj_core::{Coord, IoUnits, Operation, ProjError, ProjResult};
use oxiproj_grids::{read_tinshift, TinShiftIndex};

#[derive(Debug)]
struct TinShiftTransform {
    index: TinShiftIndex,
}

impl Operation for TinShiftTransform {
    fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
        let v = c.v();
        let lon_deg = v[0].to_degrees();
        let lat_deg = v[1].to_degrees();
        let (dx_deg, dy_deg) = self
            .index
            .interpolate(lon_deg, lat_deg)
            .ok_or(ProjError::OutsideGrid)?;
        Ok(Coord::new(
            v[0] + dx_deg.to_radians(),
            v[1] + dy_deg.to_radians(),
            v[2],
            v[3],
        ))
    }

    fn inverse_4d(&self, _c: Coord) -> ProjResult<Coord> {
        Err(ProjError::NoInverseOp)
    }

    fn has_inverse(&self) -> bool {
        false
    }
}

/// Construct a `tinshift` transform from parsed parameters.
///
/// Requires `+grids=<name>` and a populated [`GridRegistry`](crate::GridRegistry).
/// The named grid is parsed as a TIN-shift JSON file.
pub fn new(p: &TransParams) -> ProjResult<TransBuild> {
    let grid_name = p.params.get_str("grids").ok_or(ProjError::MissingArg)?;
    let registry = p.registry.ok_or(ProjError::FileNotFound)?;
    let data = registry
        .get_grid(grid_name)
        .ok_or(ProjError::FileNotFound)?;
    let index = read_tinshift(data)?;
    Ok(TransBuild::new(
        Box::new(TinShiftTransform { index }),
        IoUnits::Radians,
        IoUnits::Radians,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use oxiproj_core::DEG_TO_RAD;

    /// Build a minimal tinshift JSON for a 2-triangle mesh.
    /// Vertices: (0,0), (1,0), (0.5,1), (0,1)
    /// Triangles: [[0,1,2], [0,2,3]]
    /// offset_x: [0.1, 0.2, 0.3, 0.0] (degrees)
    /// offset_y: [0.4, 0.5, 0.6, 0.0] (degrees)
    fn make_tinshift_json() -> Vec<u8> {
        let s = r#"{
  "file_type": "tinshift_v1",
  "input_crs": "EPSG:4326",
  "output_crs": "EPSG:4167",
  "triangulation": {
    "vertices": [[0.0, 0.0], [1.0, 0.0], [0.5, 1.0], [0.0, 1.0]],
    "triangles": [[0, 1, 2], [0, 2, 3]],
    "offset_x": [0.1, 0.2, 0.3, 0.0],
    "offset_y": [0.4, 0.5, 0.6, 0.0]
  }
}"#;
        s.as_bytes().to_vec()
    }

    struct InMemReg(std::collections::HashMap<String, Vec<u8>>);
    impl crate::GridRegistry for InMemReg {
        fn get_grid(&self, name: &str) -> Option<&[u8]> {
            self.0.get(name).map(|v| v.as_slice())
        }
    }

    struct TestParams {
        grids: String,
    }
    impl crate::TransParamLookup for TestParams {
        fn get_str(&self, key: &str) -> Option<&str> {
            if key == "grids" {
                Some(&self.grids)
            } else {
                None
            }
        }
        fn get_f64(&self, _: &str) -> Option<f64> {
            None
        }
        fn get_dms(&self, _: &str) -> Option<f64> {
            None
        }
        fn get_int(&self, _: &str) -> Option<i64> {
            None
        }
        fn get_bool(&self, _: &str) -> bool {
            false
        }
        fn exists(&self, key: &str) -> bool {
            key == "grids"
        }
    }

    fn make_transform() -> TransBuild {
        let mut map = std::collections::HashMap::new();
        map.insert("test.json".to_string(), make_tinshift_json());
        let reg = InMemReg(map);
        let tp = TestParams {
            grids: "test.json".to_string(),
        };
        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
        let params = crate::TransParams {
            ellipsoid: &ell,
            params: &tp,
            registry: Some(&reg),
        };
        new(&params).unwrap()
    }

    #[test]
    fn test_tinshift_forward_inside_mesh() {
        let tb = make_transform();
        // Point at (0.3, 0.3) is inside triangle [0,1,2]
        let input = Coord::new(0.3 * DEG_TO_RAD, 0.3 * DEG_TO_RAD, 0.0, 0.0);
        let out = tb.operation.forward_4d(input);
        assert!(out.is_ok(), "should succeed inside mesh: {:?}", out);
        let v = out.unwrap().v();
        // Result should be shifted from (0.3, 0.3) by some interpolated dx/dy
        // (exact values depend on barycentric weights, just check it's nonzero shift)
        assert!(v[0] > 0.3 * DEG_TO_RAD, "lon should be shifted positively");
        assert!(v[1] > 0.3 * DEG_TO_RAD, "lat should be shifted positively");
    }

    #[test]
    fn test_tinshift_inverse_unavailable() {
        let tb = make_transform();
        assert!(!tb.operation.has_inverse());
        let c = Coord::new(0.3 * DEG_TO_RAD, 0.3 * DEG_TO_RAD, 0.0, 0.0);
        assert_eq!(
            tb.operation.inverse_4d(c).err(),
            Some(ProjError::NoInverseOp)
        );
    }

    #[test]
    fn test_tinshift_outside_returns_err() {
        let tb = make_transform();
        // Point far outside the mesh
        let far = Coord::new(45.0 * DEG_TO_RAD, 45.0 * DEG_TO_RAD, 0.0, 0.0);
        assert_eq!(
            tb.operation.forward_4d(far).err(),
            Some(ProjError::OutsideGrid)
        );
    }

    #[test]
    fn test_tinshift_missing_grids_param() {
        struct NoParams;
        impl crate::TransParamLookup for NoParams {
            fn get_str(&self, _: &str) -> Option<&str> {
                None
            }
            fn get_f64(&self, _: &str) -> Option<f64> {
                None
            }
            fn get_dms(&self, _: &str) -> Option<f64> {
                None
            }
            fn get_int(&self, _: &str) -> Option<i64> {
                None
            }
            fn get_bool(&self, _: &str) -> bool {
                false
            }
            fn exists(&self, _: &str) -> bool {
                false
            }
        }
        let reg = InMemReg(std::collections::HashMap::new());
        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
        let np = NoParams;
        let params = crate::TransParams {
            ellipsoid: &ell,
            params: &np,
            registry: Some(&reg),
        };
        assert_eq!(new(&params).err(), Some(ProjError::MissingArg));
    }
}