Files
OneCAD-Simulation/crates/mesh/src/tet/improve.rs
T
jyvikoandClaude Opus 5.5 9469ac0853 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
2026-10-14 08:28:49 -04:00

502 lines
19 KiB
Rust

//! Aggressive mesh improvement: the schedule of Klingner and Shewchuk (IMR 2007, §4.2,
//! Figure 4) over smoothing, topological transformations and vertex insertion, monotone in the
//! worst quality (the minimum dihedral sine).
//!
//! Each pass smooths; if that is not enough progress, a topological pass (edge removal,
//! multi-face removal, and deletion of flat tetrahedra lying on one B-rep face) follows; if still
//! not enough, an insertion pass tries new vertices in the worst tetrahedra (a boundary face's
//! barycentre first, then the tetrahedron's, then the midpoints of its boundary edges). An
//! insertion is a composite operation: the vertex goes in with a Delaunay cavity, is smoothed,
//! and topological passes and smoothing run around it; if the worst of the tetrahedra it made
//! or changed is not better than the worst of those it removed, everything is rolled back
//! ([`super::complex::Journal`]). Progress is judged on the worst quality and seven thresholded
//! means (§4.2); the schedule stops after three passes without progress, or at its pass and
//! insertion caps (reported).
//!
//! The paper chooses the inserted vertex's cavity by an optimal graph cut (§3.4); this
//! implementation uses the Delaunay cavity followed by the same smoothing and topological
//! repair, which the paper's own composite operation relies on to make insertion pay off.
use crate::Result;
use crate::predicates::Point3;
use super::complex::{EDGES, IdMap, NONE, Step, TetComplex, VertexKind, Walk, face_toward, sorted4};
use super::measure::{self, P};
use super::ops::{self, Site, remove_edge_at, remove_face, tet_quality};
use super::refine::{Context, MAX_CAVITY};
use super::smooth::smooth_vertex;
/// Settings of the schedule.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ImproveOptions {
pub max_passes: usize,
pub max_insertions: usize,
/// Tetrahedra at or above this quality are left alone by the topological and smoothing
/// passes (default sin 40°, the paper's desperation threshold: a mesh whose tetrahedra all
/// have dihedral angles between 40° and 140° needs nothing more).
pub good: f64,
/// Insertion runs every pass while the worst quality is below this (sin 30°: dihedral
/// angles between 30° and 150°).
pub target: f64,
}
impl Default for ImproveOptions {
fn default() -> Self {
ImproveOptions { max_passes: 10, max_insertions: 50_000, good: SIN_40, target: 0.5 }
}
}
/// What the schedule did.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ImproveStats {
pub passes: usize,
pub smoothed: usize,
pub topological: usize,
pub inserted: usize,
/// The worst quality (smallest dihedral sine) after each pass.
pub worst: Vec<f64>,
pub stopped: Option<String>,
}
/// sin 1°, 5°, 10°, 15°, 25°, 35°, 45° (the thresholds of §4.2), as literals so they are the same
/// everywhere.
pub const SIN_DEG: [f64; 7] = [
0.017_452_406_437_283_51,
0.087_155_742_747_658_17,
0.173_648_177_666_930_33,
0.258_819_045_102_520_74,
0.422_618_261_740_699_44,
0.573_576_436_351_046,
std::f64::consts::FRAC_1_SQRT_2,
];
/// Most tetrahedra one insertion pass tries (the worst first).
pub const MAX_INSERTION_LIST: usize = 2000;
/// sin 40°.
pub const SIN_40: f64 = 0.642_787_609_686_539_3;
/// The worst quality and the seven thresholded means.
fn indicators(cx: &TetComplex) -> Result<[f64; 8]> {
let mut out = [0.0; 8];
out[0] = f64::INFINITY;
let mut n = 0usize;
for (_, vs) in cx.live_tets() {
let q = tet_quality(cx, vs)?;
out[0] = out[0].min(q);
for (i, s) in SIN_DEG.iter().enumerate() {
out[i + 1] += q.min(*s);
}
n += 1;
}
for m in out.iter_mut().skip(1) {
*m /= n.max(1) as f64;
}
Ok(out)
}
fn progressed(now: &[f64; 8], before: &[f64; 8]) -> bool {
now[0] > before[0] || now.iter().zip(before).skip(1).any(|(a, b)| *a >= b + 1e-4)
}
/// Smooth every movable vertex of a tetrahedron below `good`. Returns the moves.
fn smoothing_pass(cx: &mut TetComplex, ctx: &Context, good: f64) -> Result<usize> {
let mut want = vec![false; cx.point_count()];
for (_, vs) in cx.live_tets() {
if tet_quality(cx, vs)? < good {
for v in vs {
if let Some(w) = want.get_mut(v as usize) {
*w = true;
}
}
}
}
let mut moved = 0;
for (v, w) in want.iter().enumerate() {
if *w && smooth_vertex(cx, ctx, v as u32)? {
moved += 1;
}
}
Ok(moved)
}
/// Delete a tetrahedron capping two boundary faces of one B-rep face that fold by at most 20°
/// ([`ops::BOUNDARY_FLIP_COS`]): its dihedral angle at their shared edge is at least 160°, and
/// its two other faces cover the same piece of surface.
fn delete_flat(cx: &mut TetComplex, t: u32) -> Result<bool> {
let vs = cx.tet(t)?;
let (ns, tags) = (cx.adj(t)?, cx.tags(t)?);
let bnd: Vec<usize> = (0..4).filter(|&k| ns[k] == NONE).collect();
let &[i, j] = bnd.as_slice() else { return Ok(false) };
if tags[i] != tags[j] {
return Ok(false);
}
let normal = |k: usize| -> Result<P> {
let f = face_toward(vs, k);
Ok(measure::triangle_normal(cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?))
};
// The inward normals of the two faces of such a cap point nearly the same way.
let (a, b) = (normal(i)?, normal(j)?);
if !(measure::dot(a, b) >= ops::BOUNDARY_FLIP_COS) {
return Ok(false);
}
cx.delete_boundary_tet(t)
}
/// A flat tetrahedron spanning two boundary faces of different B-rep faces that meet at a
/// nearly flat crease (two faces tangent along their curve): its two apexes lie almost on the
/// crease, so no flip or move fixes it. Collapse an apex face vertex onto an end of the crease
/// edge ([`ops::collapse`]), which takes the tetrahedron away.
fn collapse_into_crease(cx: &mut TetComplex, t: u32) -> Result<bool> {
let vs = cx.tet(t)?;
if tet_quality(cx, vs)? > 0.1 {
return Ok(false);
}
let (ns, tags) = (cx.adj(t)?, cx.tags(t)?);
let bnd: Vec<usize> = (0..4).filter(|&k| ns[k] == NONE).collect();
let &[i, j] = bnd.as_slice() else { return Ok(false) };
if tags[i] == tags[j] {
return Ok(false);
}
// The faces opposite i and j share the edge of the two other vertices; vs[i] and vs[j] are
// the apexes (vs[i] lies on face j, vs[j] on face i).
let edge: Vec<u32> = (0..4).filter(|&k| k != i && k != j).map(|k| vs[k]).collect();
let (ex, longest) = {
let x = cx.coords(vs)?;
(edge.clone(), measure::edge_range(&x).1)
};
for apex in [vs[i], vs[j]] {
if !matches!(cx.kind(apex), VertexKind::Face(_)) {
continue;
}
for &e in &ex {
if ops::collapse(cx, apex, e, 2.0 * longest, 0.0)? {
return Ok(true);
}
}
}
Ok(false)
}
/// Try to remove each tetrahedron of `list`: its edges, then its faces. Returns the changes.
pub fn topological_pass(cx: &mut TetComplex, list: &[u32]) -> Result<usize> {
let mut changes = 0;
for &t in list {
if !cx.is_live(t) {
continue;
}
let vs = cx.tet(t)?;
if delete_flat(cx, t)? || collapse_into_crease(cx, t)? {
changes += 1;
continue;
}
let mut gone = false;
for &(i, j) in &EDGES {
if remove_edge_at(cx, t, vs[i], vs[j])? {
gone = true;
break;
}
}
if gone {
changes += 1;
continue;
}
for k in 0..4 {
if remove_face(cx, t, k)? {
changes += 1;
break;
}
}
}
Ok(changes)
}
fn below(cx: &TetComplex, good: f64) -> Result<Vec<u32>> {
let mut out = Vec::new();
for (t, vs) in cx.live_tets() {
if tet_quality(cx, vs)? < good {
out.push(t);
}
}
Ok(out)
}
/// The quality changes since journal step `len`: the worst quality of the tetrahedra removed
/// (at their coordinates before) and of those made or moved (now).
fn change_quality(cx: &mut TetComplex, len: usize) -> Result<(f64, f64)> {
let steps: Vec<Step> = cx.journal.steps.get(len..).unwrap_or_default().to_vec();
// Earliest position of each moved vertex.
let mut first: IdMap<u32, Point3> = IdMap::default();
for s in &steps {
if let Step::Moved { v, from } = s {
first.entry(*v).or_insert(*from);
}
}
let old_xyz = |cx: &TetComplex, v: u32| -> Result<P> { first.get(&v).map_or_else(|| cx.xyz(v), |p| Ok(p.coords())) };
// Net removed and added tetrahedra by sorted vertices.
let mut net: IdMap<[u32; 4], (i32, [u32; 4])> = IdMap::default();
let key = sorted4;
for s in &steps {
match s {
Step::Replaced { removed, added, .. } => {
for r in removed {
net.entry(key(r.vs)).or_insert((0, r.vs)).0 -= 1;
}
for &vs in added {
net.entry(key(vs)).or_insert((0, vs)).0 += 1;
}
}
Step::Deleted { removed, .. } => net.entry(key(removed.vs)).or_insert((0, removed.vs)).0 -= 1,
_ => {}
}
}
let (mut before, mut after) = (f64::INFINITY, f64::INFINITY);
let mut keys: Vec<[u32; 4]> = net.keys().copied().collect();
keys.sort_unstable();
for k in &keys {
let Some(&(c, vs)) = net.get(k) else { continue };
if c < 0 {
let x = [old_xyz(cx, vs[0])?, old_xyz(cx, vs[1])?, old_xyz(cx, vs[2])?, old_xyz(cx, vs[3])?];
before = before.min(measure::quality(&x));
}
}
// The tetrahedra made, and those around moved vertices.
for k in &keys {
if let Some(&(c, vs)) = net.get(k)
&& c > 0
{
after = after.min(tet_quality(cx, vs)?);
}
}
let mut moved: Vec<u32> = first.keys().copied().collect();
moved.sort_unstable();
for v in moved {
if !cx.is_used(v) {
continue;
}
for t in cx.star(v)? {
let vs = cx.tet(t)?;
if net.get(&key(vs)).is_some_and(|e| e.0 > 0) {
continue;
}
after = after.min(tet_quality(cx, vs)?);
let x = [old_xyz(cx, vs[0])?, old_xyz(cx, vs[1])?, old_xyz(cx, vs[2])?, old_xyz(cx, vs[3])?];
before = before.min(measure::quality(&x));
}
}
Ok((before, after))
}
/// The composite insertion of §3.5 (see the module docs). Returns whether it was kept.
fn attempt_insert(cx: &mut TetComplex, ctx: &Context, p: Point3, kind: VertexKind, site: Site) -> Result<bool> {
let was = cx.journal.recording;
cx.journal.recording = true;
let len = cx.journal.steps.len();
let kept = (|| -> Result<bool> {
let Some(v) = ops::insert_delaunay(cx, p, kind, site, MAX_CAVITY)? else { return Ok(false) };
for _ in 0..4 {
smooth_vertex(cx, ctx, v)?;
if !cx.is_used(v) {
break;
}
let star = cx.star(v)?;
if topological_pass(cx, &star)? == 0 {
break;
}
}
if cx.is_used(v) {
let mut around: Vec<u32> = cx.neighbours(v)?;
around.push(v);
for u in around {
smooth_vertex(cx, ctx, u)?;
}
}
let (before, after) = change_quality(cx, len)?;
Ok(after > before && after.is_finite())
})();
let keep = match kept {
Ok(k) => k,
Err(e) => {
cx.rollback(len)?;
cx.journal.recording = was;
return Err(e);
}
};
if !keep {
cx.rollback(len)?;
}
if !was {
cx.journal.steps.truncate(len);
}
cx.journal.recording = was;
Ok(keep)
}
/// Try inserting a vertex in each tetrahedron of `list` (§4.2). Returns the insertions kept.
fn insertion_pass(cx: &mut TetComplex, ctx: &Context, list: &[u32], budget: usize, good: f64) -> Result<usize> {
let mut kept = 0;
for &t in list {
if kept >= budget {
break;
}
if !cx.is_live(t) {
continue;
}
let vs = cx.tet(t)?;
if tet_quality(cx, vs)? >= good {
continue;
}
let ns = cx.adj(t)?;
let tags = cx.tags(t)?;
let mut done = false;
// A tetrahedron lying on two boundary faces that meet at a nearly flat edge (two faces
// that are tangent along their curve) is flat however its vertices move: the edge needs
// a second tetrahedron. A vertex inside, off the edge's midpoint along the inward
// normal, splits the edge's dihedral angle.
let bnd: Vec<usize> = (0..4).filter(|&k| ns[k] == NONE).collect();
if let &[i, j] = bnd.as_slice() {
let edge: Vec<u32> = (0..4).filter(|&k| k != i && k != j).map(|k| vs[k]).collect();
if let &[a, b] = edge.as_slice() {
let n = |k: usize| -> Result<P> {
let f = face_toward(vs, k);
Ok(measure::triangle_normal(cx.xyz(f[0])?, cx.xyz(f[1])?, cx.xyz(f[2])?))
};
let inward = measure::add(n(i)?, n(j)?);
let len = measure::norm(inward);
let (pa, pb) = (cx.xyz(a)?, cx.xyz(b)?);
let l = measure::dist(pa, pb);
// Unit inward normals within 25° of each other: |n_i + n_j| > 2 cos 12.5°.
if len > 1.952 {
let target = measure::add(measure::mid(pa, pb), measure::scale(inward, 0.4 * l / len));
if let Ok(p) = Point3::try_from(target)
&& let Some(Walk::Inside(at)) = cx.walk(t, &p, 1 << 12)?
&& attempt_insert(cx, ctx, p, VertexKind::Interior, Site::Interior(at))?
{
done = true;
}
}
}
}
// Boundary faces' barycentres.
for k in 0..4 {
if done || ns[k] != NONE || tags[k] == NONE {
continue;
}
let f = face_toward(vs, k);
let g = measure::scale(measure::add(measure::add(cx.xyz(f[0])?, cx.xyz(f[1])?), cx.xyz(f[2])?), 1.0 / 3.0);
let kind = VertexKind::Face(tags[k]);
if let Some(p) = ctx.place(kind, g)
&& cx.is_live(t)
&& attempt_insert(cx, ctx, p, kind, Site::Face(t, k))?
{
done = true;
}
}
// The barycentre.
if !done && cx.is_live(t) && cx.tet(t)? == vs {
let g = measure::centroid(&cx.coords(vs)?);
if let Ok(p) = Point3::try_from(g)
&& attempt_insert(cx, ctx, p, VertexKind::Interior, Site::Interior(t))?
{
done = true;
}
}
// Boundary edges' midpoints.
if !done && cx.is_live(t) && cx.tet(t)? == vs {
for &(i, j) in &EDGES {
let (a, b) = (vs[i], vs[j]);
let Some(ring) = cx.edge_ring(a, b)? else { continue };
if ring.closed {
continue;
}
let tag = ring
.tets
.first()
.and_then(|&r| {
let rv = cx.tet(r).ok()?;
let rt = cx.tags(r).ok()?;
let rn = cx.adj(r).ok()?;
(0..4).find(|&k| rn[k] == NONE && face_toward(rv, k).contains(&a) && face_toward(rv, k).contains(&b)).map(|k| rt[k])
})
.unwrap_or(NONE);
let kind = match cx.curve(a, b) {
Some(c) => VertexKind::Curve(c),
None if tag != NONE => VertexKind::Face(tag),
None => continue,
};
let m = measure::mid(cx.xyz(a)?, cx.xyz(b)?);
if let Some(p) = ctx.place(kind, m)
&& attempt_insert(cx, ctx, p, kind, Site::Edge(a, b))?
{
done = true;
break;
}
if !cx.is_live(t) {
break;
}
}
}
kept += usize::from(done);
}
Ok(kept)
}
/// The improvement schedule (see the module docs). Each pass smooths and runs a topological
/// pass over the tetrahedra below the threshold, then an insertion pass when the worst
/// tetrahedron did not improve; it stops after three passes without progress (§4.2).
pub fn improve(cx: &mut TetComplex, ctx: &Context, o: &ImproveOptions) -> Result<ImproveStats> {
let mut stats = ImproveStats::default();
stats.smoothed += smoothing_pass(cx, ctx, o.good)?;
let list = below(cx, o.good)?;
stats.topological += topological_pass(cx, &list)?;
let mut failed = 0;
// A pass ends where the next begins: its indicators are the next one's starting point.
let mut carried: Option<[f64; 8]> = None;
while failed < 3 {
if stats.passes >= o.max_passes {
stats.stopped = Some(format!("improvement stopped after {} passes", o.max_passes));
break;
}
stats.passes += 1;
let before = match carried.take() {
Some(b) => b,
None => indicators(cx)?,
};
stats.smoothed += smoothing_pass(cx, ctx, o.good)?;
let list = below(cx, o.good)?;
stats.topological += topological_pass(cx, &list)?;
// Insertion when smoothing and flips did not make enough progress (§4.2), or left a
// tetrahedron below the target.
let mid = indicators(cx)?;
if (!progressed(&mid, &before) || mid[0] < o.target) && stats.inserted < o.max_insertions {
// The worst 3.5% of the tetrahedra, or (desperation pass, §4.2) every one with a
// dihedral angle below 40° or above 140° (sine below sin 40°).
let mut scored: Vec<(f64, u32)> = Vec::new();
for (t, vs) in cx.live_tets() {
scored.push((tet_quality(cx, vs)?, t));
}
scored.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
let list: Vec<u32> = if failed == 1 {
scored.iter().filter(|s| s.0 < SIN_40).take(MAX_INSERTION_LIST).map(|s| s.1).collect()
} else {
let n = (scored.len() * 35).div_ceil(1000).clamp(1, MAX_INSERTION_LIST);
scored.iter().take(n).map(|s| s.1).collect()
};
let budget = o.max_insertions - stats.inserted;
stats.inserted += insertion_pass(cx, ctx, &list, budget, o.good)?;
}
let now = indicators(cx)?;
carried = Some(now);
stats.worst.push(now[0]);
if progressed(&now, &before) {
failed = 0;
} else {
failed += 1;
}
}
if stats.stopped.is_none() && stats.inserted >= o.max_insertions {
stats.stopped = Some(format!("improvement stopped at {} inserted vertices", o.max_insertions));
}
Ok(stats)
}