s2rst 0.4.0

A Rust port of Google's S2 spherical geometry library — points, regions, shapes, and a hierarchical cell index on the sphere.
Documentation
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 Torgeir Børresen <tb@starkad.no>
// Written for this crate (not ported from upstream S2).

//! Differential harness for the `graph_edge_clipper` boolean-op regression
//! cases (see `BUG.md` §2). Replays the inputs in
//! `tests/data/boolean_op_inputs.txt` through the Rust boolean-op pipeline and
//! compares the outcome against the C++ reference
//! (`tests/data/cpp_boolean_op_vectors.txt`, generated by
//! `tools/refgen-cpp/generate_boolean_op.cc` from google/s2geometry). CI runs
//! this with no C++ toolchain — the committed vectors are the oracle.
//!
//! The upstream union is non-empty for every case, so a Rust case "matches"
//! when it also returns a non-empty polygon. The rest currently panic (debug
//! `debug_assert!`) or return empty — the known clipper bug. As the bug is
//! fixed, more cases flip to matching; bump `MIN_MATCHES` so progress cannot
//! regress.

#![allow(
    clippy::print_stderr,
    reason = "diagnostic harness: prints a per-case comparison table"
)]

use std::panic::{self, AssertUnwindSafe};

use s2rst::r3::Vector;
use s2rst::s2::{Loop, Point, Polygon};

const INPUTS: &str = concat!(
    env!("CARGO_MANIFEST_DIR"),
    "/tests/data/boolean_op_inputs.txt"
);
const ORACLE: &str = concat!(
    env!("CARGO_MANIFEST_DIR"),
    "/tests/data/cpp_boolean_op_vectors.txt"
);

/// Lower bound on how many cases must match the C++ oracle. The
/// `left_to_right` robustness fix plus the hole-orientation fix (BUG.md §2)
/// brought this to 13/13. This guards against regressions.
const MIN_MATCHES: usize = 13;

struct Case {
    name: String,
    op: String,
    normalize: bool,
    a: Vec<Vec<[f64; 3]>>,
    b: Vec<Vec<[f64; 3]>>,
}

struct CppResult {
    empty: bool,
    loops: usize,
    vertices: usize,
}

fn parse_inputs(text: &str) -> Vec<Case> {
    let mut toks = text
        .lines()
        .filter(|l| !l.trim_start().starts_with('#'))
        .flat_map(str::split_whitespace);
    let mut cases = Vec::new();
    while let Some(tok) = toks.next() {
        assert_eq!(tok, "CASE");
        let name = toks.next().unwrap().to_string();
        let op = toks.next().unwrap().to_string();
        let normalize = toks.next().unwrap() == "1";
        let operand = |toks: &mut dyn Iterator<Item = &str>| {
            let _label = toks.next().unwrap(); // "A" / "B"
            let num_loops: usize = toks.next().unwrap().parse().unwrap();
            let mut loops = Vec::with_capacity(num_loops);
            for _ in 0..num_loops {
                assert_eq!(toks.next().unwrap(), "LOOP");
                let nv: usize = toks.next().unwrap().parse().unwrap();
                let mut verts = Vec::with_capacity(nv);
                for _ in 0..nv {
                    let x = toks.next().unwrap().parse().unwrap();
                    let y = toks.next().unwrap().parse().unwrap();
                    let z = toks.next().unwrap().parse().unwrap();
                    verts.push([x, y, z]);
                }
                loops.push(verts);
            }
            loops
        };
        let a = operand(&mut toks);
        let b = operand(&mut toks);
        assert_eq!(toks.next().unwrap(), "END");
        cases.push(Case {
            name,
            op,
            normalize,
            a,
            b,
        });
    }
    cases
}

fn parse_oracle(text: &str) -> std::collections::HashMap<String, CppResult> {
    let mut map = std::collections::HashMap::new();
    let mut lines = text.lines().filter(|l| !l.trim_start().starts_with('#'));
    while let Some(case_line) = lines.next() {
        let mut it = case_line.split_whitespace();
        assert_eq!(it.next(), Some("CASE"));
        let name = it.next().unwrap().to_string();
        let result_line = lines.next().unwrap();
        let kv = |key: &str| -> &str {
            result_line
                .split_whitespace()
                .find_map(|t| t.strip_prefix(key))
                .unwrap()
        };
        map.insert(
            name,
            CppResult {
                empty: kv("empty=") == "1",
                loops: kv("loops=").parse().unwrap(),
                vertices: kv("vertices=").parse().unwrap(),
            },
        );
    }
    map
}

fn build(loops: &[Vec<[f64; 3]>], normalize: bool) -> Polygon {
    let rust_loops = loops
        .iter()
        .map(|verts| {
            let pts = verts
                .iter()
                .map(|&[x, y, z]| {
                    if normalize {
                        Point::from_coords(x, y, z)
                    } else {
                        Point::new(Vector::new(x, y, z))
                    }
                })
                .collect();
            Loop::new(pts)
        })
        .collect();
    Polygon::from_loops(rust_loops)
}

/// Rust outcome for one case.
enum Outcome {
    Panic,
    Empty,
    NonEmpty { loops: usize, vertices: usize },
}

fn run_case(case: &Case) -> Outcome {
    let result = panic::catch_unwind(AssertUnwindSafe(|| {
        let mut a = build(&case.a, case.normalize);
        let mut b = build(&case.b, case.normalize);
        match case.op.as_str() {
            "union" => Polygon::union(&mut a, &mut b),
            "intersection" => Polygon::intersection(&mut a, &mut b),
            "difference" => Polygon::difference(&mut a, &mut b),
            _ => Polygon::symmetric_difference(&mut a, &mut b),
        }
    }));
    match result {
        Err(_) => Outcome::Panic,
        Ok(poly) if poly.is_empty_polygon() => Outcome::Empty,
        Ok(poly) => Outcome::NonEmpty {
            loops: poly.num_loops(),
            vertices: poly.num_vertices(),
        },
    }
}

#[test]
fn boolean_op_matches_cpp_reference() {
    let cases = parse_inputs(&std::fs::read_to_string(INPUTS).expect("read inputs"));
    let oracle = parse_oracle(&std::fs::read_to_string(ORACLE).expect("read oracle"));
    assert_eq!(cases.len(), 13, "expected 13 regression cases");

    // Suppress the per-panic backtrace spam from the cases that still abort.
    let prev_hook = panic::take_hook();
    panic::set_hook(Box::new(|_| {}));

    let mut matches = 0;
    let mut rows = Vec::new();
    for case in &cases {
        let cpp = &oracle[&case.name];
        let outcome = run_case(case);
        let (rust_str, ok) = match &outcome {
            Outcome::Panic => ("PANIC".to_string(), false),
            Outcome::Empty => ("empty".to_string(), false),
            Outcome::NonEmpty { loops, vertices } => {
                (format!("nonempty {loops}L/{vertices}v"), !cpp.empty)
            }
        };
        if ok {
            matches += 1;
        }
        rows.push(format!(
            "  {:<34} {:<6} cpp={}L/{}v  rust={:<18} {}",
            case.name,
            case.op,
            cpp.loops,
            cpp.vertices,
            rust_str,
            if ok { "MATCH" } else { "DIFF" }
        ));
    }

    panic::set_hook(prev_hook);

    eprintln!(
        "\nBoolean-op vs C++ oracle (upstream union is non-empty for all {}):",
        cases.len()
    );
    for row in &rows {
        eprintln!("{row}");
    }
    eprintln!(
        "matches: {}/{} (floor {MIN_MATCHES})\n",
        matches,
        cases.len()
    );

    assert!(
        matches >= MIN_MATCHES,
        "boolean-op/C++ matches regressed: {matches} < {MIN_MATCHES}"
    );
}