Files
OneCAD-Simulation/crates/mesh/src/quality/conformity.rs
T
jyvikoandClaude Opus 5.5 9e38fe866d mesh: conformity check finds cracks (free faces, open edges, duplicate nodes, mid-side mismatches)
`quality::conformity` checks a solid mesh for cracks: the free faces must
close up (every corner edge on exactly two), no two distinct nodes may
coincide, and every element edge has one mid-side node. The hex tests
assert it for every method, hex20 included, and the crank arm sample's
7274 hex20 is conforming (#260: the disjoint lines are a display issue).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 22:13:15 -04:00

220 lines
9.9 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.
//! Whether a solid mesh is conforming: the checks that find cracks.
//!
//! A mesh of solids without cracks has
//! - free faces (element faces used by one element) that form a closed 2-manifold: every corner
//! edge of a free face lies on exactly two free faces ([`Conformity::open_edges`]),
//! - no two distinct nodes at the same place ([`Conformity::coincident`]): a duplicate node
//! splits the elements around it, and the faces between them become internal free faces,
//! - one mid-side node per element edge for quadratic kinds ([`Conformity::midside_mismatches`]):
//! two elements on the same corner edge with different mid-side nodes are joined at the
//! corners and open along the edge.
//!
//! The report is deterministic: every list is sorted.
use fieldcraft_model::ElementKind;
use crate::{Error, Result};
/// What [`conformity`] found.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Conformity {
/// Free faces, as (element, face number from 1 as [`ElementKind::faces`] numbers them).
pub free_faces: Vec<(u32, u8)>,
/// Corner edges (sorted node pair) of free faces that are not on exactly two free faces.
pub open_edges: Vec<[u32; 2]>,
/// Pairs of distinct nodes (lower first) closer than the tolerance.
pub coincident: Vec<[u32; 2]>,
/// Corner edges (sorted node pair) that elements give different mid-side nodes.
pub midside_mismatches: Vec<[u32; 2]>,
}
impl Conformity {
/// No open edges, no coincident nodes and no mismatched mid-side nodes.
pub fn is_conforming(&self) -> bool {
self.open_edges.is_empty() && self.coincident.is_empty() && self.midside_mismatches.is_empty()
}
}
/// Check the solid elements (`(label, kind, nodes)`; shells and line elements are skipped) over
/// the nodes `(label, xyz)`: nodes closer than `tolerance` count as coincident. Elements that
/// name a node missing from `nodes` are an error, as are non-finite coordinates or tolerance.
pub fn conformity<'a>(
nodes: &[(u32, [f64; 3])],
elements: impl IntoIterator<Item = (u32, ElementKind, &'a [u32])>,
tolerance: f64,
) -> Result<Conformity> {
if !(tolerance >= 0.0 && tolerance.is_finite()) {
return Err(Error::Invalid(format!("the tolerance must be a non-negative number, got {tolerance}")));
}
let mut labels: Vec<u32> = nodes.iter().map(|n| n.0).collect();
labels.sort_unstable();
// (sorted corners, corners in order, element, face), triangles padded with `u32::MAX`.
let mut faces: Vec<([u32; 4], [u32; 4], u32, u8)> = Vec::new();
// (corner pair, mid-side node) of every edge of a quadratic element.
let mut mids: Vec<([u32; 2], u32)> = Vec::new();
for (label, kind, conn) in elements {
if kind.dimension() != 3 {
continue;
}
if conn.len() != kind.node_count() {
return Err(Error::Invalid(format!("element {label} has {} nodes; a {} has {}", conn.len(), kind.name(), kind.node_count())));
}
if let Some(n) = conn.iter().find(|n| labels.binary_search(n).is_err()) {
return Err(Error::Invalid(format!("element {label} names node {n}, which does not exist")));
}
let node = |i: usize| conn.get(i).copied().unwrap_or(u32::MAX);
for (k, f) in kind.faces().iter().enumerate() {
let mut ordered = [u32::MAX; 4];
for (slot, &i) in ordered.iter_mut().zip(f.iter().take(face_corners(f.len()))) {
*slot = node(i);
}
let mut key = ordered;
key.sort_unstable();
faces.push((key, ordered, label, u8::try_from(k + 1).unwrap_or(u8::MAX)));
}
if kind.is_quadratic() {
for e in kind.edges() {
if let (Some(&a), Some(&b), Some(&m)) = (e.first(), e.get(1), e.get(2)) {
let (a, b) = (node(a), node(b));
mids.push(([a.min(b), a.max(b)], node(m)));
}
}
}
}
faces.sort_unstable();
let mut out = Conformity::default();
let mut rim: Vec<[u32; 2]> = Vec::new();
for run in faces.chunk_by(|a, b| a.0 == b.0) {
let [(_, ordered, element, face)] = run else { continue };
out.free_faces.push((*element, *face));
let ordered: Vec<u32> = ordered.iter().copied().filter(|&n| n != u32::MAX).collect();
for (j, &a) in ordered.iter().enumerate() {
let b = ordered.get((j + 1) % ordered.len()).copied().unwrap_or(a);
rim.push([a.min(b), a.max(b)]);
}
}
out.free_faces.sort_unstable();
rim.sort_unstable();
out.open_edges = rim.chunk_by(|a, b| a == b).filter(|r| r.len() != 2).filter_map(|r| r.first().copied()).collect();
mids.sort_unstable();
mids.dedup();
out.midside_mismatches = mids.chunk_by(|a, b| a.0 == b.0).filter(|r| r.len() > 1).filter_map(|r| r.first().map(|m| m.0)).collect();
out.coincident = coincident(nodes, tolerance)?;
Ok(out)
}
/// Corners of a face with `n` node positions: a quadratic face lists its mid-side nodes after
/// its corners, as many of each.
fn face_corners(n: usize) -> usize {
match n {
6 | 8 => n / 2,
n => n,
}
}
/// Pairs of distinct nodes closer than `tol`: nodes are bucketed in a grid of cells `tol` wide
/// (sorted, so the work stays O(n log n) on any input) and compared with the 27 cells around.
fn coincident(nodes: &[(u32, [f64; 3])], tol: f64) -> Result<Vec<[u32; 2]>> {
if let Some((l, _)) = nodes.iter().find(|(_, p)| !p.iter().all(|v| v.is_finite())) {
return Err(Error::Invalid(format!("node {l} has a non-finite coordinate")));
}
// A zero tolerance still needs a cell size; exact copies share a cell at any size.
let h = if tol > 0.0 { tol } else { 1.0 };
let cell = |p: &[f64; 3]| p.map(|v| (v / h).floor() as i64);
let mut grid: Vec<([i64; 3], u32, [f64; 3])> = nodes.iter().map(|(l, p)| (cell(p), *l, *p)).collect();
grid.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let mut out = Vec::new();
for (c, la, pa) in &grid {
for d in 0..27i64 {
let near = [c[0].saturating_add(d % 3 - 1), c[1].saturating_add((d / 3) % 3 - 1), c[2].saturating_add(d / 9 - 1)];
let start = grid.partition_point(|g| g.0 < near);
for (_, lb, pb) in grid.get(start..).unwrap_or(&[]).iter().take_while(|g| g.0 == near) {
let d2: f64 = (0..3).map(|k| (pa[k] - pb[k]).powi(2)).sum();
if la < lb && d2 <= tol * tol {
out.push([*la, *lb]);
}
}
}
}
out.sort_unstable();
out.dedup();
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
/// Two unit hex8 cells side by side along x (nodes 1–12), elements 1 and 2.
fn pair() -> (Vec<(u32, [f64; 3])>, Vec<Vec<u32>>) {
let mut nodes = Vec::new();
for k in 0..2 {
for j in 0..2 {
for i in 0..3 {
nodes.push((1 + i + 3 * j + 6 * k, [f64::from(i), f64::from(j), f64::from(k)]));
}
}
}
let hex = |i: u32| vec![1 + i, 2 + i, 5 + i, 4 + i, 7 + i, 8 + i, 11 + i, 10 + i];
(nodes, vec![hex(0), hex(1)])
}
fn check(nodes: &[(u32, [f64; 3])], elements: &[Vec<u32>], kind: ElementKind) -> Conformity {
conformity(nodes, elements.iter().enumerate().map(|(e, n)| (e as u32 + 1, kind, n.as_slice())), 1e-9).unwrap()
}
#[test]
fn a_conforming_pair_has_a_closed_skin() {
let (nodes, elements) = pair();
let c = check(&nodes, &elements, ElementKind::Hex8);
assert!(c.is_conforming(), "{c:?}");
assert_eq!(c.free_faces.len(), 10);
assert!(!c.free_faces.contains(&(1, 4)) && !c.free_faces.contains(&(2, 6)), "the shared face is inside");
}
#[test]
fn a_duplicated_node_is_a_crack() {
let (mut nodes, mut elements) = pair();
// Element 2 takes its own copy of node 2 (at x = 1).
nodes.push((99, [1.0, 0.0, 0.0]));
elements[1][0] = 99;
let c = check(&nodes, &elements, ElementKind::Hex8);
assert_eq!(c.coincident, vec![[2, 99]]);
assert!(!c.open_edges.is_empty(), "the faces beside the copy no longer close up");
assert_eq!(c.free_faces.len(), 12, "the faces between the elements are free");
}
#[test]
fn different_mid_side_nodes_on_one_edge_are_a_crack() {
let (mut nodes, elements) = pair();
let mut next = 100;
let mut quadratic = Vec::new();
for (e, n) in elements.iter().enumerate() {
let mut q = n.clone();
for edge in ElementKind::Hex20.edges() {
next += 1;
let (a, b) = (n[edge[0]], n[edge[1]]);
let (pa, pb) = (nodes.iter().find(|x| x.0 == a).unwrap().1, nodes.iter().find(|x| x.0 == b).unwrap().1);
nodes.push((next, [0.5 * (pa[0] + pb[0]), 0.5 * (pa[1] + pb[1]), 0.5 * (pa[2] + pb[2]) + e as f64 * 1e-3]));
q.push(next);
}
quadratic.push(q);
}
let c = check(&nodes, &quadratic, ElementKind::Hex20);
// The four edges of the shared face got a mid-side node from each element.
assert_eq!(c.midside_mismatches.len(), 4);
assert!(c.open_edges.is_empty(), "the corners still close up");
}
#[test]
fn hostile_input_is_an_error() {
let (nodes, elements) = pair();
assert!(conformity(&nodes, std::iter::once((1, ElementKind::Hex8, &elements[0][..4])), 0.0).is_err());
assert!(conformity(&nodes, std::iter::once((1, ElementKind::Hex8, &[1, 2, 3, 4, 5, 6, 7, 1000][..])), 0.0).is_err());
assert!(conformity(&nodes, std::iter::empty(), f64::NAN).is_err());
assert!(conformity(&[(1, [f64::INFINITY, 0.0, 0.0])], std::iter::empty(), 0.0).is_err());
let far = [(1, [1e300, 0.0, 0.0]), (2, [1e300, 0.0, 0.0]), (3, [-1e300, 0.0, 0.0])];
assert_eq!(conformity(&far, std::iter::empty(), 1e-300).unwrap().coincident, vec![[1, 2]]);
}
}