Files
OneCAD-Simulation/crates/mesh/src/cdt/mod.rs
T
jyvikoandClaude Opus 5.5 d5bd74570f Rename the project to FieldCraft
Crates fieldcraft-*, binaries fieldcraft and fieldcraft-cli, the .fieldcraft file (format tag
fieldcraft/1, manifest fieldcraft.json), FIELDCRAFT_* environment variables, the MCP server
name, egui widget ids (fc_*), the app icon document FieldCraft.icon and its generated family,
docs and licences. No compatibility shim: no files of the earlier name exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
2026-10-13 23:51:40 -04:00

362 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 fieldcraft_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 })
}
}