oxiproj-core 0.1.2

Foundation types for OxiProj: coordinates, errors, ellipsoids, datums, and units.
Documentation
//! Core operation contract for OxiProj, ported from PROJ.
//!
//! PROJ models every coordinate operation as a C `struct PJ` (see
//! `src/proj_internal.h`) that carries a set of forward/inverse function
//! pointers — `fwd`, `inv`, `fwd3d`, `inv3d`, `fwd4d`, `inv4d` — together with
//! the input/output unit enumeration `enum pj_io_units`. The dispatch logic in
//! `src/fwd.cpp` (`pj_fwd`, `pj_fwd3d`, `pj_fwd4d`) and `src/inv.cpp`
//! (`pj_inv`, `pj_inv3d`, `pj_inv4d`) implements an *arity cascade*: a higher
//! dimensional call falls back to the lower dimensional function pointer when
//! the higher one is absent, passing the extra coordinate slots through
//! unchanged.
//!
//! This module replaces the raw function pointers with the safe, object-safe
//! [`Operation`] trait. Its default method bodies reproduce PROJ's arity
//! cascade (`forward_4d` → `forward_3d` → `forward_2d`, and the inverse
//! mirror), and [`IoUnits`] mirrors `enum pj_io_units`.

use crate::coord::{Coord, Lp, Lpz, Xy, Xyz};
use crate::error::{ProjError, ProjResult};

/// Input/output unit kind of an operation.
///
/// Ported from src/proj_internal.h (enum pj_io_units). The discriminants match
/// PROJ's enumeration values so the two can be compared directly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[repr(i32)]
pub enum IoUnits {
    /// Doesn't matter (or depends on pipeline neighbours)
    Whatever = 0,
    /// Scaled meters (right), projected system
    Classic = 1,
    /// Meters, projected system
    Projected = 2,
    /// Meters, 3D cartesian system
    Cartesian = 3,
    /// Radians
    Radians = 4,
    /// Degrees
    Degrees = 5,
}

/// The core operation contract: a single coordinate operation that can be
/// applied forward and (optionally) inverse, at 2D, 3D, or 4D arity.
///
/// This is the OxiProj equivalent of the forward/inverse function pointers on
/// PROJ's `struct PJ` (`fwd`, `inv`, `fwd3d`, `inv3d`, `fwd4d`, `inv4d`), as
/// declared in src/proj_internal.h and dispatched by src/fwd.cpp / src/inv.cpp.
///
/// The trait requires `Debug + Send + Sync` so that a boxed operation,
/// `Box<dyn Operation>`, is itself `Debug + Send + Sync`: the transformation
/// engine stores operations as boxed trait objects and must be able to print
/// and share them across threads.
///
/// The trait is object-safe: every method takes `&self` and none is generic,
/// so `Box<dyn Operation>` (and `&dyn Operation`) are valid types.
pub trait Operation: core::fmt::Debug + Send + Sync {
    /// Apply the 2D forward operation, mapping geodetic [`Lp`] to projected [`Xy`].
    ///
    /// The default returns `Err(ProjError::InvalidOp)`, meaning "this operation
    /// does not provide a 2D forward".
    ///
    /// Rationale for choosing [`ProjError::InvalidOp`] rather than
    /// [`ProjError::NoInverseOp`]: `NoInverseOp` (PROJ's
    /// `PROJ_ERR_OTHER_NO_INVERSE_OP`) is semantically about *inverse*
    /// operations, so it is the wrong signal for a missing *forward* direction.
    /// `InvalidOp` (PROJ's `PROJ_ERR_INVALID_OP`) is the correct "no operation
    /// of this kind is set up" indication, matching how PROJ's `pj_fwd` treats a
    /// null forward function pointer as a setup/operation error rather than an
    /// inverse error.
    fn forward_2d(&self, lp: Lp) -> ProjResult<Xy> {
        let _ = lp;
        Err(ProjError::InvalidOp)
    }

    /// Apply the 2D inverse operation, mapping projected [`Xy`] back to geodetic [`Lp`].
    ///
    /// The default returns `Err(ProjError::NoInverseOp)`, meaning no inverse is
    /// available for this operation.
    fn inverse_2d(&self, xy: Xy) -> ProjResult<Lp> {
        let _ = xy;
        Err(ProjError::NoInverseOp)
    }

    /// Apply the 3D forward operation, mapping geodetic [`Lpz`] to Cartesian [`Xyz`].
    ///
    /// The default delegates to [`forward_2d`](Operation::forward_2d) on the
    /// horizontal components and passes the vertical `z` slot through
    /// unchanged, mirroring PROJ's `pj_fwd3d` cascade in src/fwd.cpp.
    fn forward_3d(&self, lpz: Lpz) -> ProjResult<Xyz> {
        let xy = self.forward_2d(Lp::new(lpz.lam, lpz.phi))?;
        Ok(Xyz::new(xy.x, xy.y, lpz.z))
    }

    /// Apply the 3D inverse operation, mapping Cartesian [`Xyz`] back to geodetic [`Lpz`].
    ///
    /// The default delegates to [`inverse_2d`](Operation::inverse_2d) on the
    /// horizontal components and passes the vertical `z` slot through
    /// unchanged, mirroring PROJ's `pj_inv3d` cascade in src/inv.cpp.
    fn inverse_3d(&self, xyz: Xyz) -> ProjResult<Lpz> {
        let lp = self.inverse_2d(Xy::new(xyz.x, xyz.y))?;
        Ok(Lpz::new(lp.lam, lp.phi, xyz.z))
    }

    /// Apply the 4D forward operation on a full [`Coord`] (with time).
    ///
    /// The default delegates to [`forward_3d`](Operation::forward_3d) on the
    /// spatial components and passes the time `t` slot through unchanged,
    /// mirroring PROJ's `pj_fwd4d` cascade in src/fwd.cpp.
    fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
        let lpz = c.lpz();
        let t = c.v()[3];
        let xyz = self.forward_3d(lpz)?;
        Ok(Coord::new(xyz.x, xyz.y, xyz.z, t))
    }

    /// Apply the 4D inverse operation on a full [`Coord`] (with time).
    ///
    /// The default delegates to [`inverse_3d`](Operation::inverse_3d) on the
    /// spatial components and passes the time `t` slot through unchanged,
    /// mirroring PROJ's `pj_inv4d` cascade in src/inv.cpp.
    fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
        let xyz = c.xyz();
        let t = c.v()[3];
        let lpz = self.inverse_3d(xyz)?;
        Ok(Coord::new(lpz.lam, lpz.phi, lpz.z, t))
    }

    /// Report whether an inverse operation is available (default `true`).
    fn has_inverse(&self) -> bool {
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[cfg(feature = "no_std")]
    use alloc::boxed::Box;

    fn approx(a: f64, b: f64) -> bool {
        (a - b).abs() < 1e-12
    }

    #[derive(Debug)]
    struct Shift;

    impl Operation for Shift {
        fn forward_2d(&self, lp: Lp) -> ProjResult<Xy> {
            Ok(Xy::new(lp.lam + 1.0, lp.phi + 2.0))
        }
        fn inverse_2d(&self, xy: Xy) -> ProjResult<Lp> {
            Ok(Lp::new(xy.x - 1.0, xy.y - 2.0))
        }
    }

    #[test]
    fn shift_forward_3d_passes_z_through() -> ProjResult<()> {
        let op = Shift;
        let result = op.forward_3d(Lpz::new(0.1, 0.2, 99.0))?;
        assert!(approx(result.z, 99.0));
        assert!(approx(result.x, 1.1));
        assert!(approx(result.y, 2.2));
        Ok(())
    }

    #[test]
    fn shift_forward_4d_passes_z_and_t_through() -> ProjResult<()> {
        let op = Shift;
        let result = op.forward_4d(Coord::new(0.1, 0.2, 99.0, 2020.0))?;
        assert!(approx(result.v()[2], 99.0));
        assert!(approx(result.v()[3], 2020.0));
        assert!(approx(result.v()[0], 1.1));
        assert!(approx(result.v()[1], 2.2));
        Ok(())
    }

    #[test]
    fn shift_inverse_4d_round_trips() -> ProjResult<()> {
        let op = Shift;
        let c = Coord::new(0.1, 0.2, 99.0, 2020.0);
        let f = op.forward_4d(c)?;
        let r = op.inverse_4d(f)?;
        assert!(approx(r.v()[0], 0.1));
        assert!(approx(r.v()[1], 0.2));
        assert!(approx(r.v()[2], 99.0));
        assert!(approx(r.v()[3], 2020.0));
        Ok(())
    }

    #[derive(Debug)]
    struct Identity4d;

    impl Operation for Identity4d {
        fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
            Ok(c)
        }
        fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
            Ok(c)
        }
        fn has_inverse(&self) -> bool {
            true
        }
    }

    #[test]
    fn identity4d_forward_works_directly() -> ProjResult<()> {
        let op = Identity4d;
        let result = op.forward_4d(Coord::new(5.0, 6.0, 7.0, 8.0))?;
        assert!(approx(result.v()[0], 5.0));
        assert!(approx(result.v()[1], 6.0));
        assert!(approx(result.v()[2], 7.0));
        assert!(approx(result.v()[3], 8.0));
        Ok(())
    }

    #[test]
    fn identity4d_forward_2d_returns_default_invalid_op() {
        let op = Identity4d;
        assert!(op.forward_2d(Lp::new(0.0, 0.0)) == Err(ProjError::InvalidOp));
    }

    #[test]
    fn identity4d_has_inverse_true() {
        let op = Identity4d;
        assert!(op.has_inverse());
    }

    #[derive(Debug)]
    struct NoInverse;

    impl Operation for NoInverse {
        fn forward_2d(&self, lp: Lp) -> ProjResult<Xy> {
            Ok(Xy::new(lp.lam, lp.phi))
        }
        fn has_inverse(&self) -> bool {
            false
        }
    }

    #[test]
    fn no_inverse_reports_false() {
        assert!(!NoInverse.has_inverse());
        assert!(NoInverse.inverse_2d(Xy::new(0.0, 0.0)) == Err(ProjError::NoInverseOp));
    }

    #[test]
    fn io_units_discriminants() {
        assert!(IoUnits::Whatever as i32 == 0);
        assert!(IoUnits::Classic as i32 == 1);
        assert!(IoUnits::Projected as i32 == 2);
        assert!(IoUnits::Cartesian as i32 == 3);
        assert!(IoUnits::Radians as i32 == 4);
        assert!(IoUnits::Degrees as i32 == 5);
    }

    #[test]
    fn operation_is_object_safe() -> ProjResult<()> {
        let b: Box<dyn Operation> = Box::new(Shift);
        let xy = b.forward_2d(Lp::new(0.0, 0.0))?;
        assert!(approx(xy.x, 1.0));
        assert!(approx(xy.y, 2.0));
        Ok(())
    }
}