forked from 1b-agent/fieldcraft-upstream
The end-to-end demo met a raw-CDT sliver that the validity gate accepted (exactly positive) and the solver refused. Now: - One validity criterion, `fieldcraft_model::admissible_jacobian` (normalized Jacobian above 1e-12), used by the implicit solver (fem `positive_inverse`), the explicit tet4/hex8 kinematics and the mesh gate, which proves it on the whole element (tet4: the solvers' own Jacobian; curved kinds: a Bernstein floor), so whatever passes the gate passes the solvers. - CDT rounding repaired by flips on the rounded mesh (Diazzi et al. 2023 §5.1) instead of the ±2-ulp nudge. - `fieldcraft-mesh::tet`: a mutable tetrahedral complex whose every change is a checked replacement (positive and admissible tetrahedra, faces matched with opposite orientation) with an exact undo log; edge removal (Klincsek), multi-face removal, Delaunay insertion with boundary faces, edge collapse; a curvature- and thickness-graded size field; constrained Delaunay refinement with encroachment splits; ODT smoothing; the Klingner–Shewchuk schedule; boundary vertices moved onto the exact B-rep surfaces at the end; tet10 with mid-edge nodes on the geometry. - `mesh.tet.generate` gains size, order, quality, quality_target, curvature_angle, grading, max_passes, max_points and reports the CDT's and the mesh's quality; `solve.probe` takes a set and reports its largest von Mises stress. - Oracle cases filleted-bracket-fillet-stress(-coarse): the fillet's peak von Mises stress on tet10 meshes of 2k and 13.5k elements, 213.3 MPa (a four-level study stays within 1.8%); the CDT cases keep the raw tetrahedralization. - Corpus quality columns: all 20 valid parts mesh and pass the gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
321 lines
13 KiB
Rust
321 lines
13 KiB
Rust
//! Tests of the constrained Delaunay tetrahedralization: exact conformity to every input
|
|
//! triangle, the enclosed volume, the validity gate, and the classic polyhedra that have no
|
|
//! tetrahedralization without Steiner points.
|
|
|
|
use super::meet::{in_closed_tet, meet_properly};
|
|
use super::*;
|
|
use crate::predicates::{GenericPoint, Lnc, Point3};
|
|
use crate::surface::{BoundarySurface, SurfaceOptions};
|
|
use crate::vec3::{cross, dot, norm, sub};
|
|
|
|
/// A closed surface from points and triangles, each triangle oriented outward from `centre`
|
|
/// (the shapes here are star-shaped about it).
|
|
pub(crate) fn surface(points: &[[f64; 3]], triangles: &[[u32; 3]], centre: [f64; 3]) -> BoundarySurface {
|
|
let oriented: Vec<[u32; 3]> = triangles
|
|
.iter()
|
|
.map(|&[a, b, c]| {
|
|
let (pa, pb, pc) = (points[a as usize], points[b as usize], points[c as usize]);
|
|
let n = cross(sub(pb, pa), sub(pc, pa));
|
|
if dot(n, sub(pa, centre)) > 0.0 { [a, b, c] } else { [a, c, b] }
|
|
})
|
|
.collect();
|
|
let faces: Vec<u32> = (0..oriented.len() as u32).collect();
|
|
BoundarySurface::new(points, &oriented, &faces, &SurfaceOptions::default()).unwrap()
|
|
}
|
|
|
|
/// The checks every result must pass: the gate (validity, volume), every input triangle covered
|
|
/// by boundary faces of exactly its area, each boundary face on its triangle's plane.
|
|
fn check(s: &BoundarySurface, m: &TetMesh) {
|
|
let report = m.gate(s.volume()).unwrap();
|
|
assert!(report.passed(), "{report:?}");
|
|
let mut area = vec![0.0; s.triangles().len()];
|
|
for b in &m.boundary {
|
|
let t = m.tets[b.tet as usize];
|
|
let face = face_toward(t, b.opposite as usize).map(|v| m.points[v as usize]);
|
|
area[b.triangle as usize] += 0.5 * norm(cross(sub(face[1], face[0]), sub(face[2], face[0])));
|
|
let tri = s.triangles()[b.triangle as usize].map(|v| s.points()[v as usize]);
|
|
let tn = cross(sub(tri[1], tri[0]), sub(tri[2], tri[0]));
|
|
for p in face {
|
|
let d = dot(tn, sub(p, tri[0])) / norm(tn);
|
|
assert!(d.abs() < 1e-9, "{d}");
|
|
}
|
|
}
|
|
for (i, t) in s.triangles().iter().enumerate() {
|
|
let p = t.map(|v| s.points()[v as usize]);
|
|
let want = 0.5 * norm(cross(sub(p[1], p[0]), sub(p[2], p[0])));
|
|
assert!((area[i] - want).abs() <= 1e-9 * want.max(1e-300), "triangle {i}: {} vs {want}", area[i]);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn cube() -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
|
|
let p: Vec<[f64; 3]> = (0..8).map(|i| [(i & 1) as f64, (i >> 1 & 1) as f64, (i >> 2 & 1) as f64]).collect();
|
|
let quads = [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 5, 4], [2, 3, 7, 6], [0, 2, 6, 4], [1, 3, 7, 5]];
|
|
let t = quads.iter().flat_map(|q| [[q[0], q[1], q[2]], [q[0], q[2], q[3]]]).collect();
|
|
(p, t)
|
|
}
|
|
|
|
#[test]
|
|
fn a_cube_conforms() {
|
|
let (p, t) = cube();
|
|
let s = surface(&p, &t, [0.5; 3]);
|
|
let m = tetrahedralize(&s, &CdtOptions::default()).unwrap();
|
|
check(&s, &m);
|
|
// A square's diagonal is cocircular with the other two corners, so it is not strongly
|
|
// Delaunay; at most one split per face (its midpoint) recovers it.
|
|
assert!(m.steiner.len() <= 6, "{:?}", m.steiner);
|
|
assert_eq!(m.boundary.len(), 12 + 2 * m.steiner.len());
|
|
}
|
|
|
|
/// Schönhardt's twisted prism (Schönhardt 1928): no tetrahedralization without Steiner points.
|
|
fn schonhardt(twist_deg: f64) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
|
|
let mut p = Vec::new();
|
|
for z in 0..2 {
|
|
for i in 0..3 {
|
|
let a = (i as f64) * 2.0 * std::f64::consts::PI / 3.0 + if z == 1 { twist_deg.to_radians() } else { 0.0 };
|
|
p.push([a.cos(), a.sin(), z as f64 * 1.5]);
|
|
}
|
|
}
|
|
let mut t = vec![[0, 1, 2], [3, 4, 5]];
|
|
for i in 0..3u32 {
|
|
let j = (i + 1) % 3;
|
|
// The reflex diagonal: from bottom i to top j (the top is turned towards j).
|
|
t.push([i, j, 3 + j]);
|
|
t.push([i, 3 + j, 3 + i]);
|
|
}
|
|
(p, t)
|
|
}
|
|
|
|
#[test]
|
|
fn schonhardt_prism_gets_steiner_points_and_conforms() {
|
|
let (p, t) = schonhardt(30.0);
|
|
let s = surface(&p, &t, [0.0, 0.0, 0.75]);
|
|
let m = tetrahedralize(&s, &CdtOptions::default()).unwrap();
|
|
check(&s, &m);
|
|
assert!(!m.steiner.is_empty(), "Schönhardt's prism has no tetrahedralization without Steiner points");
|
|
for sp in &m.steiner {
|
|
assert!(sp.t > 0.0 && sp.t < 1.0);
|
|
}
|
|
}
|
|
|
|
/// A UV sphere: many cospherical vertices.
|
|
pub(crate) fn uv_sphere(rings: usize, sectors: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
|
|
let mut p = vec![[0.0, 0.0, 1.0], [0.0, 0.0, -1.0]];
|
|
for r in 1..rings {
|
|
let th = std::f64::consts::PI * r as f64 / rings as f64;
|
|
for s in 0..sectors {
|
|
let ph = 2.0 * std::f64::consts::PI * s as f64 / sectors as f64;
|
|
p.push([th.sin() * ph.cos(), th.sin() * ph.sin(), th.cos()]);
|
|
}
|
|
}
|
|
let id = |r: usize, s: usize| (2 + (r - 1) * sectors + s % sectors) as u32;
|
|
let mut t = Vec::new();
|
|
for s in 0..sectors {
|
|
t.push([0, id(1, s), id(1, s + 1)]);
|
|
t.push([1, id(rings - 1, s + 1), id(rings - 1, s)]);
|
|
for r in 1..rings - 1 {
|
|
t.push([id(r, s), id(r + 1, s), id(r + 1, s + 1)]);
|
|
t.push([id(r, s), id(r + 1, s + 1), id(r, s + 1)]);
|
|
}
|
|
}
|
|
(p, t)
|
|
}
|
|
|
|
#[test]
|
|
fn a_sphere_conforms_and_is_deterministic() {
|
|
let (p, t) = uv_sphere(12, 24);
|
|
let s = surface(&p, &t, [0.0; 3]);
|
|
let a = tetrahedralize(&s, &CdtOptions::default()).unwrap();
|
|
check(&s, &a);
|
|
let b = tetrahedralize(&s, &CdtOptions::default()).unwrap();
|
|
assert_eq!(a, b);
|
|
}
|
|
|
|
#[test]
|
|
fn caps_are_reported() {
|
|
let (p, t) = schonhardt(30.0);
|
|
let s = surface(&p, &t, [0.0, 0.0, 0.75]);
|
|
let none = CdtOptions { max_steiner: 0, max_tets: 1_000_000 };
|
|
assert!(matches!(tetrahedralize(&s, &none), Err(Error::Limit(_))));
|
|
let few = CdtOptions { max_steiner: 1000, max_tets: 10 };
|
|
assert!(matches!(tetrahedralize(&s, &few), Err(Error::Limit(_))));
|
|
}
|
|
|
|
fn e(x: f64, y: f64, z: f64) -> GenericPoint {
|
|
GenericPoint::Explicit(Point3::new(x, y, z).unwrap())
|
|
}
|
|
|
|
#[test]
|
|
fn proper_meeting_handles_coplanar_and_shared_cases() {
|
|
let p = vec![
|
|
e(0.0, 0.0, 0.0), // 0
|
|
e(1.0, 0.0, 0.0), // 1
|
|
e(0.0, 1.0, 0.0), // 2
|
|
e(0.0, 0.0, 1.0), // 3: tet 0 1 2 3
|
|
e(1.0, 1.0, 0.0), // 4: beside face 0 1 2, in its plane
|
|
e(0.2, 0.2, 0.0), // 5: inside face 0 1 2
|
|
e(0.0, 0.0, -1.0), // 6: below
|
|
e(0.5, 0.5, 2.0), // 7: above, off the tet
|
|
e(2.0, 2.0, 2.0), // 8: far
|
|
e(0.1, 0.1, 0.1), // 9: inside the tet
|
|
];
|
|
let tet = [0, 1, 2, 3];
|
|
let yes = |b: &[u32]| assert!(meet_properly(&p, &tet, b).unwrap(), "{b:?}");
|
|
let no = |b: &[u32]| assert!(!meet_properly(&p, &tet, b).unwrap(), "{b:?}");
|
|
yes(&[0, 1, 2]); // a face
|
|
yes(&[1, 2, 4]); // coplanar, sharing edge 1 2, on the other side
|
|
yes(&[0, 1, 6]); // shares an edge, below
|
|
yes(&[3, 7, 8]); // shares a vertex, away
|
|
yes(&[6, 8, 4]); // disjoint
|
|
no(&[0, 1, 5]); // coplanar, overlapping the face 0 1 2
|
|
no(&[1, 2, 5]); // coplanar, overlapping
|
|
no(&[6, 7, 4]); // passes through the tet
|
|
no(&[3, 9, 7]); // shares a vertex, enters the tet
|
|
no(&[5, 6, 8]); // touches the face 0 1 2 at an interior point
|
|
assert!(in_closed_tet(&p, tet, 9).unwrap());
|
|
assert!(in_closed_tet(&p, tet, 5).unwrap());
|
|
assert!(!in_closed_tet(&p, tet, 8).unwrap());
|
|
// An LNC point on edge 0 3 is on the tet's boundary.
|
|
let mut q = p.clone();
|
|
q.push(GenericPoint::Lnc(Lnc::new(&Point3::new(0.0, 0.0, 0.0).unwrap(), &Point3::new(0.0, 0.0, 1.0).unwrap(), 0.3).unwrap()));
|
|
assert!(in_closed_tet(&q, tet, 10).unwrap());
|
|
assert!(!meet_properly(&q, &tet, &[10, 6, 8]).unwrap(), "touches edge 0 3 at a non-shared point");
|
|
// Tetrahedra sharing a face meet properly; overlapping ones do not.
|
|
assert!(meet_properly(&p, &tet, &[0, 1, 2, 6]).unwrap());
|
|
assert!(!meet_properly(&p, &tet, &[0, 1, 2, 9]).unwrap());
|
|
}
|
|
|
|
/// Two solids: a wide tetrahedron standing on the big triangle z = 0, and a small one whose top
|
|
/// vertex is just under that triangle's centre. The Delaunay edge from the big apex to the small
|
|
/// top pierces the big triangle while all its edges are present: only facet recovery (the
|
|
/// gift-wrapping of a cavity) restores it.
|
|
fn pierced() -> (Vec<[f64; 3]>, Vec<[u32; 3]>, Vec<[f64; 3]>) {
|
|
let p = vec![
|
|
[-10.0, -10.0, 0.0],
|
|
[10.0, -10.0, 0.0],
|
|
[0.0, 10.0, 0.0],
|
|
[0.0, 0.0, 5.0],
|
|
[-1.0, -1.0, -1.0],
|
|
[1.0, -1.0, -1.0],
|
|
[0.0, 1.0, -1.0],
|
|
[0.0, 0.0, -0.01],
|
|
];
|
|
let t = vec![[0, 1, 2], [0, 1, 3], [1, 2, 3], [2, 0, 3], [4, 5, 6], [4, 5, 7], [5, 6, 7], [6, 4, 7]];
|
|
(p, t, vec![[0.0, -3.0, 1.0], [0.0, -0.3, -0.7]])
|
|
}
|
|
|
|
/// Orients each triangle outward from the centre of the shell it belongs to.
|
|
fn shells(points: &[[f64; 3]], triangles: &[[u32; 3]], centres: &[[f64; 3]], per_shell: usize) -> BoundarySurface {
|
|
let oriented: Vec<[u32; 3]> = triangles
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, &[a, b, c])| {
|
|
let centre = centres[i / per_shell];
|
|
let (pa, pb, pc) = (points[a as usize], points[b as usize], points[c as usize]);
|
|
let n = cross(sub(pb, pa), sub(pc, pa));
|
|
if dot(n, sub(pa, centre)) > 0.0 { [a, b, c] } else { [a, c, b] }
|
|
})
|
|
.collect();
|
|
let faces: Vec<u32> = (0..oriented.len() as u32).collect();
|
|
BoundarySurface::new(points, &oriented, &faces, &SurfaceOptions::default()).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn a_pierced_facet_is_recovered_by_gift_wrapping() {
|
|
let (p, t, centres) = pierced();
|
|
let s = shells(&p, &t, ¢res, 4);
|
|
assert_eq!(s.shells().len(), 2);
|
|
let m = tetrahedralize(&s, &CdtOptions::default()).unwrap();
|
|
check(&s, &m);
|
|
assert!(m.stats.cavities > 0, "{:?}", m.stats);
|
|
assert!(m.stats.gift_wrapped_tets > 0);
|
|
}
|
|
|
|
/// A Chazelle-style polyhedron (Chazelle 1984): a block whose top is cut by V-notches along `x`
|
|
/// and whose bottom is cut by V-notches along `y`, the notch edges almost touching. Its
|
|
/// tetrahedralizations need many Steiner points.
|
|
fn chazelle(notches: usize, gap: f64) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
|
|
let n = 2 * notches;
|
|
let at = |i: usize| i as f64 / n as f64;
|
|
// Top height by y index: ridges at even, valleys at odd. Bottom height by x index.
|
|
let top = |j: usize| if j.is_multiple_of(2) { 1.0 } else { 0.5 + gap / 2.0 };
|
|
let bottom = |i: usize| if i.is_multiple_of(2) { 0.0 } else { 0.5 - gap / 2.0 };
|
|
let mut p = Vec::new();
|
|
let mut id = std::collections::HashMap::new();
|
|
let mut vertex = |x: usize, y: usize, up: bool, p: &mut Vec<[f64; 3]>| -> u32 {
|
|
*id.entry((x, y, up)).or_insert_with(|| {
|
|
p.push([at(x), at(y), if up { top(y) } else { bottom(x) }]);
|
|
(p.len() - 1) as u32
|
|
})
|
|
};
|
|
let mut t = Vec::new();
|
|
let quad = |q: [u32; 4], out: [f64; 3], p: &Vec<[f64; 3]>, t: &mut Vec<[u32; 3]>| {
|
|
for tri in [[q[0], q[1], q[2]], [q[0], q[2], q[3]]] {
|
|
let (a, b, c) = (p[tri[0] as usize], p[tri[1] as usize], p[tri[2] as usize]);
|
|
let nrm = cross(sub(b, a), sub(c, a));
|
|
t.push(if dot(nrm, out) > 0.0 { tri } else { [tri[0], tri[2], tri[1]] });
|
|
}
|
|
};
|
|
for i in 0..n {
|
|
for j in 0..n {
|
|
let q = [vertex(i, j, true, &mut p), vertex(i + 1, j, true, &mut p), vertex(i + 1, j + 1, true, &mut p), vertex(i, j + 1, true, &mut p)];
|
|
quad(q, [0.0, 0.0, 1.0], &p, &mut t);
|
|
let q =
|
|
[vertex(i, j, false, &mut p), vertex(i + 1, j, false, &mut p), vertex(i + 1, j + 1, false, &mut p), vertex(i, j + 1, false, &mut p)];
|
|
quad(q, [0.0, 0.0, -1.0], &p, &mut t);
|
|
}
|
|
}
|
|
for k in 0..n {
|
|
for (fixed, out) in [(0, -1.0), (n, 1.0)] {
|
|
// Walls x = fixed (along y) and y = fixed (along x).
|
|
let q = [
|
|
vertex(fixed, k, false, &mut p),
|
|
vertex(fixed, k + 1, false, &mut p),
|
|
vertex(fixed, k + 1, true, &mut p),
|
|
vertex(fixed, k, true, &mut p),
|
|
];
|
|
quad(q, [out, 0.0, 0.0], &p, &mut t);
|
|
let q = [
|
|
vertex(k, fixed, false, &mut p),
|
|
vertex(k + 1, fixed, false, &mut p),
|
|
vertex(k + 1, fixed, true, &mut p),
|
|
vertex(k, fixed, true, &mut p),
|
|
];
|
|
quad(q, [0.0, out, 0.0], &p, &mut t);
|
|
}
|
|
}
|
|
(p, t)
|
|
}
|
|
|
|
#[test]
|
|
fn chazelle_polyhedron_conforms() {
|
|
let (p, t) = chazelle(3, 0.02);
|
|
let faces: Vec<u32> = (0..t.len() as u32).collect();
|
|
let s = BoundarySurface::new(&p, &t, &faces, &SurfaceOptions::default()).unwrap();
|
|
assert_eq!(s.shells().len(), 1);
|
|
let m = tetrahedralize(&s, &CdtOptions::default()).unwrap();
|
|
check(&s, &m);
|
|
assert!(!m.steiner.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_and_degenerate_surfaces_are_errors() {
|
|
// A flat "solid" (all points coplanar) bounds no volume.
|
|
let p = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 1.0, 0.0]];
|
|
let t = vec![[0, 1, 2], [1, 3, 2], [0, 2, 1], [1, 2, 3]];
|
|
let faces = vec![0; 4];
|
|
if let Ok(s) = BoundarySurface::new(&p, &t, &faces, &SurfaceOptions::default()) {
|
|
assert!(tetrahedralize(&s, &CdtOptions::default()).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_gate_reports_an_inverted_tetrahedron() {
|
|
let (p, t) = cube();
|
|
let s = surface(&p, &t, [0.5; 3]);
|
|
let mut m = tetrahedralize(&s, &CdtOptions::default()).unwrap();
|
|
m.tets[0].swap(0, 1);
|
|
let report = m.gate(s.volume()).unwrap();
|
|
assert_eq!(report.invalid, vec![1]);
|
|
assert!(report.into_result().is_err());
|
|
}
|