Tet mesh quality: one validity criterion, Delaunay refinement, ODT, Klingner–Shewchuk, tet10

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
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 08:28:49 -04:00
1 parent fed27d816d
commit 9469ac0853
53 files changed
+5880 -278

No files matched your search

+1 -51
View File
@@ -8,43 +8,12 @@
//! the hull. The hull is the bounding box the recovery adds around the surface, so every Steiner
//! point is strictly inside it and no ghost tetrahedra are needed.
use std::collections::HashMap;
use std::hash::{BuildHasherDefault, Hasher};
pub(super) use crate::tet::complex::{IdMap, NONE, face_toward, sorted3};
use crate::delaunay::Triangulation;
use crate::predicates::{GenericPoint, Sign, generic_insphere_sos, generic_lex, generic_orient3d};
use crate::{Error, Result};
/// A multiplicative hasher for small integer keys (vertex triples). The maps it serves are only
/// looked up, never iterated, so its order cannot leak into the output.
#[derive(Default)]
pub(super) struct IdHasher(u64);
impl Hasher for IdHasher {
fn finish(&self) -> u64 {
self.0
}
fn write(&mut self, bytes: &[u8]) {
for &b in bytes {
self.write_u64(u64::from(b));
}
}
fn write_u32(&mut self, x: u32) {
self.write_u64(u64::from(x));
}
fn write_u64(&mut self, x: u64) {
self.0 = (self.0.rotate_left(5) ^ x).wrapping_mul(0x51_7c_c1_b7_27_22_0a_95);
}
}
/// A hash map keyed by vertex ids.
pub(super) type IdMap<K, V> = HashMap<K, V, BuildHasherDefault<IdHasher>>;
/// No tetrahedron / no vertex.
pub(super) const NONE: u32 = u32::MAX;
/// Marks a recycled tetrahedron slot.
const DEAD: u32 = u32::MAX - 1;
@@ -52,25 +21,6 @@ pub(super) fn corrupt(what: &str) -> Error {
Error::Failed(format!("constrained tetrahedralization is inconsistent: {what}"))
}
/// The face of `vs` opposite slot `k`, ordered so that vertex `k` is on its positive side
/// ([`Complex::volume`] of the face plus that vertex is positive).
pub(super) fn face_toward(vs: [u32; 4], k: usize) -> [u32; 3] {
let [a, b, c, d] = vs;
match k {
0 => [b, d, c],
1 => [a, c, d],
2 => [a, d, b],
_ => [a, b, c],
}
}
/// A triangle's vertices sorted: the key of an unoriented face.
pub(super) fn sorted3([a, b, c]: [u32; 3]) -> [u32; 3] {
let mut f = [a, b, c];
f.sort_unstable();
f
}
/// The working tetrahedralization.
#[derive(Debug, Clone)]
pub(super) struct Complex {
+10 -9
View File
@@ -34,9 +34,10 @@
//! then the Steiner points, each with its segment and parameter, rounded to doubles; and for each
//! boundary tetrahedron face the input triangle it lies on (so the B-rep face tag carries over).
//! Rounding a Steiner point to the nearest doubles moves it off its segment by under an ulp and
//! can, on a nearly flat tetrahedron, flip it (§5.1). [`round`] then tries the doubles a few ulps
//! around such a point for one that keeps every incident tetrahedron positive; [`TetMesh::gate`]
//! runs the validity gate on the rounded mesh and any failure left is reported, never hidden.
//! can, on a nearly flat tetrahedron, flip it (§5.1); nearly cospherical input also gives
//! tetrahedra that are positive yet flat to rounding. [`repair`] removes them by flips that keep
//! the boundary (§5.1); [`TetMesh::gate`] runs the validity gate on the rounded mesh and any
//! failure left is reported, never hidden.
//!
//! # Determinism and bounds
//! Same surface → same mesh: the Delaunay step is deterministic, segments and facets are
@@ -46,11 +47,11 @@
mod complex;
mod facets;
mod meet;
mod round;
mod repair;
mod segments;
#[cfg(test)]
mod tests;
pub(crate) mod tests;
use std::collections::HashMap;
@@ -122,9 +123,9 @@ pub struct CdtStats {
pub gift_wrapped_tets: usize,
/// Tetrahedra in the box at the end.
pub box_tets: usize,
/// Steiner points moved by a few ulps from their nearest doubles to keep every incident
/// tetrahedron positive ([`round`]).
pub rounding_adjusted: usize,
/// Tetrahedra that were flat, inverted or inadmissible on the rounded coordinates and were
/// removed by flips ([`repair`]).
pub repaired: usize,
}
/// The constrained Delaunay tetrahedralization of a closed surface (see the module docs).
@@ -355,7 +356,7 @@ impl Recovery {
}
let mut stats = self.stats;
stats.box_tets = self.cx.live_count();
stats.rounding_adjusted = round::repair(&mut points, &tets, n)?;
stats.repaired = repair::repair(&points, &mut tets, &mut boundary, self.options.max_tets)?;
Ok(TetMesh { points, steiner, tets, boundary, stats })
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Repairing the tetrahedra that rounding to doubles leaves flat, inverted or inadmissible.
//!
//! The exact tetrahedralization is valid, but its Steiner points are rounded to doubles, which
//! can flip a nearly flat tetrahedron (Diazzi et al. 2023, §5.1), and nearly cospherical input
//! (a tessellated sphere, a cylinder) gives tetrahedra that are positive yet flat to rounding:
//! their normalized Jacobian is below FieldCraft's validity criterion
//! ([`fieldcraft_model::admissible_jacobian`]), so a solver would refuse them. Following §5.1,
//! such tetrahedra are removed by flips that never touch the boundary, on the rounded
//! coordinates and decided by exact `orient3d`: edge removal (which includes the 3-2 and 4-4
//! flips) around each of their edges and face removal (the 2-3 flip and multi-face removal)
//! across each of their faces, each applied only when it improves the worst tetrahedron it
//! changes ([`crate::tet::ops`]). Every boundary edge is held fixed, so every input triangle
//! stays a union of tetrahedron faces. The passes are capped; whatever is left is reported by the
//! validity gate, never hidden.
use fieldcraft_model::{normalized_jacobian, tet4_jacobian};
use super::BoundaryFace;
use crate::tet::complex::{EDGES, NONE, TetComplex, VertexKind};
use crate::tet::ops::{remove_edge_at, remove_face};
use crate::tet::refine::feature_curves;
use crate::{Error, Result};
/// A tetrahedron is repaired when its normalized Jacobian is at most this (a margin of a
/// thousand above the validity criterion, so rounding differences cannot bring it back).
const REPAIR_BELOW: f64 = 1e3 * fieldcraft_model::MIN_NORMALIZED_JACOBIAN;
/// Passes over the tetrahedra still to repair.
const MAX_PASSES: usize = 8;
fn needs_repair(points: &[[f64; 3]], t: &[u32; 4]) -> bool {
let p = |v: u32| points.get(v as usize).copied().unwrap_or([f64::NAN; 3]);
!(normalized_jacobian(&tet4_jacobian(&[p(t[0]), p(t[1]), p(t[2]), p(t[3])])) > REPAIR_BELOW)
}
/// Repair the rounded mesh in place; the number of tetrahedra that needed it.
pub(super) fn repair(points: &[[f64; 3]], tets: &mut Vec<[u32; 4]>, boundary: &mut Vec<BoundaryFace>, max_tets: usize) -> Result<usize> {
let bad = tets.iter().filter(|t| needs_repair(points, t)).count();
if bad == 0 {
return Ok(0);
}
let tags: Vec<(u32, usize, u32)> = boundary.iter().map(|b| (b.tet, usize::from(b.opposite), b.triangle)).collect();
let kinds = vec![VertexKind::Corner; points.len()];
let mut cx = TetComplex::with_invalid(points, kinds, tets, &tags, &[], points.len(), max_tets.max(tets.len() * 2))?;
// The segments: boundary edges between two different input triangles (never by angle).
// Edges inside one input triangle (between its pieces) may be flipped; the triangle stays
// covered.
let (_, segments) = feature_curves(&cx, -2.0)?;
for ((a, b), c) in segments {
cx.set_curve(a, b, Some(c));
}
for _ in 0..MAX_PASSES {
let targets: Vec<(u32, [u32; 4])> = cx.live_tets().filter(|(_, vs)| needs_repair(points, vs)).collect();
if targets.is_empty() {
break;
}
let mut changed = false;
for (t, vs) in targets {
if !cx.is_live(t) || cx.tet(t)? != vs {
continue;
}
for &(i, j) in &EDGES {
if cx.is_live(t) && cx.tet(t)? == vs && remove_edge_at(&mut cx, t, vs[i], vs[j])? {
changed = true;
break;
}
}
// A flat tetrahedron lying on one input triangle (two of its faces are pieces of
// it): delete it; its two other faces cover the same piece of the triangle.
if cx.is_live(t) && cx.tet(t)? == vs && cx.delete_boundary_tet(t)? {
changed = true;
continue;
}
if cx.is_live(t) && cx.tet(t)? == vs {
for k in 0..4 {
if remove_face(&mut cx, t, k)? {
changed = true;
break;
}
}
}
}
if !changed {
break;
}
}
let c = cx.compact()?;
if c.source.len() != points.len() || c.source.iter().enumerate().any(|(i, &s)| s as usize != i) {
return Err(Error::Failed("repair removed a vertex".into()));
}
*tets = c.tets;
*boundary = c.boundary.into_iter().map(|(tet, k, tag)| BoundaryFace { tet, opposite: k as u8, triangle: tag }).collect();
if boundary.iter().any(|b| b.triangle == NONE) {
return Err(Error::Failed("repair lost a boundary face's triangle".into()));
}
Ok(bad)
}
-126
View File
@@ -1,126 +0,0 @@
//! Rounding the Steiner points to doubles without inverting tetrahedra.
//!
//! The exact tetrahedralization is valid, but a Steiner point rounded to its nearest doubles is
//! off its segment by up to half an ulp per coordinate, which flips a tetrahedron whose volume is
//! below that scale (Diazzi et al. 2023, §5.1, §6.2: the nearly flat ones, typically four Steiner
//! points on nearly parallel boundary edges). For each tetrahedron that is not positive after
//! rounding, each of its Steiner points tries the doubles within [`REACH`] ulps of its rounded
//! coordinates, nearest first, and takes the first with which every tetrahedron around it is
//! positive (exact `orient3d` on the doubles). Surface vertices never move. The moves are a few
//! ulps, so boundary faces stay on their triangles to rounding precision. The number of passes is
//! capped; what is left is for the validity gate to report.
//!
//! This is the interim answer. The paper's own (§5.1) improves the exact mesh by flips that never
//! cross a facet before rounding, which removes most nearly flat tetrahedra instead of nudging
//! their vertices; it belongs with the flips of the quality stage.
use crate::predicates::{Point3, Sign, tet_volume_sign};
use crate::{Error, Result};
/// How far, in ulps per coordinate, a rounded Steiner point may move.
const REACH: i32 = 2;
/// Passes over the tetrahedra that are not positive.
const MAX_PASSES: usize = 4;
fn positive(points: &[[f64; 3]], t: &[u32; 4]) -> Result<bool> {
let p = |v: u32| -> Result<Point3> {
let c = points.get(v as usize).copied().ok_or_else(|| Error::Failed("rounding: vertex id out of range".into()))?;
Point3::try_from(c)
};
Ok(tet_volume_sign(&p(t[0])?, &p(t[1])?, &p(t[2])?, &p(t[3])?) == Sign::Positive)
}
/// `x` moved by `k` ulps.
fn step(mut x: f64, k: i32) -> f64 {
for _ in 0..k.unsigned_abs() {
x = if k > 0 { x.next_up() } else { x.next_down() };
}
x
}
/// Offsets within [`REACH`], nearest (smallest total) first, in a fixed order.
fn offsets() -> Vec<[i32; 3]> {
let r = -REACH..=REACH;
let mut o: Vec<[i32; 3]> = r.clone().flat_map(|a| r.clone().flat_map(move |b| (-REACH..=REACH).map(move |c| [a, b, c]))).collect();
o.retain(|d| d != &[0, 0, 0]);
o.sort_by_key(|d| (d.iter().map(|k| k.abs()).sum::<i32>(), *d));
o
}
/// Move Steiner points (ids `>= surface`) so every tetrahedron is positive where a few-ulp move
/// does it. The number of points moved.
pub(super) fn repair(points: &mut [[f64; 3]], tets: &[[u32; 4]], surface: usize) -> Result<usize> {
let mut bad = Vec::new();
for (i, t) in tets.iter().enumerate() {
if !positive(points, t)? {
bad.push(i);
}
}
if bad.is_empty() {
return Ok(0);
}
let mut around: Vec<Vec<u32>> = vec![Vec::new(); points.len()];
for (i, t) in tets.iter().enumerate() {
for &v in t {
if (v as usize) >= surface
&& let Some(a) = around.get_mut(v as usize)
{
a.push(i as u32);
}
}
}
let offsets = offsets();
let mut moved = vec![false; points.len()];
for _ in 0..MAX_PASSES {
let mut still = Vec::new();
for &i in &bad {
let t = *tets.get(i).ok_or_else(|| Error::Failed("rounding: tetrahedron id".into()))?;
if positive(points, &t)? {
continue;
}
let mut fixed = false;
for v in t.into_iter().filter(|&v| (v as usize) >= surface) {
let Some(origin) = points.get(v as usize).copied() else { continue };
let incident = around.get(v as usize).cloned().unwrap_or_default();
for d in &offsets {
let candidate: [f64; 3] = std::array::from_fn(|k| step(origin[k], d[k]));
if candidate.iter().any(|x| !crate::predicates::is_admissible(*x)) {
continue;
}
if let Some(p) = points.get_mut(v as usize) {
*p = candidate;
}
let mut ok = true;
for &j in &incident {
let u = tets.get(j as usize).ok_or_else(|| Error::Failed("rounding: tetrahedron id".into()))?;
if !positive(points, u)? {
ok = false;
break;
}
}
if ok {
fixed = true;
if let Some(m) = moved.get_mut(v as usize) {
*m = true;
}
break;
}
if let Some(p) = points.get_mut(v as usize) {
*p = origin;
}
}
if fixed {
break;
}
}
if !fixed {
still.push(i);
}
}
if still.is_empty() || still.len() == bad.len() {
break;
}
bad = still;
}
Ok(moved.iter().filter(|m| **m).count())
}
+3 -3
View File
@@ -10,7 +10,7 @@ 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).
fn surface(points: &[[f64; 3]], triangles: &[[u32; 3]], centre: [f64; 3]) -> BoundarySurface {
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]| {
@@ -47,7 +47,7 @@ fn check(s: &BoundarySurface, m: &TetMesh) {
}
}
fn cube() -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
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();
@@ -98,7 +98,7 @@ fn schonhardt_prism_gets_steiner_points_and_conforms() {
}
/// A UV sphere: many cospherical vertices.
fn uv_sphere(rings: usize, sectors: usize) -> (Vec<[f64; 3]>, Vec<[u32; 3]>) {
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;