proj-core 0.10.0

Pure-Rust coordinate transformation library with no C dependencies
Documentation
//! Corpus-driven reference value tests verified against C PROJ.
//!
//! Loads `testdata/reference_values.json` (generated by `gen-reference` using the
//! C PROJ library with bundled_proj feature) and verifies every point matches
//! proj-core's output within the specified tolerance.
//!
//! The corpus covers:
//! - Common projection families (Web Mercator, UTM, Polar Stereographic, LCC, Albers, Mercator,
//!   Equidistant Cylindrical, LAEA, Oblique Stereographic, Hotine Oblique Mercator, Cassini-Soldner)
//! - Multiple geographic test points per projection (NYC, Tokyo, London, Paris, poles, dateline, etc.)
//! - Forward and inverse transforms
//! - Cross-datum transforms (NAD27→WGS84, OSGB36→WGS84, ED50→WGS84)
//! - Roundtrip verification (forward then inverse)
//! - Edge cases (poles, antimeridian, equator, projection zone boundaries,
//!   near-pole inverses, wrong-hemisphere polar stereographic inputs)
//! - 3D points through promoted 3D CRSs, including cross-datum ellipsoidal
//!   height changes

use proj_core::{AreaOfInterest, Coord, SelectionOptions, Transform};
use serde::Deserialize;

#[derive(Deserialize)]
struct ReferencePoint {
    from_epsg: u32,
    to_epsg: u32,
    input_x: f64,
    input_y: f64,
    #[serde(default)]
    input_z: Option<f64>,
    expected_x: f64,
    expected_y: f64,
    #[serde(default)]
    expected_z: Option<f64>,
    tolerance: f64,
    #[serde(default)]
    tolerance_z: Option<f64>,
    description: String,
}

fn load_corpus() -> Vec<ReferencePoint> {
    let path = concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../testdata/reference_values.json"
    );
    let data =
        std::fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"));
    serde_json::from_str(&data).unwrap_or_else(|e| panic!("failed to parse {path}: {e}"))
}

/// Check one reference point; returns a failure message on mismatch.
///
/// The transform is constructed with the input point as area of interest:
/// the reference values come from C PROJ's per-point late-binding operation
/// selection, so location-aware selection is the comparable configuration.
fn check_point(r: &ReferencePoint) -> Result<(), String> {
    let point = Coord::new(r.input_x, r.input_y);
    let options = SelectionOptions {
        area_of_interest: Some(AreaOfInterest::source_crs_point(point)),
        ..SelectionOptions::default()
    };
    let t = Transform::with_selection_options(
        &format!("EPSG:{}", r.from_epsg),
        &format!("EPSG:{}", r.to_epsg),
        options,
    )
    .map_err(|e| {
        format!(
            "{}: unexpected transform construction failure for EPSG:{}→EPSG:{}: {e}",
            r.description, r.from_epsg, r.to_epsg
        )
    })?;

    match (r.input_z, r.expected_z) {
        (Some(iz), Some(ez)) => {
            let (rx, ry, rz) = t
                .convert_3d((r.input_x, r.input_y, iz))
                .map_err(|e| format!("{}: 3D transform failed: {e}", r.description))?;
            let (dx, dy, dz) = (
                (rx - r.expected_x).abs(),
                (ry - r.expected_y).abs(),
                (rz - ez).abs(),
            );
            let tol_z = r.tolerance_z.unwrap_or(r.tolerance);
            if dx > r.tolerance || dy > r.tolerance || dz > tol_z {
                return Err(format!(
                    "{}: expected ({}, {}, {}), got ({}, {}, {}), delta ({:e}, {:e}, {:e}), tol {:e}, tol_z {:e}",
                    r.description, r.expected_x, r.expected_y, ez, rx, ry, rz, dx, dy, dz,
                    r.tolerance, tol_z
                ));
            }
        }
        _ => {
            let (rx, ry) = t
                .convert((r.input_x, r.input_y))
                .map_err(|e| format!("{}: transform failed: {e}", r.description))?;
            let (dx, dy) = ((rx - r.expected_x).abs(), (ry - r.expected_y).abs());
            if dx > r.tolerance || dy > r.tolerance {
                return Err(format!(
                    "{}: expected ({}, {}), got ({}, {}), delta ({:e}, {:e}), tol {:e}",
                    r.description, r.expected_x, r.expected_y, rx, ry, dx, dy, r.tolerance
                ));
            }
        }
    }
    Ok(())
}

#[test]
fn corpus_matches_c_proj() {
    let corpus = load_corpus();
    assert!(!corpus.is_empty(), "corpus is empty");

    let mut pass = 0;
    let mut failures = Vec::new();

    for r in &corpus {
        match check_point(r) {
            Ok(()) => pass += 1,
            Err(msg) => failures.push(msg),
        }
    }

    eprintln!(
        "Corpus results: {} passed, {} failed out of {} total",
        pass,
        failures.len(),
        corpus.len()
    );

    if !failures.is_empty() {
        panic!(
            "{} of {} reference values failed:\n{}",
            failures.len(),
            corpus.len(),
            failures.join("\n")
        );
    }
}

#[test]
fn corpus_has_adequate_coverage() {
    let corpus = load_corpus();

    // Verify the corpus covers all projection types
    let epsg_targets: Vec<u32> = corpus.iter().map(|r| r.to_epsg).collect();

    assert!(epsg_targets.contains(&3857), "missing Web Mercator");
    assert!(
        epsg_targets.contains(&3413),
        "missing Polar Stereographic North"
    );
    assert!(
        epsg_targets.contains(&3031),
        "missing Antarctic Polar Stereographic"
    );
    assert!(epsg_targets.contains(&3395), "missing World Mercator");
    assert!(epsg_targets.contains(&2154), "missing Lambert-93");
    assert!(epsg_targets.contains(&5070), "missing CONUS Albers");
    assert!(epsg_targets.contains(&3035), "missing LAEA Europe");
    assert!(epsg_targets.contains(&3408), "missing spherical LAEA north");
    assert!(
        epsg_targets.contains(&9311),
        "missing spherical LAEA ellipsoid CRS"
    );
    assert!(epsg_targets.contains(&28992), "missing RD New");
    assert!(epsg_targets.contains(&3078), "missing Hotine variant A");
    assert!(epsg_targets.contains(&2056), "missing Hotine variant B");
    assert!(epsg_targets.contains(&30200), "missing Cassini-Soldner");
    assert!(epsg_targets.contains(&32662), "missing Plate Carree");
    assert!(epsg_targets.contains(&6247), "missing Colombia Urban");
    assert!(epsg_targets.contains(&24200), "missing LCC 1SP");
    assert!(epsg_targets.contains(&6201), "missing LCC 2SP Michigan");
    assert!(epsg_targets.contains(&9549), "missing LCC 1SP variant B");
    assert!(epsg_targets.contains(&5514), "missing Krovak East North");
    assert!(
        epsg_targets.contains(&5516),
        "missing Modified Krovak East North"
    );
    assert!(
        epsg_targets.contains(&8353),
        "missing JTSK03 Krovak East North"
    );
    assert!(epsg_targets.contains(&8857), "missing Equal Earth");
    assert!(
        epsg_targets.contains(&8859),
        "missing Equal Earth Asia-Pacific"
    );
    assert!(
        epsg_targets.contains(&5880),
        "missing SIRGAS 2000 Brazil Polyconic"
    );
    assert!(
        epsg_targets.contains(&29101),
        "missing SAD69 Brazil Polyconic"
    );
    assert!(
        epsg_targets.contains(&27701),
        "missing Equi7 Africa Azimuthal Equidistant"
    );
    assert!(epsg_targets.contains(&3993), "missing Guam Projection");
    assert!(
        epsg_targets.contains(&3295),
        "missing Modified Azimuthal Equidistant"
    );
    assert!(
        epsg_targets.contains(&22780),
        "missing grad-unit Levant Stereographic"
    );
    assert!(
        epsg_targets.contains(&8441),
        "missing Laborde Oblique Mercator"
    );

    // Verify UTM coverage
    let has_utm = epsg_targets.iter().any(|e| (32601..=32660).contains(e));
    assert!(has_utm, "missing UTM north zones");
    let has_utm_south = epsg_targets.iter().any(|e| (32701..=32760).contains(e));
    assert!(has_utm_south, "missing UTM south zones");

    // Verify datum shift coverage
    let from_epsgs: Vec<u32> = corpus.iter().map(|r| r.from_epsg).collect();
    assert!(from_epsgs.contains(&4267), "missing NAD27 datum shift");
    assert!(from_epsgs.contains(&4277), "missing OSGB36 datum shift");
    assert!(from_epsgs.contains(&4230), "missing ED50 datum shift");

    // Verify inverse transforms
    assert!(from_epsgs.contains(&3857), "missing 3857→4326 inverse");
    assert!(from_epsgs.contains(&3413), "missing 3413→4326 inverse");

    // Verify 3D coverage (promoted 3D CRS references)
    assert!(
        corpus.iter().any(|r| r.input_z.is_some()),
        "missing 3D reference points"
    );
    // Verify near-pole and wrong-hemisphere edge coverage
    assert!(
        corpus.iter().any(|r| r.input_y.abs() > 89.99),
        "missing near-pole points"
    );

    // Verify corpus size is substantial
    assert!(
        corpus.len() >= 100,
        "corpus too small: {} points",
        corpus.len()
    );

    eprintln!("Corpus coverage: {} reference points", corpus.len());
}