Files
OneCAD-Simulation/crates/mesh/src/predicates/tests.rs
T
4667655e08 mesh: exact predicates (Shewchuk adaptive, symbolic perturbation, indirect LPI/TPI) (#8)
* mesh: exact predicates (Shewchuk adaptive orient/incircle/insphere), symbolic perturbation, indirect LPI/TPI predicates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* mesh: predicate benchmark (filter hit rate, CAD-degenerate inputs); record the predicate decision

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* mesh: simplify predicates (destructuring over fallbacks, shared-minor exact insphere, cheaper expansion product)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:04:56 -04:00

608 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Differential tests against independent oracles: exact rationals (`num-rational`, Leibniz
//! determinants written here from the definitions), `i128` on integer grids, and georust's
//! `robust` (a port of Shewchuk's public-domain `predicates.c`).
use std::cmp::Ordering;
use num_bigint::BigInt;
use num_rational::BigRational;
use num_traits::{FromPrimitive, Signed as _, ToPrimitive, Zero};
use super::adaptive::exact;
use super::*;
// ---------------------------------------------------------------------------------------------
// Oracles and generators
// ---------------------------------------------------------------------------------------------
fn q(x: f64) -> BigRational {
BigRational::from_f64(x).unwrap()
}
fn rsign(x: &BigRational) -> Sign {
if x.is_zero() {
Sign::Zero
} else if x.is_positive() {
Sign::Positive
} else {
Sign::Negative
}
}
/// Laplace expansion along the first row.
fn rdet(m: &[Vec<BigRational>]) -> BigRational {
if m.len() == 1 {
return m[0][0].clone();
}
let mut total = BigRational::zero();
for j in 0..m.len() {
let minor: Vec<Vec<BigRational>> =
m[1..].iter().map(|row| row.iter().enumerate().filter(|(k, _)| *k != j).map(|(_, v)| v.clone()).collect()).collect();
let term = m[0][j].clone() * rdet(&minor);
if j % 2 == 0 { total += term } else { total -= term }
}
total
}
fn rdiff(p: &[f64], o: &[f64]) -> Vec<BigRational> {
p.iter().zip(o).map(|(a, b)| q(*a) - q(*b)).collect()
}
fn lifted(mut row: Vec<BigRational>) -> Vec<BigRational> {
let lift = row.iter().fold(BigRational::zero(), |acc, v| acc + v.clone() * v.clone());
row.push(lift);
row
}
fn o2(a: &Point2, b: &Point2, c: &Point2) -> Sign {
rsign(&rdet(&[rdiff(&a.0, &c.0), rdiff(&b.0, &c.0)]))
}
fn o3(a: &Point3, b: &Point3, c: &Point3, d: &Point3) -> Sign {
rsign(&rdet(&[rdiff(&a.0, &d.0), rdiff(&b.0, &d.0), rdiff(&c.0, &d.0)]))
}
fn ic(a: &Point2, b: &Point2, c: &Point2, d: &Point2) -> Sign {
rsign(&rdet(&[lifted(rdiff(&a.0, &d.0)), lifted(rdiff(&b.0, &d.0)), lifted(rdiff(&c.0, &d.0))]))
}
fn is(a: &Point3, b: &Point3, c: &Point3, d: &Point3, e: &Point3) -> Sign {
rsign(&rdet(&[lifted(rdiff(&a.0, &e.0)), lifted(rdiff(&b.0, &e.0)), lifted(rdiff(&c.0, &e.0)), lifted(rdiff(&d.0, &e.0))]))
}
/// SplitMix64: a deterministic generator for reproducible tests.
struct Rng(u64);
impl Rng {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Uniform in [-1, 1).
fn unit(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 52) as f64 - 1.0
}
fn int(&mut self, n: i64) -> i64 {
(self.next_u64() % (2 * n as u64 + 1)) as i64 - n
}
/// `x` moved by up to `k` ulps either way.
fn nudge(&mut self, mut x: f64, k: i64) -> f64 {
let steps = self.int(k);
for _ in 0..steps.abs() {
x = if steps > 0 { x.next_up() } else { x.next_down() };
}
x
}
}
fn p2(x: f64, y: f64) -> Point2 {
Point2::new(x, y).unwrap()
}
fn p3(x: f64, y: f64, z: f64) -> Point3 {
Point3::new(x, y, z).unwrap()
}
fn rc2(p: &Point2) -> robust::Coord<f64> {
robust::Coord { x: p.0[0], y: p.0[1] }
}
fn rc3(p: &Point3) -> robust::Coord3D<f64> {
robust::Coord3D { x: p.0[0], y: p.0[1], z: p.0[2] }
}
/// Every stage agrees with every oracle, and the filter, when it answers, agrees too.
fn check2(a: &Point2, b: &Point2, c: &Point2, d: &Point2) {
let want = o2(a, b, c);
assert_eq!(orient2d(a, b, c), want, "orient2d {a:?} {b:?} {c:?}");
assert_eq!(exact::orient2d(a, b, c), want);
assert_eq!(Sign::of(robust::orient2d(rc2(a), rc2(b), rc2(c))), want);
assert!(orient2d_filter(a, b, c).is_none_or(|s| s == want));
let want = ic(a, b, c, d);
assert_eq!(incircle(a, b, c, d), want, "incircle {a:?} {b:?} {c:?} {d:?}");
assert_eq!(exact::incircle(a, b, c, d), want);
assert_eq!(Sign::of(robust::incircle(rc2(a), rc2(b), rc2(c), rc2(d))), want);
assert!(incircle_filter(a, b, c, d).is_none_or(|s| s == want));
}
fn check3(a: &Point3, b: &Point3, c: &Point3, d: &Point3, e: &Point3) {
let want = o3(a, b, c, d);
assert_eq!(orient3d(a, b, c, d), want, "orient3d {a:?} {b:?} {c:?} {d:?}");
assert_eq!(exact::orient3d(a, b, c, d), want);
assert_eq!(Sign::of(robust::orient3d(rc3(a), rc3(b), rc3(c), rc3(d))), want);
assert!(orient3d_filter(a, b, c, d).is_none_or(|s| s == want));
let want = is(a, b, c, d, e);
assert_eq!(insphere(a, b, c, d, e), want, "insphere {a:?} {b:?} {c:?} {d:?} {e:?}");
assert_eq!(exact::insphere(a, b, c, d, e), want);
assert_eq!(Sign::of(robust::insphere(rc3(a), rc3(b), rc3(c), rc3(d), rc3(e))), want);
assert!(insphere_filter(a, b, c, d, e).is_none_or(|s| s == want));
}
// ---------------------------------------------------------------------------------------------
// Explicit predicates
// ---------------------------------------------------------------------------------------------
#[test]
fn random_points_match_the_oracles() {
let mut r = Rng(1);
for _ in 0..300 {
let pt2 = |r: &mut Rng| p2(r.unit(), r.unit());
let (a, b, c, d) = (pt2(&mut r), pt2(&mut r), pt2(&mut r), pt2(&mut r));
check2(&a, &b, &c, &d);
let pt3 = |r: &mut Rng| p3(r.unit(), r.unit(), r.unit());
let (a, b, c, d, e) = (pt3(&mut r), pt3(&mut r), pt3(&mut r), pt3(&mut r), pt3(&mut r));
check3(&a, &b, &c, &d, &e);
}
}
#[test]
fn near_degenerate_points_match_the_oracles() {
// Points on a line, plane, circle or sphere, rounded and then moved by a few ulps: the
// filter fails and every adaptive stage is exercised.
let mut r = Rng(2);
for i in 0..400 {
let k = if i % 4 == 0 { 0 } else { 2 };
let scale = [1.0, 1e-3, 1e5, 3.0e-20][i % 4];
let (ax, ay, bx, by) = (r.unit() * scale, r.unit() * scale, r.unit() * scale, r.unit() * scale);
let t = r.unit();
let c = p2(r.nudge(ax + t * (bx - ax), k), r.nudge(ay + t * (by - ay), k));
let (a, b) = (p2(ax, ay), p2(bx, by));
let on_circle = |r: &mut Rng| {
let th = r.unit() * 3.2;
p2(r.nudge(scale * th.cos() + scale * 0.25, k), r.nudge(scale * th.sin() - scale * 0.5, k))
};
let (ca, cb, cc, cd) = (on_circle(&mut r), on_circle(&mut r), on_circle(&mut r), on_circle(&mut r));
check2(&a, &b, &c, &c);
check2(&ca, &cb, &cc, &cd);
let pt = |r: &mut Rng| [r.unit() * scale, r.unit() * scale, r.unit() * scale];
let (a, b, c) = (pt(&mut r), pt(&mut r), pt(&mut r));
let (s, t) = (r.unit(), r.unit());
let d: Vec<f64> = (0..3).map(|j| r.nudge(a[j] + s * (b[j] - a[j]) + t * (c[j] - a[j]), k)).collect();
let on_sphere = |r: &mut Rng| {
let (th, ph) = (r.unit() * 3.2, r.unit() * 1.6);
p3(r.nudge(scale * ph.cos() * th.cos() + scale, k), r.nudge(scale * ph.cos() * th.sin(), k), r.nudge(scale * ph.sin() - scale * 0.5, k))
};
let (a, b, c, d) = (p3(a[0], a[1], a[2]), p3(b[0], b[1], b[2]), p3(c[0], c[1], c[2]), p3(d[0], d[1], d[2]));
let s: Vec<Point3> = (0..5).map(|_| on_sphere(&mut r)).collect();
check3(&a, &b, &c, &d, &s[0]);
check3(&s[0], &s[1], &s[2], &s[3], &s[4]);
}
}
#[test]
fn ulp_grid_near_a_line_matches_the_oracle() {
// The classic failure of naive orient2d (Kettner et al., "Classroom examples of robustness
// problems"): p on a 64×64 grid of ulps around (0.5, 0.5), q = (12, 12), r = (24, 24).
// Variants with points off the diagonal make the coordinate differences inexact.
let ulp = 2f64.powi(-53);
for (q2, r2) in [(p2(12.0, 12.0), p2(24.0, 24.0)), (p2(0.1, 0.3), p2(17.3, 0.7)), (p2(1e-9, 3.0), p2(0.9, 1e-7))] {
for i in 0..64 {
for j in 0..64 {
let p = p2(0.5 + i as f64 * ulp, 0.5 + j as f64 * ulp);
let want = o2(&p, &q2, &r2);
assert_eq!(orient2d(&p, &q2, &r2), want);
assert_eq!(orient2d(&q2, &r2, &p), want);
assert_eq!(orient2d(&r2, &p, &q2), want);
}
}
}
}
fn i128_det(m: &[Vec<i128>]) -> i128 {
if m.len() == 1 {
return m[0][0];
}
(0..m.len())
.map(|j| {
let minor: Vec<Vec<i128>> = m[1..].iter().map(|row| row.iter().enumerate().filter(|(k, _)| *k != j).map(|(_, v)| *v).collect()).collect();
let t = m[0][j] * i128_det(&minor);
if j % 2 == 0 { t } else { -t }
})
.sum()
}
#[test]
fn integer_grids_match_i128() {
// Small grids are full of exact degeneracies (collinear, coplanar, cocircular,
// cospherical); large coordinates (2^30 for orient, 2^14 for in-sphere) stress the filter.
let mut r = Rng(3);
for &(n, big) in &[(2i64, 1i64), (3, 1 << 30), (3, 1 << 14)] {
for _ in 0..400 {
let v: Vec<[i64; 3]> = (0..5).map(|_| [r.int(n) * big, r.int(n) * big, r.int(n) * big]).collect();
let pt = |p: &[i64; 3]| p3(p[0] as f64, p[1] as f64, p[2] as f64);
let d = |p: &[i64; 3], o: &[i64; 3]| (0..3).map(|k| (p[k] - o[k]) as i128).collect::<Vec<_>>();
let o3 = i128_det(&[d(&v[0], &v[3]), d(&v[1], &v[3]), d(&v[2], &v[3])]);
assert_eq!(orient3d(&pt(&v[0]), &pt(&v[1]), &pt(&v[2]), &pt(&v[3])), Sign::of(o3 as f64));
let o2v = |p: &[i64; 3]| p2(p[0] as f64, p[1] as f64);
let d2 = |p: &[i64; 3], o: &[i64; 3]| d(p, o)[..2].to_vec();
let want = i128_det(&[d2(&v[0], &v[2]), d2(&v[1], &v[2])]);
assert_eq!(orient2d(&o2v(&v[0]), &o2v(&v[1]), &o2v(&v[2])), Sign::of(want as f64));
if big <= 1 << 14 || n == 2 {
let lift = |mut row: Vec<i128>| {
row.push(row.iter().map(|x| x * x).sum());
row
};
let s = i128_det(&[lift(d(&v[0], &v[4])), lift(d(&v[1], &v[4])), lift(d(&v[2], &v[4])), lift(d(&v[3], &v[4]))]);
assert_eq!(insphere(&pt(&v[0]), &pt(&v[1]), &pt(&v[2]), &pt(&v[3]), &pt(&v[4])), Sign::of(s as f64));
let c = i128_det(&[lift(d2(&v[0], &v[3])), lift(d2(&v[1], &v[3])), lift(d2(&v[2], &v[3]))]);
assert_eq!(incircle(&o2v(&v[0]), &o2v(&v[1]), &o2v(&v[2]), &o2v(&v[3])), Sign::of(c as f64));
}
}
}
}
#[test]
fn extreme_magnitudes_inside_the_domain_stay_exact() {
// Coordinates at both ends of the domain, mixed, near-degenerate: no overflow or underflow
// may corrupt a sign.
let mut r = Rng(4);
let tiny = MIN_MAGNITUDE;
let huge = MAX_MAGNITUDE;
for i in 0..150 {
let s = [tiny, huge, 1.0, tiny * 1e10][i % 4];
let pick = |r: &mut Rng| {
let m = r.unit();
if m.abs() < 0.2 { 0.0 } else { (m * s).clamp(-huge, huge) }
};
let fix = |x: f64| if is_admissible(x) { x } else { 0.0 };
let v: Vec<Point3> = (0..5)
.map(|_| {
let (x, y, z) = (pick(&mut r), pick(&mut r), pick(&mut r));
p3(fix(r.nudge(x, 1)), fix(y), fix(z))
})
.collect();
check3(&v[0], &v[1], &v[2], &v[3], &v[4]);
// Collinear and coplanar with huge and tiny offsets mixed.
let base = p3(huge / 2.0, tiny, 0.0);
let shifted = p3(huge / 2.0, tiny * 2.0, tiny);
check3(&base, &shifted, &v[2], &v[3], &v[4]);
}
}
#[test]
fn permutations_follow_the_determinant() {
let mut r = Rng(5);
for _ in 0..200 {
// Grid points so that zero results also occur.
let pt = |r: &mut Rng| p3(r.int(2) as f64, r.int(2) as f64, r.int(2) as f64 * 0.5);
let (a, b, c, d, e) = (pt(&mut r), pt(&mut r), pt(&mut r), pt(&mut r), pt(&mut r));
let s = orient3d(&a, &b, &c, &d);
assert_eq!(orient3d(&b, &a, &c, &d), -s);
assert_eq!(orient3d(&a, &b, &d, &c), -s);
assert_eq!(orient3d(&b, &c, &a, &d), s);
assert_eq!(orient3d(&b, &c, &d, &a), -s); // a 4-cycle is odd
let s = insphere(&a, &b, &c, &d, &e);
assert_eq!(insphere(&b, &a, &c, &d, &e), -s);
assert_eq!(insphere(&a, &b, &c, &e, &d), -s);
assert_eq!(insphere(&c, &a, &b, &d, &e), s);
let (a2, b2, c2, d2) = (p2(a.0[0], a.0[1]), p2(b.0[0], b.0[1]), p2(c.0[0], c.0[1]), p2(d.0[0], d.0[2]));
let s = orient2d(&a2, &b2, &c2);
assert_eq!(orient2d(&b2, &a2, &c2), -s);
assert_eq!(orient2d(&b2, &c2, &a2), s);
let s = incircle(&a2, &b2, &c2, &d2);
assert_eq!(incircle(&b2, &a2, &c2, &d2), -s);
assert_eq!(incircle(&a2, &b2, &d2, &c2), -s);
}
}
#[test]
fn conventions_match_the_documentation() {
let (o, x, y, z) = (p3(0.0, 0.0, 0.0), p3(1.0, 0.0, 0.0), p3(0.0, 1.0, 0.0), p3(0.0, 0.0, 1.0));
// o, x, y counterclockwise seen from +z; a point below gives Positive.
assert_eq!(orient3d(&o, &x, &y, &p3(0.0, 0.0, -1.0)), Sign::Positive);
assert_eq!(orient3d(&o, &x, &y, &z), Sign::Negative);
let tet = [o, x, y, p3(0.0, 0.0, -1.0)];
assert_eq!(insphere(&tet[0], &tet[1], &tet[2], &tet[3], &p3(0.1, 0.1, -0.1)), Sign::Positive);
assert_eq!(insphere(&tet[0], &tet[1], &tet[2], &tet[3], &p3(5.0, 5.0, 5.0)), Sign::Negative);
assert_eq!(insphere(&tet[0], &tet[1], &tet[2], &tet[3], &p3(1.0, 1.0, 0.0)), Sign::Zero);
let (a, b, c) = (p2(0.0, 0.0), p2(1.0, 0.0), p2(0.0, 1.0));
assert_eq!(orient2d(&a, &b, &c), Sign::Positive);
assert_eq!(incircle(&a, &b, &c, &p2(0.5, 0.5)), Sign::Positive);
assert_eq!(incircle(&a, &b, &c, &p2(1.0, 1.0)), Sign::Zero);
}
#[test]
fn the_domain_is_enforced_at_construction() {
for bad in [f64::NAN, f64::INFINITY, -f64::INFINITY, 1e-40, -1e-300, 5e-324, 1e49, f64::MAX] {
assert!(Point3::new(0.0, bad, 1.0).is_err(), "{bad}");
assert!(Point2::try_from([bad, 0.0]).is_err(), "{bad}");
}
for good in [0.0, -0.0, MIN_MAGNITUDE, -MAX_MAGNITUDE, 1e-36, 1e48] {
assert!(Point3::new(good, good, good).is_ok(), "{good}");
}
}
#[test]
fn hostile_bit_patterns_never_panic() {
let mut r = Rng(6);
let mut accepted = Vec::new();
for _ in 0..20_000 {
let c = [f64::from_bits(r.next_u64()), f64::from_bits(r.next_u64()), f64::from_bits(r.next_u64())];
if let Ok(p) = Point3::try_from(c) {
accepted.push(p);
}
}
accepted.push(p3(0.0, 0.0, 0.0));
for w in accepted.windows(5).take(200) {
let s = orient3d(&w[0], &w[1], &w[2], &w[3]);
assert_eq!(s, o3(&w[0], &w[1], &w[2], &w[3]));
let s = insphere(&w[0], &w[1], &w[2], &w[3], &w[4]);
assert_eq!(s, is(&w[0], &w[1], &w[2], &w[3], &w[4]));
let _ = insphere_sos(&w[0], &w[1], &w[2], &w[3], &w[4]);
}
}
// ---------------------------------------------------------------------------------------------
// Symbolic perturbation
// ---------------------------------------------------------------------------------------------
/// The perturbed determinant evaluated with an actual tiny ε, lifts `|p|² + ε^rank` (rank 1 for
/// the lexicographically largest point): an independent check of the cofactor signs.
fn perturbed_sign(points: &[Vec<f64>]) -> Sign {
let mut order: Vec<usize> = (0..points.len()).collect();
order.sort_by(|&i, &j| points[j].partial_cmp(&points[i]).unwrap_or(Ordering::Equal));
let eps = BigRational::new(BigInt::from(1), BigInt::from(1u64 << 40));
let rows: Vec<Vec<BigRational>> = points
.iter()
.enumerate()
.map(|(i, p)| {
let rank = order.iter().position(|&k| k == i).unwrap() + 1;
let mut row = lifted(p.iter().map(|x| q(*x)).collect());
let mut e = BigRational::from_integer(BigInt::from(1));
for _ in 0..rank {
e *= eps.clone();
}
if let Some(lift) = row.last_mut() {
*lift += e;
}
row.push(BigRational::from_integer(BigInt::from(1)));
row
})
.collect();
rsign(&rdet(&rows))
}
#[test]
fn perturbed_insphere_matches_an_infinitesimal_lift() {
let mut r = Rng(7);
let mut zeros_resolved = 0;
for _ in 0..600 {
let v: Vec<Vec<f64>> = (0..5).map(|_| vec![r.int(1) as f64, r.int(1) as f64, r.int(1) as f64]).collect();
if (0..5).any(|i| (0..i).any(|j| v[i] == v[j])) {
continue;
}
let p: Vec<Point3> = v.iter().map(|c| p3(c[0], c[1], c[2])).collect();
if orient3d(&p[0], &p[1], &p[2], &p[3]).is_zero() {
continue;
}
let got = insphere_sos(&p[0], &p[1], &p[2], &p[3], &p[4]);
assert_ne!(got, Sign::Zero);
assert_eq!(got, perturbed_sign(&v), "{v:?}");
zeros_resolved += insphere(&p[0], &p[1], &p[2], &p[3], &p[4]).is_zero() as usize;
}
assert!(zeros_resolved > 20, "the grid must produce cospherical cases ({zeros_resolved})");
}
#[test]
fn perturbed_incircle_matches_an_infinitesimal_lift() {
let mut r = Rng(8);
let mut zeros_resolved = 0;
for _ in 0..600 {
let v: Vec<Vec<f64>> = (0..4).map(|_| vec![r.int(1) as f64, r.int(1) as f64]).collect();
if (0..4).any(|i| (0..i).any(|j| v[i] == v[j])) {
continue;
}
let p: Vec<Point2> = v.iter().map(|c| p2(c[0], c[1])).collect();
if orient2d(&p[0], &p[1], &p[2]).is_zero() {
continue;
}
let got = incircle_sos(&p[0], &p[1], &p[2], &p[3]);
assert_ne!(got, Sign::Zero);
assert_eq!(got, perturbed_sign(&v), "{v:?}");
zeros_resolved += incircle(&p[0], &p[1], &p[2], &p[3]).is_zero() as usize;
}
assert!(zeros_resolved > 20, "{zeros_resolved}");
}
// ---------------------------------------------------------------------------------------------
// Indirect predicates
// ---------------------------------------------------------------------------------------------
type RPoint = [BigRational; 3];
fn rp(p: &Point3) -> RPoint {
p.0.map(q)
}
fn rsub(a: &RPoint, b: &RPoint) -> RPoint {
std::array::from_fn(|k| a[k].clone() - b[k].clone())
}
fn rdot(a: &RPoint, b: &RPoint) -> BigRational {
(0..3).fold(BigRational::zero(), |acc, k| acc + a[k].clone() * b[k].clone())
}
fn rcross(a: &RPoint, b: &RPoint) -> RPoint {
[
a[1].clone() * b[2].clone() - a[2].clone() * b[1].clone(),
a[2].clone() * b[0].clone() - a[0].clone() * b[2].clone(),
a[0].clone() * b[1].clone() - a[1].clone() * b[0].clone(),
]
}
/// Line–plane intersection by the parametric form (independent of the determinant formula).
fn r_lpi(q1: &Point3, q2: &Point3, r: &Point3, s: &Point3, t: &Point3) -> RPoint {
let (q1, q2, r, s, t) = (rp(q1), rp(q2), rp(r), rp(s), rp(t));
let n = rcross(&rsub(&s, &r), &rsub(&t, &r));
let dir = rsub(&q2, &q1);
let u = rdot(&n, &rsub(&r, &q1)) / rdot(&n, &dir);
std::array::from_fn(|k| q1[k].clone() + u.clone() * dir[k].clone())
}
/// Three-plane intersection by Gaussian elimination.
fn r_tpi(planes: &[[Point3; 3]; 3]) -> RPoint {
let mut m: Vec<Vec<BigRational>> = planes
.iter()
.map(|[r, s, t]| {
let (r, s, t) = (rp(r), rp(s), rp(t));
let n = rcross(&rsub(&s, &r), &rsub(&t, &r));
let c = rdot(&n, &r);
vec![n[0].clone(), n[1].clone(), n[2].clone(), c]
})
.collect();
for col in 0..3 {
let piv = (col..3).find(|&i| !m[i][col].is_zero()).unwrap();
m.swap(col, piv);
for i in 0..3 {
if i != col {
let f = m[i][col].clone() / m[col][col].clone();
let pivot_row = m[col].clone();
for (cell, p) in m[i].iter_mut().zip(&pivot_row) {
*cell -= p.clone() * f.clone();
}
}
}
}
std::array::from_fn(|k| m[k][3].clone() / m[k][k].clone())
}
fn r_orient3d(p: &[RPoint; 4]) -> Sign {
let rows: Vec<Vec<BigRational>> = (0..3).map(|i| rsub(&p[i], &p[3]).to_vec()).collect();
rsign(&rdet(&rows))
}
fn rand_pt(r: &mut Rng, scale: f64) -> Point3 {
p3(r.unit() * scale, r.unit() * scale, r.unit() * scale)
}
#[test]
fn indirect_orient3d_matches_rational_constructions() {
let mut r = Rng(9);
let mut checked = 0;
for i in 0..150 {
let pts: Vec<Point3> = (0..14).map(|_| rand_pt(&mut r, 1.0)).collect();
let Ok(lpi) = Lpi::new(&pts[0], &pts[1], &pts[2], &pts[3], &pts[4]) else { continue };
let Ok(tpi) = Tpi::new([[&pts[5], &pts[6], &pts[7]], [&pts[8], &pts[9], &pts[10]], [&pts[11], &pts[12], &pts[13]]]) else {
continue;
};
let rl = r_lpi(&pts[0], &pts[1], &pts[2], &pts[3], &pts[4]);
let rt = r_tpi(&[[pts[5], pts[6], pts[7]], [pts[8], pts[9], pts[10]], [pts[11], pts[12], pts[13]]]);
let g = [GenericPoint::Lpi(lpi.clone()), GenericPoint::Tpi(tpi.clone())];
let rg = [rl.clone(), rt.clone()];
let ex: Vec<Point3> = (0..4).map(|_| rand_pt(&mut r, 1.0)).collect();
// Every mix of implicit and explicit points in every position.
for mask in 0u32..81 {
let mut gp = Vec::new();
let mut rpv = Vec::new();
let mut m = mask;
for e in &ex {
match m % 3 {
0 => {
gp.push(GenericPoint::Explicit(*e));
rpv.push(rp(e));
}
k => {
gp.push(g[k as usize - 1].clone());
rpv.push(rg[k as usize - 1].clone());
}
}
m /= 3;
}
if !(i + mask as usize).is_multiple_of(7) {
continue; // a deterministic sample of the masks keeps the test fast
}
let want = r_orient3d(&[rpv[0].clone(), rpv[1].clone(), rpv[2].clone(), rpv[3].clone()]);
assert_eq!(generic_orient3d(&gp[0], &gp[1], &gp[2], &gp[3]), want, "mask {mask}");
checked += 1;
}
// The constructed points lie exactly on their defining planes.
let lp = GenericPoint::Lpi(lpi);
let tp = GenericPoint::Tpi(tpi);
let e = |p: &Point3| GenericPoint::Explicit(*p);
assert_eq!(generic_orient3d(&e(&pts[2]), &e(&pts[3]), &e(&pts[4]), &lp), Sign::Zero);
assert_eq!(generic_orient3d(&lp, &e(&pts[2]), &e(&pts[3]), &e(&pts[4])), Sign::Zero);
for k in 0..3 {
let pl = &pts[5 + 3 * k..8 + 3 * k];
assert_eq!(generic_orient3d(&e(&pl[0]), &e(&pl[1]), &tp, &e(&pl[2])), Sign::Zero);
}
// ... and on their line.
for axis in [Axis::X, Axis::Y, Axis::Z] {
assert_eq!(generic_orient2d(&e(&pts[0]), &e(&pts[1]), &lp, axis), Sign::Zero);
}
// Two different constructions of the same point compare equal on every axis.
let again = GenericPoint::Lpi(Lpi::new(&pts[1], &pts[0], &pts[3], &pts[4], &pts[2]).unwrap());
for axis in [Axis::X, Axis::Y, Axis::Z] {
assert_eq!(generic_compare(&lp, &again, axis), Ordering::Equal);
let want = rl[axis.index()].cmp(&rt[axis.index()]);
assert_eq!(generic_compare(&lp, &tp, axis), want);
let want = rl[axis.index()].cmp(&q(ex[0].0[axis.index()]));
assert_eq!(generic_compare(&lp, &e(&ex[0]), axis), want);
}
let approx = lp.approx();
for k in 0..3 {
assert!((approx[k] - rl[k].to_f64().unwrap()).abs() <= 1e-9 * (1.0 + approx[k].abs()));
}
}
assert!(checked > 500, "{checked}");
}
#[test]
fn indirect_orient2d_matches_rational_constructions() {
let mut r = Rng(10);
for _ in 0..200 {
let pts: Vec<Point3> = (0..8).map(|_| rand_pt(&mut r, 10.0)).collect();
let Ok(lpi) = Lpi::new(&pts[0], &pts[1], &pts[2], &pts[3], &pts[4]) else { continue };
let rl = r_lpi(&pts[0], &pts[1], &pts[2], &pts[3], &pts[4]);
for axis in [Axis::X, Axis::Y, Axis::Z] {
let (u, v) = axis.projected();
for slot in 0..3 {
let mut g: Vec<GenericPoint> = pts[5..8].iter().map(|p| GenericPoint::Explicit(*p)).collect();
let mut rr: Vec<RPoint> = pts[5..8].iter().map(rp).collect();
g[slot] = GenericPoint::Lpi(lpi.clone());
rr[slot] = rl.clone();
let d = |p: &RPoint, o: &RPoint| vec![p[u].clone() - o[u].clone(), p[v].clone() - o[v].clone()];
let want = rsign(&rdet(&[d(&rr[0], &rr[2]), d(&rr[1], &rr[2])]));
assert_eq!(generic_orient2d(&g[0], &g[1], &g[2], axis), want);
}
}
}
}
#[test]
fn undefined_constructions_are_rejected() {
let o = p3(0.0, 0.0, 0.0);
let (x, y, z) = (p3(1.0, 0.0, 0.0), p3(0.0, 1.0, 0.0), p3(0.0, 0.0, 1.0));
// Line along x, plane z = 0 containing it: parallel.
assert!(Lpi::new(&o, &x, &o, &x, &y).is_err());
// Degenerate line.
assert!(Lpi::new(&z, &z, &o, &x, &y).is_err());
// Collinear plane points.
assert!(Lpi::new(&o, &z, &o, &x, &p3(2.0, 0.0, 0.0)).is_err());
// A nearly parallel line is fine: z rises by one ulp over the segment.
assert!(Lpi::new(&p3(0.0, 0.0, -1.0), &p3(1.0, 0.0, -1.0 + f64::EPSILON), &o, &x, &y).is_ok());
// Two parallel planes.
let lifted = |p: &Point3| p3(p.0[0], p.0[1], 1.0);
assert!(Tpi::new([[&o, &x, &y], [&lifted(&o), &lifted(&x), &lifted(&y)], [&o, &x, &z]]).is_err());
let t = Tpi::new([[&o, &x, &y], [&o, &y, &z], [&o, &z, &x]]).unwrap();
let t = GenericPoint::Tpi(t);
for axis in [Axis::X, Axis::Y, Axis::Z] {
assert_eq!(generic_compare(&t, &GenericPoint::Explicit(o), axis), Ordering::Equal);
}
}