polyops 0.0.7

Fast Martinez-Rueda polygon clipping: Boolean operations (intersection, union, difference, xor) on polygons and multipolygons over GeoJSON-shaped coordinates. Pure-Rust port of martinez-polygon-clipping.
Documentation
//! Parity tests against `martinez-polygon-clipping@0.8.1` upstream.
//!
//! Goldens are generated by `parity/generate-goldens.ts` (run from the
//! repo's `parity/` Node project) and committed under `tests/goldens/`.
//! Each `.json` file under `tests/goldens/{operation}/` carries the
//! subject + clipping coordinate arrays plus the expected
//! `MultiPolygon` (or `null`) that upstream returned.
//!
//! As of the initial parity lock, all four `parity_*` tests run on
//! every `cargo test` and gate CI. Drop them at your peril — they're
//! the proof that polyops behaves like upstream.

use std::fs;
use std::path::{Path, PathBuf};

use polyops::{difference, intersection, union, xor, Geometry, MultiPolygon};
use serde::Deserialize;

/*
 * Golden file shape — matches what parity/generate-goldens.ts emits.
 */
#[derive(Deserialize)]
struct Golden {
    /* GeoJSON `Polygon` (`[[[x,y],...]]`) or `MultiPolygon` (`[[[[x,y],...]]]`) coordinates. */
    subject: serde_json::Value,
    clipping: serde_json::Value,
    /* Upstream's return: a `MultiPolygon` or `null`. */
    expected: Option<MultiPolygon>,
}

/*
 * Internal helpers — alphabetical.
 */

fn coords_to_geometry(value: serde_json::Value) -> Geometry {
    /*
     * GeoJSON discriminates Polygon vs MultiPolygon by nesting depth.
     * A Polygon is [[[x,y], ...]]; a MultiPolygon is [[[[x,y], ...]]].
     */
    if depth(&value) >= 4 {
        let mp: MultiPolygon = serde_json::from_value(value).expect("multipolygon coords");
        Geometry::MultiPolygon(mp)
    } else {
        let p: Vec<Vec<[f64; 2]>> = serde_json::from_value(value).expect("polygon coords");
        Geometry::Polygon(p)
    }
}

fn depth(value: &serde_json::Value) -> usize {
    let mut d = 0;
    let mut cur = value;
    while let serde_json::Value::Array(arr) = cur {
        d += 1;
        match arr.first() {
            Some(next) => cur = next,
            None => break,
        }
    }
    d
}

fn goldens_dir(operation: &str) -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("goldens")
        .join(operation)
}

fn multipolygon_close(actual: &MultiPolygon, expected: &MultiPolygon, eps: f64) -> bool {
    if actual.len() != expected.len() {
        return false;
    }
    actual
        .iter()
        .zip(expected.iter())
        .all(|(pa, pe)| polygon_close(pa, pe, eps))
}

fn polygon_close(a: &[Vec<[f64; 2]>], b: &[Vec<[f64; 2]>], eps: f64) -> bool {
    if a.len() != b.len() {
        return false;
    }
    a.iter()
        .zip(b.iter())
        .all(|(ra, rb)| ring_close(ra, rb, eps))
}

fn ring_close(a: &[[f64; 2]], b: &[[f64; 2]], eps: f64) -> bool {
    if a.len() != b.len() {
        return false;
    }
    a.iter()
        .zip(b.iter())
        .all(|(pa, pb)| (pa[0] - pb[0]).abs() <= eps && (pa[1] - pb[1]).abs() <= eps)
}

fn run_parity(operation: &str, op: fn(Geometry, Geometry) -> Option<MultiPolygon>) {
    /*
     * Coordinate-equality tolerance. 1e-10 is tight enough to catch real
     * divergences but loose enough to absorb sum-of-products ordering
     * differences between JS and Rust floating-point.
     */
    const EPS: f64 = 1e-10;

    let dir = goldens_dir(operation);
    assert!(
        dir.exists(),
        "goldens for {operation} not found at {dir:?} — run `cd parity && npm install && npm run generate`",
    );

    let mut failures: Vec<String> = Vec::new();
    for entry in fs::read_dir(&dir).expect("read goldens dir") {
        let entry = entry.expect("dir entry");
        if entry.path().extension().and_then(|s| s.to_str()) != Some("json") {
            continue;
        }
        let name = entry
            .path()
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("<unknown>")
            .to_owned();

        let raw = fs::read_to_string(entry.path()).expect("read golden");
        let golden: Golden = serde_json::from_str(&raw).expect("parse golden");
        let subject = coords_to_geometry(golden.subject);
        let clipping = coords_to_geometry(golden.clipping);

        let actual = op(subject, clipping);
        match (actual, golden.expected) {
            (None, None) => {}
            (Some(a), Some(e)) if multipolygon_close(&a, &e, EPS) => {}
            (a, e) => {
                failures.push(format!("{name}: got {a:?}, expected {e:?}"));
            }
        }
    }

    assert!(
        failures.is_empty(),
        "{} {operation} parity failure(s):\n{}",
        failures.len(),
        failures.join("\n"),
    );
}

/*
 * Test entry points — one per operation. All `#[ignore]`d until the
 * algorithm is implemented; drop the attribute to gate CI on parity.
 */

#[test]
fn parity_intersection() {
    run_parity("intersection", intersection);
}

#[test]
fn parity_union() {
    run_parity("union", union);
}

#[test]
fn parity_difference() {
    run_parity("difference", difference);
}

#[test]
fn parity_xor() {
    run_parity("xor", xor);
}