oxiproj-transformations 0.1.2

Datum transformations and coordinate conversions for OxiProj.
Documentation
//! `axisswap` conversion. Ported from PROJ `src/conversions/axisswap.cpp`.
//!
//! The `axisswap` operation reorders and/or sign-flips the four coordinate
//! components. Two parameter forms are supported — exactly one must be present:
//!
//! * `+order=` — a comma-separated list of 2-to-4 signed, 1-based axis
//!   indices. For example `+order=2,1` swaps the first two axes and
//!   `+order=1,-2,3,4` negates the second axis while leaving the rest in place.
//!
//! * `+axis=` — a 3-character classic PROJ.4 string whose letters encode both
//!   the axis source and sign. Letters: `e`/`w` (east/west → ±X), `n`/`s`
//!   (north/south → ±Y), `u`/`d` (up/down → ±Z). Example: `+axis=neu` swaps
//!   X and Y; `+axis=wsu` negates X and Y.
//!
//! `+order` and `+axis` are mutually exclusive (mirrors PROJ behaviour).

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

/// The `axisswap` conversion. Ported from PROJ `src/conversions/axisswap.cpp`.
///
/// `axis[i]` is the source slot (0-based) feeding output slot `i`, and
/// `sign[i]` is the multiplier (`+1.0` or `-1.0`) applied to it.
#[derive(Debug)]
struct AxisSwap {
    axis: [usize; 4],
    sign: [f64; 4],
}

// Ported from PROJ `src/conversions/axisswap.cpp`.
impl Operation for AxisSwap {
    fn forward_4d(&self, c: Coord) -> ProjResult<Coord> {
        // PROJ: out.v[i] = in.v[axis[i]] * sign[i]
        let v = c.v();
        let mut out = [0.0f64; 4];
        for (i, slot) in out.iter_mut().enumerate() {
            *slot = v[self.axis[i]] * self.sign[i];
        }
        Ok(Coord::new(out[0], out[1], out[2], out[3]))
    }

    fn inverse_4d(&self, c: Coord) -> ProjResult<Coord> {
        // PROJ: out.v[axis[i]] = in.v[i] * sign[i]
        let v = c.v();
        let mut out = [0.0f64; 4];
        for ((src, &dst_idx), &s) in v.iter().zip(self.axis.iter()).zip(self.sign.iter()) {
            out[dst_idx] = src * s;
        }
        Ok(Coord::new(out[0], out[1], out[2], out[3]))
    }

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

/// Parse an `+order=` value into per-slot source axes and signs.
///
/// Accepts 2 to 4 comma-separated, signed, 1-based axis indices. Returns
/// [`ProjError::IllegalArgValue`] for empty input, unparseable tokens,
/// out-of-range values, a zero index, the wrong number of tokens, or a
/// duplicated axis.
fn parse_order(s: &str) -> ProjResult<([usize; 4], [f64; 4])> {
    // Parse all tokens up front, rejecting empty input and parse failures.
    let mut tokens: Vec<i32> = Vec::new();
    for raw in s.split(',') {
        let tok = raw.trim();
        if tok.is_empty() {
            return Err(ProjError::IllegalArgValue);
        }
        match tok.parse::<i32>() {
            Ok(value) => tokens.push(value),
            Err(_) => return Err(ProjError::IllegalArgValue),
        }
    }

    let n = tokens.len();
    if !(2..=4).contains(&n) {
        return Err(ProjError::IllegalArgValue);
    }

    let mut axis = [0usize, 1, 2, 3];
    let mut sign = [1.0f64; 4];

    for (i, &tok) in tokens.iter().enumerate() {
        // Validate the value range BEFORE deriving the (unsigned) index.
        if tok == 0 || !(1..=4).contains(&tok.abs()) {
            return Err(ProjError::IllegalArgValue);
        }
        let idx = (tok.unsigned_abs() as usize) - 1;
        axis[i] = idx;
        sign[i] = if tok < 0 { -1.0 } else { 1.0 };
    }

    // Reject duplicated axes across the full resolved mapping.
    let mut seen = [false; 4];
    for &a in axis.iter() {
        if seen[a] {
            return Err(ProjError::IllegalArgValue);
        }
        seen[a] = true;
    }

    Ok((axis, sign))
}

/// Parse a classic PROJ.4 `+axis=` letter specification (exactly 3 characters).
///
/// Letter mapping (from `src/conversions/axisswap.cpp`):
/// - `e` → axis 0, sign +1   (east)
/// - `w` → axis 0, sign −1   (west)
/// - `n` → axis 1, sign +1   (north)
/// - `s` → axis 1, sign −1   (south)
/// - `u` → axis 2, sign +1   (up)
/// - `d` → axis 2, sign −1   (down)
///
/// Must be exactly 3 characters; returns [`ProjError::IllegalArgValue`] for
/// unknown characters, wrong length, or a duplicated axis index.
fn parse_axis(s: &str) -> ProjResult<([usize; 4], [f64; 4])> {
    let bytes = s.as_bytes();
    if bytes.len() != 3 {
        return Err(ProjError::IllegalArgValue);
    }
    // Start with an identity mapping for all four slots so that slot 3
    // (which the 3-character +axis= form never touches) stays at index 3.
    let mut axis = [0usize, 1, 2, 3];
    let mut sign = [1.0f64; 4];
    for (i, &b) in bytes.iter().enumerate() {
        let (ax, sg) = match b {
            b'e' => (0usize, 1.0f64),
            b'w' => (0, -1.0),
            b'n' => (1, 1.0),
            b's' => (1, -1.0),
            b'u' => (2, 1.0),
            b'd' => (2, -1.0),
            _ => return Err(ProjError::IllegalArgValue),
        };
        axis[i] = ax;
        sign[i] = sg;
    }
    // Reject duplicate axes across the three specified positions.
    let mut seen = [false; 4];
    for &a in &axis[..3] {
        if seen[a] {
            return Err(ProjError::IllegalArgValue);
        }
        seen[a] = true;
    }
    Ok((axis, sign))
}

/// Construct the `axisswap` conversion from its `+order=` or `+axis=` parameter.
///
/// `+order` and `+axis` are mutually exclusive: exactly one must be present.
pub fn new(p: &TransParams) -> ProjResult<TransBuild> {
    let has_order = p.params.exists("order");
    let has_axis = p.params.exists("axis");

    // Exactly one must be specified (mirrors PROJ's mutual exclusivity check).
    if has_order == has_axis {
        // Both present or both absent.
        return Err(ProjError::MissingArg);
    }

    let (axis, sign) = if has_order {
        let order = p.params.get_str("order").ok_or(ProjError::MissingArg)?;
        parse_order(order)?
    } else {
        let axis_str = p.params.get_str("axis").ok_or(ProjError::MissingArg)?;
        parse_axis(axis_str)?
    };

    Ok(TransBuild::new(
        Box::new(AxisSwap { axis, sign }),
        IoUnits::Whatever,
        IoUnits::Whatever,
    ))
}

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

    // ── helpers for +order= ───────────────────────────────────────────────────

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

    fn build_with(order: Option<&str>) -> ProjResult<TransBuild> {
        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
        let params = OrderParams {
            order: order.map(|s| s.to_string()),
        };
        let tp = TransParams {
            ellipsoid: &ell,
            params: &params,
            registry: None,
        };
        new(&tp)
    }

    // ── helpers for +axis= ────────────────────────────────────────────────────

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

    fn build_with_axis(axis: Option<&str>) -> ProjResult<TransBuild> {
        let ell = oxiproj_core::Ellipsoid::named("WGS84").unwrap();
        let params = AxisParams {
            axis: axis.map(|s| s.to_string()),
        };
        let tp = TransParams {
            ellipsoid: &ell,
            params: &params,
            registry: None,
        };
        new(&tp)
    }

    // ── +order= tests ─────────────────────────────────────────────────────────

    #[test]
    fn order_swap_two() {
        let build = build_with(Some("2,1")).unwrap();
        let op = build.operation;
        let fwd = op.forward_4d(Coord::new(12.0, 55.0, 100.0, 0.0)).unwrap();
        assert_eq!(fwd.v(), [55.0, 12.0, 100.0, 0.0]);
        let inv = op.inverse_4d(fwd).unwrap();
        assert_eq!(inv.v(), [12.0, 55.0, 100.0, 0.0]);
    }

    #[test]
    fn order_negate_second() {
        let build = build_with(Some("1,-2,3,4")).unwrap();
        let op = build.operation;
        let fwd = op.forward_4d(Coord::new(12.0, 55.0, 100.0, 0.0)).unwrap();
        assert_eq!(fwd.v(), [12.0, -55.0, 100.0, 0.0]);
        let inv = op.inverse_4d(fwd).unwrap();
        assert_eq!(inv.v(), [12.0, 55.0, 100.0, 0.0]);
    }

    #[test]
    fn order_full_reverse() {
        let build = build_with(Some("4,3,2,1")).unwrap();
        let op = build.operation;
        let fwd = op.forward_4d(Coord::new(1.0, 2.0, 3.0, 4.0)).unwrap();
        assert_eq!(fwd.v(), [4.0, 3.0, 2.0, 1.0]);
        let inv = op.inverse_4d(fwd).unwrap();
        assert_eq!(inv.v(), [1.0, 2.0, 3.0, 4.0]);
    }

    #[test]
    fn missing_order_is_missing_arg() {
        // Both order and axis absent → MissingArg.
        assert_eq!(build_with(None).err(), Some(ProjError::MissingArg));
    }

    #[test]
    fn out_of_range_is_illegal() {
        assert_eq!(
            build_with(Some("5,1")).err(),
            Some(ProjError::IllegalArgValue)
        );
    }

    #[test]
    fn duplicate_axis_is_illegal() {
        assert_eq!(
            build_with(Some("1,1")).err(),
            Some(ProjError::IllegalArgValue)
        );
    }

    // ── +axis= tests ──────────────────────────────────────────────────────────

    #[test]
    fn axis_enu_is_identity() {
        // enu: east=X(+1), north=Y(+1), up=Z(+1) → no swap, no sign flip.
        let build = build_with_axis(Some("enu")).unwrap();
        let op = build.operation;
        let fwd = op.forward_4d(Coord::new(1.0, 2.0, 3.0, 0.0)).unwrap();
        assert_eq!(fwd.v(), [1.0, 2.0, 3.0, 0.0]);
        let inv = op.inverse_4d(fwd).unwrap();
        assert_eq!(inv.v(), [1.0, 2.0, 3.0, 0.0]);
    }

    #[test]
    fn axis_neu_swaps_x_y() {
        // neu: slot 0 ← axis 1 (Y), slot 1 ← axis 0 (X) → swap X and Y.
        let build = build_with_axis(Some("neu")).unwrap();
        let op = build.operation;
        let fwd = op.forward_4d(Coord::new(12.0, 55.0, 100.0, 0.0)).unwrap();
        assert_eq!(fwd.v(), [55.0, 12.0, 100.0, 0.0]);
        let inv = op.inverse_4d(fwd).unwrap();
        assert_eq!(inv.v(), [12.0, 55.0, 100.0, 0.0]);
    }

    #[test]
    fn axis_wsu_negates_x_and_y() {
        // wsu: west=−X, south=−Y, up=+Z.
        let build = build_with_axis(Some("wsu")).unwrap();
        let op = build.operation;
        let fwd = op.forward_4d(Coord::new(12.0, 55.0, 100.0, 0.0)).unwrap();
        assert_eq!(fwd.v(), [-12.0, -55.0, 100.0, 0.0]);
        let inv = op.inverse_4d(fwd).unwrap();
        assert_eq!(inv.v(), [12.0, 55.0, 100.0, 0.0]);
    }

    #[test]
    fn axis_and_order_both_absent_is_missing_arg() {
        // Both absent → MissingArg (same as `build_with(None)`).
        let build = build_with_axis(None);
        assert_eq!(build.err(), Some(ProjError::MissingArg));
    }

    #[test]
    fn axis_invalid_char_is_illegal() {
        assert_eq!(
            build_with_axis(Some("exu")).err(),
            Some(ProjError::IllegalArgValue)
        );
    }

    #[test]
    fn axis_wrong_length_is_illegal() {
        assert_eq!(
            build_with_axis(Some("en")).err(),
            Some(ProjError::IllegalArgValue)
        );
        assert_eq!(
            build_with_axis(Some("enud")).err(),
            Some(ProjError::IllegalArgValue)
        );
    }

    #[test]
    fn axis_duplicate_is_illegal() {
        // "een" — both slots 0 and 1 map to axis 0 (east/X).
        assert_eq!(
            build_with_axis(Some("een")).err(),
            Some(ProjError::IllegalArgValue)
        );
    }
}