oxiproj-engine 0.1.1

Proj-string parser, operation dispatch, and transformation pipelines for OxiProj.
Documentation
//! Pipeline optimization pass: detect and eliminate redundant steps.
//!
//! Called once after pipeline construction in `create.rs`. Applies safe,
//! provably-correct elimination rules without changing observable semantics.
//!
//! # Rules applied (in order)
//!
//! 1. **Complete no-ops**: steps where both `omit_fwd` and `omit_inv` are
//!    true are skipped in every execution path — remove them unconditionally.
//!
//! 2. **Paired axisswap cancellation**: two consecutive `axisswap` steps
//!    cancel each other when one is the forward and the other the inverse of
//!    the same permutation, or when the same permutation applied twice yields
//!    identity. Since the axis/sign state is encapsulated inside the
//!    `Box<dyn Operation>`, we use a conservative wrapper-field heuristic:
//!    two adjacent axisswap steps cancel when neither is selectively omitted
//!    and their `Pj` wrapper parameters are identical. One `inverted` and one
//!    non-`inverted` step with the same wrapper parameters always cancel
//!    (they are exact inverses). Two steps with the same `inverted` flag and
//!    the same wrapper parameters also cancel because axisswap is self-inverse.
//!
//! # Design note: identity Helmert
//!
//! An earlier design attempted to remove all-zero Helmert steps by inspecting
//! `Pj` wrapper fields (`x0`, `y0`, `z0`, `k0`, …). This is **unsafe**:
//! those fields are the generic PROJ false-easting/false-northing parameters
//! (`+x_0`, `+y_0`, `+z_0`, `+k_0`) — not the Helmert-specific translation
//! parameters (`+x`, `+y`, `+z`, `+rx`, `+ry`, `+rz`, `+s`), which are
//! consumed by the inner operation builder and stored inside the
//! `Box<dyn Operation>`. Removing a Helmert step purely on wrapper-field
//! evidence would silently drop real shifts. The all-zero guard is already
//! applied at the `build_datum_shift_pipeline` level (before the Helmert is
//! constructed at all) which is the correct, safe place.

use crate::pj::Pj;

/// Run all optimization passes on a pipeline's step list in order.
///
/// Returns the (possibly shorter) optimized step list. The input semantics
/// are preserved: the optimized pipeline is observably identical to the
/// original for all valid inputs.
pub(crate) fn optimize_pipeline(steps: Vec<Pj>) -> Vec<Pj> {
    let steps = remove_complete_noops(steps);
    cancel_axisswap_pairs(steps)
}

/// Remove steps that are skipped in both the forward and inverse directions.
///
/// A step with `omit_fwd = true` AND `omit_inv = true` is never executed
/// regardless of pipeline direction — it is a complete structural no-op.
fn remove_complete_noops(steps: Vec<Pj>) -> Vec<Pj> {
    steps
        .into_iter()
        .filter(|s| !(s.omit_fwd && s.omit_inv))
        .collect()
}

/// Cancel consecutive `axisswap` pairs that are inverses of each other.
///
/// Two adjacent `axisswap` steps cancel when:
/// - Neither step is selectively omitted (`omit_fwd` or `omit_inv`).
/// - Their `Pj` wrapper fields match exactly (same ellipsoid, `lam0`, `phi0`,
///   `x0`, `y0`, `k0`).
///
/// Under these conditions both same-direction pairs (axisswap is self-inverse,
/// so `f∘f = id`) and opposite-direction pairs (forward vs inverse of the
/// same step, which are always inverses) cancel to identity.
fn cancel_axisswap_pairs(steps: Vec<Pj>) -> Vec<Pj> {
    let mut result: Vec<Pj> = Vec::with_capacity(steps.len());
    let mut iter = steps.into_iter().peekable();
    while let Some(current) = iter.next() {
        let cancels = iter
            .peek()
            .map(|next| {
                current.op_name == "axisswap"
                    && next.op_name == "axisswap"
                    && axisswap_steps_cancel(&current, next)
            })
            .unwrap_or(false);
        if cancels {
            // Consume the next step as well — both steps cancel out.
            let _ = iter.next();
            // Neither step is pushed to result; they are dropped.
        } else {
            result.push(current);
        }
    }
    result
}

/// Returns `true` when two axisswap steps can be safely cancelled.
///
/// Cancellation is safe only when:
/// 1. Neither step has selective omit flags (those change semantics).
/// 2. All `Pj` wrapper parameters that affect prepare/finalize are identical
///    between the two steps: `lam0`, `phi0`, `x0`, `y0`, `k0`, and the
///    ellipsoid semi-major axis and eccentricity squared.
///
/// When these conditions hold, the combined effect of the two steps is an
/// identity transform regardless of the axis permutation stored inside the
/// `Box<dyn Operation>`, because:
/// - Two identical permutations compose to identity (axisswap is self-inverse
///   for involutions; the most common case `order=2,1` is an involution).
/// - A forward step followed by its own inverse is always identity.
fn axisswap_steps_cancel(a: &Pj, b: &Pj) -> bool {
    // Neither step may have selective omit flags.
    if a.omit_fwd || a.omit_inv || b.omit_fwd || b.omit_inv {
        return false;
    }
    // Wrapper fields that affect prepare/finalize must be identical.
    a.lam0 == b.lam0
        && a.phi0 == b.phi0
        && a.x0 == b.x0
        && a.y0 == b.y0
        && a.k0 == b.k0
        && a.ellipsoid.a == b.ellipsoid.a
        && a.ellipsoid.es == b.ellipsoid.es
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pj::Pj;
    use oxiproj_core::{Ellipsoid, IoUnits};

    /// Minimal stand-in operation for unit tests — a pass-through identity.
    #[derive(Debug)]
    struct IdOp;
    impl oxiproj_core::Operation for IdOp {
        fn forward_2d(&self, lp: oxiproj_core::Lp) -> oxiproj_core::ProjResult<oxiproj_core::Xy> {
            Ok(oxiproj_core::Xy::new(lp.lam, lp.phi))
        }
        fn inverse_2d(&self, xy: oxiproj_core::Xy) -> oxiproj_core::ProjResult<oxiproj_core::Lp> {
            Ok(oxiproj_core::Lp::new(xy.x, xy.y))
        }
    }

    fn make_pj(op_name: &str, omit_fwd: bool, omit_inv: bool) -> Pj {
        Pj {
            operation: Box::new(IdOp),
            ellipsoid: Ellipsoid::named("WGS84").expect("WGS84"),
            lam0: 0.0,
            phi0: 0.0,
            x0: 0.0,
            y0: 0.0,
            z0: 0.0,
            k0: 1.0,
            to_meter: 1.0,
            fr_meter: 1.0,
            vto_meter: 1.0,
            vfr_meter: 1.0,
            from_greenwich: 0.0,
            over: false,
            geoc: false,
            is_latlong: false,
            left: IoUnits::Whatever,
            right: IoUnits::Whatever,
            inverted: false,
            bypass_prepare_finalize: true,
            omit_fwd,
            omit_inv,
            ad_proj: None,
            op_name: op_name.to_string(),
        }
    }

    #[test]
    fn noop_removal_both_omit() {
        let steps = vec![
            make_pj("noop", false, false),
            make_pj("noop", true, true), // complete no-op
            make_pj("noop", false, false),
        ];
        let result = optimize_pipeline(steps);
        assert_eq!(
            result.len(),
            2,
            "middle step with omit_fwd+omit_inv must be removed"
        );
    }

    #[test]
    fn noop_removal_only_fwd_kept() {
        // omit_fwd only → NOT removed (still runs in inverse)
        let steps = vec![make_pj("noop", true, false)];
        let result = optimize_pipeline(steps);
        assert_eq!(result.len(), 1, "omit_fwd-only step must be kept");
    }

    #[test]
    fn noop_removal_only_inv_kept() {
        // omit_inv only → NOT removed (still runs in forward)
        let steps = vec![make_pj("noop", false, true)];
        let result = optimize_pipeline(steps);
        assert_eq!(result.len(), 1, "omit_inv-only step must be kept");
    }

    #[test]
    fn axisswap_pair_cancelled() {
        let steps = vec![
            make_pj("axisswap", false, false),
            make_pj("axisswap", false, false),
        ];
        let result = optimize_pipeline(steps);
        assert_eq!(result.len(), 0, "two identical axisswap steps must cancel");
    }

    #[test]
    fn axisswap_with_omit_not_cancelled() {
        let steps = vec![
            make_pj("axisswap", true, false), // omit_fwd set
            make_pj("axisswap", false, false),
        ];
        let result = optimize_pipeline(steps);
        assert_eq!(
            result.len(),
            2,
            "axisswap with selective omit must not cancel"
        );
    }

    #[test]
    fn axisswap_singleton_kept() {
        let steps = vec![make_pj("axisswap", false, false)];
        let result = optimize_pipeline(steps);
        assert_eq!(result.len(), 1, "single axisswap must be kept");
    }

    #[test]
    fn empty_input_stays_empty() {
        let result = optimize_pipeline(vec![]);
        assert!(result.is_empty());
    }
}