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>
This commit is contained in:
authored and GitHub committed 2026-10-13 15:04:56 -04:00
1 parent 90254bb32f
commit 4667655e08
17 files changed
+2683 -2

No files matched your search

+607
View File
@@ -0,0 +1,607 @@
//! 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);
}
}