forked from 1b-agent/fieldcraft-upstream
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:
1 parent
fed27d816d
commit
9469ac0853
53 files changed
+5880
-278
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user