forked from 1b-agent/fieldcraft-upstream
Exact 3D Delaunay tetrahedralization (Bowyer–Watson, ghost hull, BRIO/Hilbert, verifier) (#14)
* mesh: exact Bowyer-Watson Delaunay tetrahedralization with ghost tets, BRIO/Hilbert order and a verifier Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * mesh: Delaunay benchmark, hash-paired cavity faces, mesh.delaunay.points debug command Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * mesh: simplify Delaunay helpers, share predicates and parsers, halve verifier work 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:
13 files changed
+1508
-17
No files matched your search
@@ -0,0 +1,326 @@
|
||||
use modelcraft_model::ElementKind;
|
||||
|
||||
use super::*;
|
||||
use crate::quality::{GateOptions, enclosed_volume, validity_gate_elements};
|
||||
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(1);
|
||||
order::mix(self.0)
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
fn shuffle<T>(&mut self, v: &mut [T]) {
|
||||
for i in (1..v.len()).rev() {
|
||||
v.swap(i, (self.next_u64() % (i as u64 + 1)) as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random(seed: u64, n: usize) -> Vec<[f64; 3]> {
|
||||
let mut r = Rng(seed);
|
||||
(0..n).map(|_| [r.unit(), r.unit(), r.unit()]).collect()
|
||||
}
|
||||
|
||||
fn grid(nx: usize, ny: usize, nz: usize) -> Vec<[f64; 3]> {
|
||||
let mut out = Vec::new();
|
||||
for x in 0..nx {
|
||||
for y in 0..ny {
|
||||
for z in 0..nz {
|
||||
out.push([x as f64, y as f64, z as f64]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The finite tetrahedra as sorted coordinate quadruples: comparable across vertex numberings.
|
||||
fn tet_set(tri: &Triangulation) -> Vec<[[u64; 3]; 4]> {
|
||||
let mut out: Vec<[[u64; 3]; 4]> = tri
|
||||
.finite_tets()
|
||||
.map(|(_, vs)| {
|
||||
let mut q = vs.map(|v| tri.points()[v as usize].coords().map(f64::to_bits));
|
||||
q.sort_unstable();
|
||||
q
|
||||
})
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// The validity gate on the finite tetrahedra, with the hull volume expected; returns the volume.
|
||||
fn gate(tri: &Triangulation, expected: f64) -> f64 {
|
||||
let elements = tri.finite_tets().map(|(t, vs)| (t + 1, ElementKind::Tet4, vs.map(|v| tri.points()[v as usize].coords())));
|
||||
let report = validity_gate_elements(elements, &GateOptions { expected_volume: Some(expected), volume_tolerance: 1e-9 }).unwrap();
|
||||
assert_eq!(report.checked, tri.finite_tet_count());
|
||||
report.into_result().unwrap().volume
|
||||
}
|
||||
|
||||
/// The volume enclosed by the hull faces, outward oriented, through the quality module.
|
||||
fn hull_volume(tri: &Triangulation) -> f64 {
|
||||
let points: Vec<[f64; 3]> = tri.points().iter().map(Point3::coords).collect();
|
||||
let mut triangles = Vec::new();
|
||||
for t in 0..tri.slots() as u32 {
|
||||
let Some(vs) = tri.tet(t) else { continue };
|
||||
let Some(g) = vs.iter().position(|&v| v == GHOST) else { continue };
|
||||
// Moving the ghost's slot to the end takes 3 - g transpositions.
|
||||
let mut f: Vec<usize> = vs.iter().filter(|&&v| v != GHOST).map(|&v| v as usize).collect();
|
||||
if (3 - g) % 2 == 1 {
|
||||
f.swap(0, 1);
|
||||
}
|
||||
triangles.push([f[0], f[1], f[2]]);
|
||||
}
|
||||
enclosed_volume(&points, &triangles).unwrap()
|
||||
}
|
||||
|
||||
/// Full check: invariants, brute-force empty spheres, the tet4 validity gate.
|
||||
fn check(tri: &Triangulation, hull_volume: f64) {
|
||||
tri.verify().unwrap();
|
||||
tri.verify_empty_spheres().unwrap();
|
||||
gate(tri, hull_volume);
|
||||
}
|
||||
|
||||
/// Delaunay tetrahedra by definition: every positively oriented 4-subset whose perturbed
|
||||
/// circumsphere excludes every other point.
|
||||
fn brute_force(points: &[[f64; 3]]) -> Vec<[[u64; 3]; 4]> {
|
||||
let p: Vec<Point3> = points.iter().map(|&c| Point3::try_from(c).unwrap()).collect();
|
||||
let n = p.len();
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in i + 1..n {
|
||||
for k in j + 1..n {
|
||||
for l in k + 1..n {
|
||||
let (a, b, c, d) = match orient3d(&p[i], &p[j], &p[k], &p[l]) {
|
||||
Sign::Zero => continue,
|
||||
Sign::Positive => (&p[i], &p[j], &p[k], &p[l]),
|
||||
Sign::Negative => (&p[j], &p[i], &p[k], &p[l]),
|
||||
};
|
||||
let empty = (0..n).filter(|m| ![i, j, k, l].contains(m)).all(|m| insphere_sos(a, b, c, d, &p[m]) == Sign::Negative);
|
||||
if empty {
|
||||
let mut q = [i, j, k, l].map(|v| points[v].map(f64::to_bits));
|
||||
q.sort_unstable();
|
||||
out.push(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_tetrahedron_and_its_ghosts() {
|
||||
let tri = Triangulation::new(&[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]).unwrap();
|
||||
assert_eq!(tri.dimension(), 3);
|
||||
assert_eq!(tri.finite_tet_count(), 1);
|
||||
assert_eq!((0..tri.slots() as u32).filter(|&t| tri.is_ghost(t)).count(), 4);
|
||||
check(&tri, 1.0 / 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_points_are_delaunay_and_valid() {
|
||||
let points = random(1, 1500);
|
||||
let tri = Triangulation::new(&points).unwrap();
|
||||
tri.verify().unwrap();
|
||||
tri.verify_empty_spheres().unwrap();
|
||||
// The tetrahedra fill the volume the hull faces (the ghosts' finite faces) enclose.
|
||||
let vol = hull_volume(&tri);
|
||||
assert!(vol > 0.9 && vol < 1.0, "{vol}");
|
||||
gate(&tri, vol);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_the_definition_on_small_sets() {
|
||||
let sets: Vec<Vec<[f64; 3]>> = vec![
|
||||
random(7, 9),
|
||||
random(8, 14),
|
||||
grid(3, 3, 2),
|
||||
grid(3, 3, 3),
|
||||
// A cube's corners plus its centre: every face cospherical.
|
||||
{
|
||||
let mut g = grid(2, 2, 2);
|
||||
g.push([0.5, 0.5, 0.5]);
|
||||
g
|
||||
},
|
||||
// Coplanar on two planes.
|
||||
vec![[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [2.0, 2.0, 0.0], [1.0, 1.0, 0.0], [1.0, 1.0, 1.0], [0.0, 0.0, 1.0], [2.0, 2.0, 1.0]],
|
||||
];
|
||||
for points in sets {
|
||||
let tri = Triangulation::new(&points).unwrap();
|
||||
tri.verify().unwrap();
|
||||
assert_eq!(tet_set(&tri), brute_force(&points), "{points:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_result_does_not_depend_on_the_input_order() {
|
||||
for points in [random(2, 400), grid(5, 4, 6)] {
|
||||
let reference = tet_set(&Triangulation::new(&points).unwrap());
|
||||
let mut r = Rng(99);
|
||||
for _ in 0..3 {
|
||||
let mut shuffled = points.clone();
|
||||
r.shuffle(&mut shuffled);
|
||||
let tri = Triangulation::new(&shuffled).unwrap();
|
||||
tri.verify().unwrap();
|
||||
assert_eq!(tet_set(&tri), reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_bit_for_bit() {
|
||||
let points = random(3, 3000);
|
||||
let a = Triangulation::new(&points).unwrap();
|
||||
let b = Triangulation::new(&points).unwrap();
|
||||
assert_eq!(a.tets, b.tets);
|
||||
assert_eq!(a.adj, b.adj);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grids_are_cospherical_everywhere() {
|
||||
let tri = Triangulation::new(&grid(7, 6, 5)).unwrap();
|
||||
check(&tri, 6.0 * 5.0 * 4.0);
|
||||
assert_eq!(hull_volume(&tri), 120.0);
|
||||
// A rotated-in-place grid with non-integer spacing.
|
||||
let skew: Vec<[f64; 3]> = grid(6, 6, 6).into_iter().map(|[x, y, z]| [0.1 * x, 0.3 * y + 1e3, 0.7 * z - 5.0]).collect();
|
||||
let tri = Triangulation::new(&skew).unwrap();
|
||||
tri.verify().unwrap();
|
||||
tri.verify_empty_spheres().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cospherical_and_cocircular_points() {
|
||||
// Every integer point on the sphere x² + y² + z² = 9 (all cospherical).
|
||||
let mut sphere = Vec::new();
|
||||
for x in -3i32..=3 {
|
||||
for y in -3i32..=3 {
|
||||
for z in -3i32..=3 {
|
||||
if x * x + y * y + z * z == 9 {
|
||||
sphere.push([f64::from(x), f64::from(y), f64::from(z)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(sphere.len(), 30);
|
||||
let tri = Triangulation::new(&sphere).unwrap();
|
||||
tri.verify().unwrap();
|
||||
tri.verify_empty_spheres().unwrap();
|
||||
assert_eq!(tet_set(&tri), brute_force(&sphere));
|
||||
|
||||
// Two regular 12-gons on parallel planes (a CAD cylinder's rings).
|
||||
let mut cylinder = Vec::new();
|
||||
for z in [0.0, 4.0] {
|
||||
for i in 0..12 {
|
||||
let a = f64::from(i) * std::f64::consts::TAU / 12.0;
|
||||
cylinder.push([3.0 * a.cos(), 3.0 * a.sin(), z]);
|
||||
}
|
||||
}
|
||||
let tri = Triangulation::new(&cylinder).unwrap();
|
||||
tri.verify().unwrap();
|
||||
tri.verify_empty_spheres().unwrap();
|
||||
assert_eq!(tet_set(&tri), brute_force(&cylinder));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_dimensional_inputs_have_no_tetrahedra() {
|
||||
assert_eq!(Triangulation::new(&[]).unwrap().dimension(), 0);
|
||||
let one = Triangulation::new(&[[1.0, 2.0, 3.0]]).unwrap();
|
||||
assert_eq!((one.dimension(), one.finite_tet_count()), (0, 0));
|
||||
let line: Vec<[f64; 3]> = (0..50).map(|i| [f64::from(i), 2.0 * f64::from(i), -f64::from(i)]).collect();
|
||||
let tri = Triangulation::new(&line).unwrap();
|
||||
assert_eq!((tri.dimension(), tri.finite_tet_count()), (1, 0));
|
||||
tri.verify().unwrap();
|
||||
|
||||
let plane: Vec<[f64; 3]> = grid(10, 10, 1).into_iter().map(|[x, y, _]| [x, y, x + 2.0 * y]).collect();
|
||||
let mut tri = Triangulation::new(&plane).unwrap();
|
||||
assert_eq!((tri.dimension(), tri.finite_tet_count()), (2, 0));
|
||||
assert_eq!(tri.locate([0.0, 0.0, 0.0]).unwrap(), None);
|
||||
// Duplicates of the planar points are still found.
|
||||
assert_eq!(tri.insert([3.0, 4.0, 11.0]).unwrap(), Insertion::Duplicate(34));
|
||||
// One point off the plane makes it 3D: every tetrahedron has that apex.
|
||||
let apex = match tri.insert([4.5, 4.5, 40.0]).unwrap() {
|
||||
Insertion::Inserted(v) => v,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(tri.dimension(), 3);
|
||||
assert!(tri.finite_tets().all(|(_, vs)| vs.contains(&apex)));
|
||||
tri.verify().unwrap();
|
||||
tri.verify_empty_spheres().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicates_merge_into_the_lowest_index() {
|
||||
let base = grid(4, 4, 4);
|
||||
let mut points = base.clone();
|
||||
points.extend(base.iter().rev());
|
||||
points.push([-0.0, 0.0, -0.0]);
|
||||
let tri = Triangulation::new(&points).unwrap();
|
||||
tri.verify().unwrap();
|
||||
for i in 0..base.len() {
|
||||
assert_eq!(tri.duplicate_of(i as u32), None);
|
||||
assert_eq!(tri.duplicate_of((2 * base.len() - 1 - i) as u32), Some(i as u32));
|
||||
}
|
||||
assert_eq!(tri.duplicate_of(points.len() as u32 - 1), Some(0));
|
||||
assert_eq!(tet_set(&tri), tet_set(&Triangulation::new(&base).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steiner_points_insert_into_a_built_triangulation() {
|
||||
let mut tri = Triangulation::new(&grid(4, 4, 4)).unwrap();
|
||||
let mut r = Rng(5);
|
||||
for _ in 0..200 {
|
||||
let p = [4.0 * r.unit() - 0.5, 4.0 * r.unit() - 0.5, 4.0 * r.unit() - 0.5];
|
||||
assert!(matches!(tri.insert(p).unwrap(), Insertion::Inserted(_)));
|
||||
}
|
||||
// On a vertex, on an edge, on a face, on the hull's plane outside it.
|
||||
assert_eq!(tri.insert([1.0, 2.0, 3.0]).unwrap(), Insertion::Duplicate(27));
|
||||
assert_eq!(tri.duplicate_of(tri.points().len() as u32 - 1), Some(27));
|
||||
for p in [[1.5, 2.0, 3.0], [1.5, 1.5, 0.0], [5.0, 5.0, 0.0], [-1.0, 1.0, 3.0]] {
|
||||
assert!(matches!(tri.insert(p).unwrap(), Insertion::Inserted(_)), "{p:?}");
|
||||
}
|
||||
tri.verify().unwrap();
|
||||
tri.verify_empty_spheres().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_finds_the_containing_tetrahedron() {
|
||||
let tri = Triangulation::new(&grid(3, 3, 3)).unwrap();
|
||||
match tri.locate([0.5, 0.5, 0.5]).unwrap() {
|
||||
Some(Location::Inside(t)) => assert!(!tri.is_ghost(t)),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert!(matches!(tri.locate([9.0, 0.5, 0.5]).unwrap(), Some(Location::Outside(t)) if tri.is_ghost(t)));
|
||||
assert_eq!(tri.locate([2.0, 2.0, 2.0]).unwrap(), Some(Location::Vertex(26)));
|
||||
assert!(tri.locate([f64::NAN, 0.0, 0.0]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_coordinates_are_rejected() {
|
||||
for bad in [[f64::NAN, 0.0, 0.0], [f64::INFINITY, 0.0, 0.0], [1e300, 0.0, 0.0], [1e-300, 0.0, 0.0]] {
|
||||
assert!(matches!(Triangulation::new(&[[0.0; 3], bad]), Err(Error::Invalid(_))));
|
||||
}
|
||||
let mut tri = Triangulation::new(&grid(2, 2, 2)).unwrap();
|
||||
assert!(tri.insert([0.0, f64::NEG_INFINITY, 0.0]).is_err());
|
||||
tri.verify().unwrap();
|
||||
// Extreme but admissible magnitudes still triangulate exactly.
|
||||
let big: Vec<[f64; 3]> = random(4, 200).into_iter().map(|[x, y, z]| [x * 1e40, y * 1e-30 + 1.0, z]).collect();
|
||||
Triangulation::new(&big).unwrap().verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incident_tets_and_neighbors_are_consistent() {
|
||||
let tri = Triangulation::new(&random(6, 300)).unwrap();
|
||||
for v in 0..300 {
|
||||
let t = tri.incident_tet(v).unwrap();
|
||||
assert!(tri.tet(t).unwrap().contains(&v));
|
||||
}
|
||||
for (t, _) in tri.finite_tets() {
|
||||
for n in tri.neighbors(t).unwrap() {
|
||||
assert!(tri.neighbors(n).unwrap().contains(&t));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user