Constrained Delaunay tetrahedralization (boundary recovery) and mesh.tet.generate (M2) (#29)

* predicates: LNC points, generic insphere and its perturbed form (Diazzi et al. 2023 §4.3)

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

* mesh: constrained Delaunay tetrahedralization (segment recovery with LNC Steiner points, gift-wrapping facet recovery, labelling)

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

* cdt: rounding repair, mesh.tet.generate, corpus CDT column; review cleanups (shared perturbation rule, one facet scan, cached coordinates, worklist segment recovery)

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

* mesh.tet.generate is ready in the catalogue; corpus table with the CDT column

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 18:37:09 -04:00
1 parent e688f6f934
commit aa68bbbcdf
24 files changed
+2876 -56

No files matched your search

+361
View File
@@ -0,0 +1,361 @@
//! Constrained Delaunay tetrahedralization of a closed boundary surface: every input triangle is
//! a union of tetrahedron faces, with no tolerance anywhere in the decisions.
//!
//! # Method
//! L. Diazzi, D. Panozzo, A. Vaxman, M. Attene, *Constrained Delaunay Tetrahedrization: A Robust
//! and Practical Approach*, ACM TOG 42(6):181, 2023 (arXiv 2309.09805; paper only, the authors'
//! code is GPL/LGPL and is not read), on the theory of H. Si, K. Gärtner, *Meshing Piecewise
//! Linear Complexes by Constrained Delaunay Tetrahedralizations*, IMR 2005, and J. R. Shewchuk,
//! *Constrained Delaunay Tetrahedralizations and Provably Good Boundary Recovery*, IMR 2002 (also
//! papers only; TetGen is AGPL).
//!
//! 1. The Delaunay tetrahedralization ([`crate::delaunay`]) of the surface's vertices and the
//! eight corners of a box around them, so every Steiner point lies strictly inside the hull.
//! 2. **Segment recovery** ([`segments`], §3.3): every edge of the surface is a segment; a missing
//! one is split at an implicit Steiner point (an [`Lnc`](crate::predicates::Lnc) point on the
//! original segment, §4.3) placed by Si's rule and inserted by Bowyer–Watson with the same
//! perturbed in-sphere test as the Delaunay step, until all segments are edges.
//! 3. **Facet recovery** ([`facets`], §3.4, §4.4): every input triangle is a facet; the
//! tetrahedra crossing a missing facet are deleted and the two half-cavities refilled by the
//! modified gift-wrapping, which builds the constrained Delaunay tetrahedralization of each
//! half with exact predicates on implicit points.
//! 4. **Inside/outside** (§4.5): a flood fill from the box corners that flips at every facet
//! triangle; the orientation of every facet triangle against its inside tetrahedron is then
//! checked exactly.
//!
//! Every decision is an exact predicate ([`crate::predicates`]): `orient3d`, `orient2d` and
//! coordinate comparison on explicit and LNC points, and `insphere` with the Devillers–Teillaud
//! symbolic perturbation. The result is checked before it is returned: every segment is an edge,
//! no edge crosses any facet, every tetrahedron is positive (exactly), and the labelling is
//! consistent with the surface's orientation.
//!
//! # Output
//! [`TetMesh`]: the inside tetrahedra in tet4 order, the surface's vertices first (same ids) and
//! 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.
//!
//! # Determinism and bounds
//! Same surface → same mesh: the Delaunay step is deterministic, segments and facets are
//! processed in a fixed order, and hash maps are only looked up. Steiner points, tetrahedra,
//! passes and cavities are capped ([`CdtOptions`]); hitting a cap is an [`Error::Limit`].
mod complex;
mod facets;
mod meet;
mod round;
mod segments;
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use modelcraft_model::ElementKind;
use crate::delaunay::Triangulation;
use crate::predicates::{GenericPoint, Point3, Sign};
use crate::quality::{GateOptions, GateReport, validity_gate_elements};
use crate::surface::{BoundarySurface, Box3};
use crate::{Error, Result};
use complex::{Complex, NONE, corrupt, face_toward, sorted3};
use segments::Segment;
/// Caps on the work of [`tetrahedralize`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CdtOptions {
/// Most Steiner points added on segments. A boundary whose triangles are long next to
/// features much closer than their length (needle triangles) needs Steiner points at that
/// spacing all along them; the default is well above what the corpus needs (30k at most) and
/// stops such input in seconds.
pub max_steiner: usize,
/// Most tetrahedra (inside and outside, while building).
pub max_tets: usize,
}
impl Default for CdtOptions {
fn default() -> Self {
CdtOptions { max_steiner: 50_000, max_tets: 20_000_000 }
}
}
/// A Steiner point: `points[a] + t (points[b] − points[a])` of the surface edge `[a, b]`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SteinerPoint {
pub segment: [u32; 2],
pub t: f64,
}
/// A tetrahedron face on the boundary: tetrahedron `tet`'s face opposite its vertex
/// `opposite` (0..4) lies on surface triangle `triangle`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BoundaryFace {
pub tet: u32,
pub opposite: u8,
pub triangle: u32,
}
impl BoundaryFace {
/// The face's 1-based number in the model's tet4 face order ([`ElementKind::faces`]): the
/// face without the opposite vertex.
pub fn tet4_face(&self) -> Option<u8> {
let k = usize::from(self.opposite);
let n = ElementKind::Tet4.faces().iter().position(|f| !f.contains(&k))?;
u8::try_from(n + 1).ok()
}
}
/// What the recovery did.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CdtStats {
/// Tetrahedra of the initial Delaunay tetrahedralization (with the box).
pub delaunay_tets: usize,
/// Missing subsegments split.
pub missing_subsegments: usize,
/// Cavities refilled by gift-wrapping.
pub cavities: usize,
/// Tetrahedra made by gift-wrapping.
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,
}
/// The constrained Delaunay tetrahedralization of a closed surface (see the module docs).
#[derive(Debug, Clone, PartialEq)]
pub struct TetMesh {
/// The surface's vertices (same ids as [`BoundarySurface::points`]), then the Steiner
/// points rounded to doubles.
pub points: Vec<[f64; 3]>,
/// The Steiner points, for `points[surface vertices..]`.
pub steiner: Vec<SteinerPoint>,
/// The inside tetrahedra, tet4 order (positive volume in exact arithmetic).
pub tets: Vec<[u32; 4]>,
/// Every tetrahedron face on the surface, with its input triangle.
pub boundary: Vec<BoundaryFace>,
pub stats: CdtStats,
}
impl TetMesh {
/// The validity gate on the rounded coordinates: every tetrahedron positive, and the volume
/// equal to `expected_volume` (the surface's, [`BoundarySurface::volume`]) within 1e-9.
pub fn gate(&self, expected_volume: f64) -> Result<GateReport> {
let coords = |t: &[u32; 4]| -> Result<[[f64; 3]; 4]> {
let p = |v: u32| self.points.get(v as usize).copied().ok_or_else(|| corrupt("output vertex id"));
Ok([p(t[0])?, p(t[1])?, p(t[2])?, p(t[3])?])
};
let elements = self.tets.iter().enumerate().map(|(i, t)| Ok((i as u32 + 1, ElementKind::Tet4, coords(t)?))).collect::<Result<Vec<_>>>()?;
validity_gate_elements(elements, &GateOptions { expected_volume: Some(expected_volume), volume_tolerance: 1e-9 })
}
}
/// The state of one recovery.
pub(crate) struct Recovery {
cx: Complex,
/// The surface's triangles (the facets), vertex ids as in the complex.
triangles: Vec<[u32; 3]>,
segments: Vec<Segment>,
/// Sorted edge → segment.
edge_segment: HashMap<(u32, u32), usize>,
/// Recovered facet triangles (sorted vertex ids) → facet.
constraint: HashMap<[u32; 3], u32>,
/// The surface's vertices are the complex's first `surface` vertices, then 8 box corners,
/// then the Steiner points.
surface: usize,
/// Steiner points in creation order: (segment, t).
steiner: Vec<(usize, f64)>,
options: CdtOptions,
stats: CdtStats,
}
/// The constrained Delaunay tetrahedralization of `surface` (see the module docs).
pub fn tetrahedralize(surface: &BoundarySurface, options: &CdtOptions) -> Result<TetMesh> {
let mut rec = Recovery::new(surface, options)?;
rec.classify_acute()?;
rec.recover_segments()?;
for f in 0..rec.triangles.len() {
rec.recover_facet(f)?;
}
rec.verify()?;
let inside = rec.label()?;
rec.output(&inside)
}
impl Recovery {
fn new(surface: &BoundarySurface, options: &CdtOptions) -> Result<Self> {
let n = surface.points().len();
let triangles = surface.triangles().to_vec();
if triangles.is_empty() {
return Err(Error::Invalid("the surface has no triangles".into()));
}
let mut points: Vec<[f64; 3]> = surface.points().iter().map(|p| p.map(|x| x + 0.0)).collect();
let mut bbox = Box3::EMPTY;
for p in &points {
bbox.add_point(*p);
}
let size = (0..3).map(|k| bbox.hi[k] - bbox.lo[k]).fold(0.0, f64::max).max(1.0);
for corner in 0..8 {
points.push(std::array::from_fn(|k| {
let x = if corner >> k & 1 == 0 { bbox.lo[k] - size } else { bbox.hi[k] + size };
crate::predicates::snap_to_domain(x) + 0.0
}));
}
let tri = Triangulation::new(&points)?;
if tri.dimension() != 3 || (0..points.len() as u32).any(|v| tri.duplicate_of(v).is_some()) {
return Err(Error::Invalid("the surface's vertices must be distinct".into()));
}
let generic = points.iter().map(|&p| Point3::try_from(p).map(GenericPoint::Explicit)).collect::<Result<Vec<_>>>()?;
let cx = Complex::from_delaunay(&tri, generic, options.max_tets)?;
let mut edges: Vec<(u32, u32)> =
triangles.iter().flat_map(|t| (0..3).map(move |k| (t[k].min(t[(k + 1) % 3]), t[k].max(t[(k + 1) % 3])))).collect();
edges.sort_unstable();
edges.dedup();
if let Some(&(a, b)) = edges.iter().find(|(a, b)| a == b || *b as usize >= n) {
return Err(Error::Invalid(format!("surface edge {a}–{b} is degenerate or has no vertex")));
}
let segments: Vec<Segment> = edges.iter().map(|&(a, b)| Segment::new(a, b)).collect();
let edge_segment = edges.iter().enumerate().map(|(i, &e)| (e, i)).collect();
let stats = CdtStats { delaunay_tets: cx.live_count(), ..CdtStats::default() };
Ok(Recovery { cx, triangles, segments, edge_segment, constraint: HashMap::new(), surface: n, steiner: Vec::new(), options: *options, stats })
}
/// Every segment is an edge, no edge crosses a facet, every facet has triangles, every
/// tetrahedron is positive.
fn verify(&mut self) -> Result<()> {
let mut scratch = Vec::new();
for s in 0..self.segments.len() {
let chain: Vec<u32> = self.segment(s)?.splits.iter().map(|&(_, v)| v).collect();
for w in chain.windows(2) {
if let &[u, v] = w
&& !self.cx.has_edge(u, v, &mut scratch)?
{
let at = self.cx.approx(u)?;
return Err(Error::Failed(format!("a boundary edge near {at:?} was lost during facet recovery")));
}
}
}
let mut has_faces = vec![false; self.triangles.len()];
for &f in self.constraint.values() {
if let Some(h) = has_faces.get_mut(f as usize) {
*h = true;
}
}
for f in 0..self.triangles.len() {
if !has_faces.get(f).copied().unwrap_or(false) || !self.facet_present(f)? {
return Err(Error::Failed(format!("input triangle {f} is not a union of tetrahedron faces")));
}
}
let live: Vec<[u32; 4]> = self.cx.live_tets().map(|(_, vs)| vs).collect();
for vs in live {
if self.cx.volume(vs)? != Sign::Positive {
let at = self.cx.approx(vs[0])?;
return Err(corrupt(&format!("a tetrahedron near {at:?} is not positive")));
}
}
Ok(())
}
/// Inside (`true`) or outside, per tetrahedron slot: a flood fill from the box corners that
/// flips across every facet triangle (Diazzi et al. §4.5), then the exact check that each
/// facet triangle has its inside tetrahedron on the side its normal points away from.
fn label(&mut self) -> Result<Vec<bool>> {
let mut label: Vec<Option<bool>> = vec![None; self.cx.slots()];
let corners = self.surface..self.surface + 8;
let corner = self.cx.live_tets().find(|(_, vs)| vs.iter().any(|&v| corners.contains(&(v as usize))));
let (start, _) = corner.ok_or_else(|| corrupt("no tetrahedron touches the bounding box"))?;
if let Some(l) = label.get_mut(start as usize) {
*l = Some(false);
}
let mut queue = vec![start];
while let Some(t) = queue.pop() {
let inside = label.get(t as usize).copied().flatten().unwrap_or(false);
let vs = self.cx.tet(t)?;
for (k, nb) in self.cx.adj(t)?.into_iter().enumerate() {
let want = inside != self.constraint.contains_key(&sorted3(face_toward(vs, k)));
match label.get_mut(nb as usize) {
None => {} // the hull
Some(l @ None) => {
*l = Some(want);
queue.push(nb);
}
Some(Some(got)) if *got != want => {
let at = self.cx.approx(vs[k])?;
return Err(Error::Failed(format!(
"inside/outside labelling is inconsistent near {at:?}: the surface does not bound a volume"
)));
}
Some(_) => {}
}
}
}
let live: Vec<(u32, [u32; 4])> = self.cx.live_tets().collect();
for (t, vs) in live {
match label.get(t as usize).copied().flatten() {
None => return Err(corrupt("a tetrahedron the flood fill did not reach")),
Some(false) => {}
Some(true) => {
for k in 0..4 {
let Some(&f) = self.constraint.get(&sorted3(face_toward(vs, k))) else { continue };
let [a, b, c] = *self.triangles.get(f as usize).ok_or_else(|| corrupt("facet id"))?;
let x = vs.get(k).copied().unwrap_or(NONE);
if self.cx.volume([a, b, c, x])? != Sign::Negative {
let at = self.cx.approx(a)?;
return Err(Error::Failed(format!("input triangle {f} near {at:?} faces into the solid: the surface is inverted")));
}
}
}
}
}
Ok(label.into_iter().map(|l| l == Some(true)).collect())
}
fn output(&self, inside: &[bool]) -> Result<TetMesh> {
let n = self.surface;
// Surface vertices keep their ids; box corners disappear; Steiner points follow.
let total = self.cx.points().len();
let mut id = vec![NONE; total];
let mut points = Vec::with_capacity(n + self.steiner.len());
for v in 0..n {
if let Some(s) = id.get_mut(v) {
*s = v as u32;
}
points.push(self.cx.approx(v as u32)?);
}
let mut steiner = Vec::with_capacity(self.steiner.len());
for (k, &(s, t)) in self.steiner.iter().enumerate() {
let v = n + 8 + k;
if let Some(slot) = id.get_mut(v) {
*slot = points.len() as u32;
}
points.push(self.cx.approx(v as u32)?);
steiner.push(SteinerPoint { segment: self.segment(s)?.ends, t });
}
let mut tets = Vec::new();
let mut boundary = Vec::new();
for (t, vs) in self.cx.live_tets() {
if !inside.get(t as usize).copied().unwrap_or(false) {
continue;
}
let mapped = vs.map(|v| id.get(v as usize).copied().unwrap_or(NONE));
if mapped.contains(&NONE) {
return Err(corrupt("an inside tetrahedron uses a bounding-box corner"));
}
for k in 0..4 {
if let Some(&f) = self.constraint.get(&sorted3(face_toward(vs, k))) {
boundary.push(BoundaryFace { tet: tets.len() as u32, opposite: k as u8, triangle: f });
}
}
tets.push(mapped);
}
let mut stats = self.stats;
stats.box_tets = self.cx.live_count();
stats.rounding_adjusted = round::repair(&mut points, &tets, n)?;
Ok(TetMesh { points, steiner, tets, boundary, stats })
}
}