Element quality metrics and the certified validity gate (mesh.quality) (#12)

* mesh: element quality metrics and a certified Jacobian validity check

Bernstein-form Jacobian determinants for tet4/tet10/wedge6/hex8/hex20, certified
by bounds and subdivision (Johnen-Remacle-Geuzaine 2013); Shewchuk 2002 tet
metrics, Knupp 2001 hex/wedge/quad metrics; quality report and the validity
gate meshers run on their output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: mesh.quality command; model documents its node order

mesh.quality reports certified validity, volume and per-metric statistics with
fixed-range histograms. The model's ElementKind doc states the Abaqus/VTK node
order and that deck code reorders formats like Nastran CHEXA20.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* quality: simplify after review; exact orient3d for tet4; gate on raw mesher arrays

- tet4 validity from the exact orient3d of the input coordinates
- validity_gate_elements takes (label, kind, coordinates) straight from a mesher
- one Jacobian per solid; certify caches piece bounds; product weights precomputed
- model::check_coordinates shared by set_node and quality; ElementKind is Ord
- engine: shared labels() parameter parser

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 15:14:30 -04:00
1 parent 62b0553a89
commit d8b3bb51e0
15 files changed
+1981 -17

No files matched your search

Generated
+1
View File
@@ -455,6 +455,7 @@ version = "0.1.0"
dependencies = [
"log",
"modelcraft-geometry",
"modelcraft-mesh",
"modelcraft-model",
"serde",
"serde_json",
+1 -1
View File
@@ -6,7 +6,7 @@ LS-PrePost, with hexahedral meshing as the centre of gravity, plus its own linea
| Milestone | Scope | Status |
|---|---|---|
| M0 Foundation | Workspace, quality gates (`cargo xtask ci`), FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft's kernel and tessellator | done |
| M1 Surface preparation | Geometry in: open `.solidcraft` designs, rebuilt by SolidCraft, with persistent face names (done); face-tagged 3MF files (`geometry.import.mesh`, docs/formats/tagged-mesh.md, done); exact predicates (Shewchuk adaptive, symbolic perturbation, indirect LPI/TPI, done); weld, closed/manifold/orientation checks and feature edges on the tessellated boundary; mesh quality metrics | in progress |
| M1 Surface preparation | Geometry in: open `.solidcraft` designs, rebuilt by SolidCraft, with persistent face names (done); face-tagged 3MF files (`geometry.import.mesh`, docs/formats/tagged-mesh.md, done); exact predicates (Shewchuk adaptive, symbolic perturbation, indirect LPI/TPI, done); weld, closed/manifold/orientation checks and feature edges on the tessellated boundary; mesh quality metrics and the certified validity gate (`modelcraft-mesh::quality`, `mesh.quality`, done) | in progress |
| M2 Tetrahedral meshing | Bowyer–Watson Delaunay, robust constrained Delaunay boundary recovery (Diazzi et al. 2023), Delaunay refinement to a size field, ODT smoothing, Klingner–Shewchuk sliver removal; linear and quadratic tets | planned |
| M3 Hexahedral meshing (core) | Octree/grid dual all-hex (valid on any input), then hex-dominant by tet recombination, then polycube for blocky parts; see docs/meshing/literature.md | planned |
| M4 Model definition | Materials, sections, sets and surfaces, boundary conditions, loads, steps, output requests | planned |
+1
View File
@@ -11,6 +11,7 @@ workspace = true
[dependencies]
modelcraft-model = { workspace = true }
modelcraft-geometry = { workspace = true }
modelcraft-mesh = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+106 -10
View File
@@ -1,7 +1,8 @@
use modelcraft_model::{ElementKind, MAX_LABEL};
use modelcraft_mesh::quality::{self, QualityReport};
use modelcraft_model::{ElementKind, MAX_ELEMENTS};
use serde_json::{Value, json};
use super::{label, xyz};
use super::{label, labels, xyz};
use crate::{CommandSpec, EngineError, Result, Session};
pub const NODE_ADD: CommandSpec = CommandSpec {
@@ -24,6 +25,13 @@ pub const ELEMENT_ADD: CommandSpec = CommandSpec {
pub const ELEMENT_REMOVE: CommandSpec =
CommandSpec { id: "mesh.element.remove", label: "Remove Element", params: r#"{"label": 1}"#, run: element_remove };
pub const QUALITY: CommandSpec = CommandSpec {
id: "mesh.quality",
label: "Mesh Quality",
params: r#"{"elements": [labels] (optional, default all), "bins": 10 (1..=1000)} — certified validity (det J > 0 everywhere), volume, and min/max/mean/worst/histogram per metric (angles in degrees; histograms over each metric's fixed range, out-of-range values in the end bins)"#,
run: quality_report,
};
fn node_add(s: &mut Session, p: &Value) -> Result<Value> {
let l = label(p, "label")?;
s.model.set_node(l, xyz(p, "xyz")?)?;
@@ -39,15 +47,10 @@ fn element_add(s: &mut Session, p: &Value) -> Result<Value> {
let l = label(p, "label")?;
let name = p.get("kind").and_then(Value::as_str).unwrap_or_default();
let kind = ElementKind::from_name(name).ok_or_else(|| EngineError::Params(format!("unknown element kind `{name}`")))?;
let raw = p.get("nodes").and_then(Value::as_array).ok_or_else(|| EngineError::Params("`nodes` must be a list of node labels".into()))?;
if raw.len() != kind.node_count() {
return Err(EngineError::Params(format!("{name} needs {} nodes, got {}", kind.node_count(), raw.len())));
let nodes = labels(p, "nodes", ElementKind::Hex20.node_count())?;
if nodes.len() != kind.node_count() {
return Err(EngineError::Params(format!("{name} needs {} nodes, got {}", kind.node_count(), nodes.len())));
}
let nodes = raw
.iter()
.map(|v| v.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|n| (1..=MAX_LABEL).contains(n)))
.collect::<Option<Vec<u32>>>()
.ok_or_else(|| EngineError::Params(format!("`nodes` must be node labels in 1..={MAX_LABEL}")))?;
s.model.set_element(l, kind, nodes)?;
Ok(json!({"label": l}))
}
@@ -56,3 +59,96 @@ fn element_remove(s: &mut Session, p: &Value) -> Result<Value> {
s.model.remove_element(label(p, "label")?)?;
Ok(Value::Null)
}
/// Labels listed in a report: the first ones, and how many there are.
const LISTED: usize = 100;
/// A JSON number, or a string for ±∞ (JSON has no infinity).
fn number(v: f64) -> Value {
if v.is_finite() { json!(v) } else { json!(if v > 0.0 { "inf" } else { "-inf" }) }
}
fn listed(v: &[u32]) -> Value {
json!({"count": v.len(), "labels": v.iter().take(LISTED).collect::<Vec<_>>()})
}
fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
let bins = match p.get("bins") {
None => 10,
Some(v) => v.as_u64().and_then(|b| usize::try_from(b).ok()).ok_or_else(|| EngineError::Params("`bins` must be a whole number".into()))?,
};
let selection = match p.get("elements") {
None => None,
Some(_) => Some(labels(p, "elements", MAX_ELEMENTS)?),
};
let r: QualityReport = quality::quality_report(&s.model, selection.as_deref(), bins)?;
let skipped: serde_json::Map<String, Value> = r.skipped.iter().map(|(k, n)| (k.name().to_string(), json!(n))).collect();
let metrics: serde_json::Map<String, Value> = r
.metrics
.iter()
.map(|m| {
let (lo, hi) = m.metric.range();
let stats = json!({
"count": m.count,
"min": number(m.min),
"max": number(m.max),
"mean": m.mean.map(number),
"worst": m.worst,
"histogram": {"range": [lo, hi], "counts": m.histogram},
});
(m.metric.name().to_string(), stats)
})
.collect();
Ok(json!({
"measured": r.measured,
"skipped": skipped,
"valid": r.valid,
"invalid": listed(&r.invalid),
"undetermined": listed(&r.undetermined),
"volume": r.volume,
"area": r.area,
"metrics": metrics,
}))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
fn hex(s: &mut Session, label: u32, first_node: u32, mirror: bool) {
let corners = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
for (i, c) in corners.iter().enumerate() {
let x = if mirror { -c[0] - 2 } else { c[0] };
s.execute("mesh.node.add", &json!({"label": first_node + i as u32, "xyz": [x, c[1], c[2]]})).unwrap();
}
let nodes: Vec<u32> = (first_node..first_node + 8).collect();
s.execute("mesh.element.add", &json!({"label": label, "kind": "hex8", "nodes": nodes})).unwrap();
}
#[test]
fn reports_quality_and_inverted_elements() {
let mut s = Session::default();
hex(&mut s, 1, 1, false);
hex(&mut s, 2, 11, true);
let journal = s.journal.len();
let r = s.execute("mesh.quality", &json!({"bins": 2})).unwrap();
assert_eq!(r["measured"], 2);
assert_eq!(r["valid"], 1);
assert_eq!(r["invalid"], json!({"count": 1, "labels": [2]}));
assert_eq!(r["volume"], 0.0);
let sj = &r["metrics"]["scaled_jacobian"];
assert_eq!((sj["min"].clone(), sj["max"].clone(), sj["worst"].clone()), (json!(-1.0), json!(1.0), json!(2)));
assert_eq!(sj["histogram"]["counts"], json!([1, 1]));
assert_eq!(s.journal.len(), journal, "a report does not change the model");
let one = s.execute("mesh.quality", &json!({"elements": [1, 1]})).unwrap();
assert_eq!((one["measured"].clone(), one["volume"].clone()), (json!(1), json!(1.0)));
assert!(s.execute("mesh.quality", &json!({"elements": [3]})).is_err());
assert!(s.execute("mesh.quality", &json!({"elements": [0]})).is_err());
assert!(s.execute("mesh.quality", &json!({"bins": 0})).is_err());
assert!(s.execute("mesh.quality", &json!({"bins": 1e9})).is_err());
assert!(s.execute("mesh.quality", &json!({"elements": "all"})).is_err());
}
}
+15
View File
@@ -4,6 +4,7 @@ mod geometry;
mod mesh;
mod model;
use modelcraft_model::MAX_LABEL;
use serde_json::Value;
use crate::{CommandSpec, EngineError, Result};
@@ -18,6 +19,7 @@ pub static ALL: &[CommandSpec] = &[
mesh::NODE_REMOVE,
mesh::ELEMENT_ADD,
mesh::ELEMENT_REMOVE,
mesh::QUALITY,
];
/// A required label parameter (1..=u32::MAX; the model checks its own range).
@@ -38,3 +40,16 @@ pub(crate) fn xyz(p: &Value, key: &str) -> Result<[f64; 3]> {
}
Ok(out)
}
/// A required list of labels (each in 1..=MAX_LABEL), at most `max_len` long.
pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result<Vec<u32>> {
let raw = p
.get(key)
.and_then(Value::as_array)
.filter(|a| a.len() <= max_len)
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a list of at most {max_len} labels")))?;
raw.iter()
.map(|v| v.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|n| (1..=MAX_LABEL).contains(n)))
.collect::<Option<Vec<u32>>>()
.ok_or_else(|| EngineError::Params(format!("`{key}` must be labels in 1..={MAX_LABEL}")))
}
+5
View File
@@ -26,6 +26,8 @@ pub enum EngineError {
Model(#[from] modelcraft_model::Error),
#[error(transparent)]
Geometry(#[from] modelcraft_geometry::Error),
#[error(transparent)]
Mesh(#[from] modelcraft_mesh::Error),
/// A command panicked; the model is unchanged.
#[error("internal error in {command}: {message}")]
Internal { command: String, message: String },
@@ -173,6 +175,9 @@ mod tests {
json!({"path": 7}),
json!({"body": "Body1", "tolerance": 0.05}),
json!({"body": null, "tolerance": "fine"}),
json!({"elements": [u32::MAX, -1, 1.5]}),
json!({"elements": {}, "bins": -5}),
json!({"elements": [1], "bins": 1e300}),
];
for spec in commands() {
for p in &hostile {
+2
View File
@@ -7,6 +7,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod quality;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum Error {
#[error("invalid input: {0}")]
+505
View File
@@ -0,0 +1,505 @@
//! Bernstein–Bézier polynomials on the reference domains of solid elements.
//!
//! A reference domain is a product of simplices: three segments for a hexahedron, a triangle
//! times a segment for a wedge, one tetrahedron for a tetrahedron. On a simplex of dimension `d`
//! a polynomial of degree `n` is written in the Bernstein basis
//! `B_α(λ) = n! / (α_0! … α_d!) · λ_0^α_0 … λ_d^α_d` (`|α| = n`, `λ` barycentric coordinates);
//! on a product the basis is the product of each factor's basis.
//!
//! Three properties make the basis the right tool for certifying an element's Jacobian
//! (Johnen, Remacle, Geuzaine, *Geometrical validity of curvilinear finite elements*,
//! J. Comput. Phys. 233:359–372, 2013):
//! 1. The basis is a partition of unity with non-negative terms, so the polynomial lies between
//! its smallest and its largest coefficient everywhere on the domain.
//! 2. A coefficient whose multi-index sits at a vertex of the domain is the polynomial's value
//! there.
//! 3. Subdividing the domain (de Casteljau) gives the coefficients on each piece, and they converge
//! to the values as the pieces shrink.
//!
//! So a positive smallest coefficient proves positivity, a non-positive vertex coefficient proves
//! the opposite, and subdivision settles the cases in between. The product rule
//! `B^m_α · B^n_β = C(m;α) C(n;β) / C(m+n;α+β) · B^{m+n}_{α+β}` (multinomials `C`) and the
//! derivative rule `∂p/∂λ_k − ∂p/∂λ_0 = n Σ_β (c_{β+e_k} − c_{β+e_0}) B^{n−1}_β` are the standard
//! ones (Farin, *Curves and Surfaces for CAGD*, 5th ed., ch. 17–18).
use crate::{Error, Result};
/// Highest degree per factor. The Jacobian determinant of a 20-node hexahedron has degree 5.
pub(crate) const MAX_DEGREE: usize = 8;
/// Free barycentric components of a multi-index: three for every 3-D reference domain (the
/// component `α_0` of each factor is implied by its degree).
const FREE: usize = 3;
type Multi = [usize; FREE];
/// One simplex factor of a reference domain (`dim` 1 is a segment).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Factor {
pub dim: usize,
pub deg: usize,
}
/// A polynomial in Bernstein form on a product of simplices.
///
/// Coefficients are stored densely over `(deg + 1)^dim` slots per factor; slots whose free
/// components exceed the factor's degree are unused and stay zero.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Bernstein {
factors: Vec<Factor>,
coef: Vec<f64>,
}
fn factorial(n: usize) -> f64 {
(2..=n).map(|k| k as f64).product()
}
impl Bernstein {
pub fn zero(factors: &[Factor]) -> Result<Self> {
let free: usize = factors.iter().map(|f| f.dim).sum();
if free > FREE || factors.iter().any(|f| f.dim == 0 || f.deg > MAX_DEGREE) {
return Err(Error::Invalid(format!("unsupported Bernstein domain {factors:?}")));
}
let len = factors.iter().map(|f| (f.deg + 1).pow(f.dim as u32)).product();
Ok(Self { factors: factors.to_vec(), coef: vec![0.0; len] })
}
pub fn factors(&self) -> &[Factor] {
&self.factors
}
/// Position of factor `f`'s first free component in a multi-index.
fn offset(&self, f: usize) -> usize {
self.factors.iter().take(f).map(|x| x.dim).sum()
}
fn slot(&self, m: &Multi) -> usize {
let mut s = 0;
let mut p = 0;
for f in &self.factors {
for _ in 0..f.dim {
s = s * (f.deg + 1) + m.get(p).copied().unwrap_or(0);
p += 1;
}
}
s
}
fn valid(&self, m: &Multi) -> bool {
let mut p = 0;
self.factors.iter().all(|f| {
let sum: usize = m.iter().skip(p).take(f.dim).sum();
p += f.dim;
sum <= f.deg
})
}
/// Every valid multi-index, in storage order.
fn multis(&self) -> Vec<Multi> {
let mut out = vec![[0; FREE]];
let mut p = 0;
for f in &self.factors {
for _ in 0..f.dim {
out = out
.into_iter()
.flat_map(|m| {
(0..=f.deg).map(move |c| {
let mut m = m;
if let Some(x) = m.get_mut(p) {
*x = c;
}
m
})
})
.collect();
p += 1;
}
}
out.retain(|m| self.valid(m));
out
}
fn get(&self, m: &Multi) -> f64 {
self.coef.get(self.slot(m)).copied().unwrap_or(0.0)
}
fn add_at(&mut self, m: &Multi, v: f64) {
let s = self.slot(m);
if let Some(c) = self.coef.get_mut(s) {
*c += v;
}
}
/// Set the coefficient of `m` (free components, factor after factor).
pub fn set(&mut self, m: Multi, v: f64) {
let s = self.slot(&m);
if self.valid(&m)
&& let Some(c) = self.coef.get_mut(s)
{
*c = v;
}
}
/// Barycentric component `c` (0..=dim) of factor `f` in `m`.
fn component(&self, m: &Multi, f: usize, c: usize) -> usize {
let (Some(fac), off) = (self.factors.get(f), self.offset(f)) else { return 0 };
if c == 0 { fac.deg.saturating_sub(m.iter().skip(off).take(fac.dim).sum()) } else { m.get(off + c - 1).copied().unwrap_or(0) }
}
/// The multinomial weight `n! / α!` of `m`, over all factors.
fn weight(&self, m: &Multi) -> f64 {
let mut w = 1.0;
for (f, fac) in self.factors.iter().enumerate() {
w *= factorial(fac.deg);
for c in 0..=fac.dim {
w /= factorial(self.component(m, f, c));
}
}
w
}
fn same_domain(&self, o: &Self) -> bool {
self.factors.len() == o.factors.len() && self.factors.iter().zip(&o.factors).all(|(a, b)| a.dim == b.dim)
}
/// The multinomial weight of every slot (zero for unused slots).
fn weights(&self) -> Vec<f64> {
let mut w = vec![0.0; self.coef.len()];
for m in self.multis() {
let s = self.slot(&m);
if let Some(x) = w.get_mut(s) {
*x = self.weight(&m);
}
}
w
}
/// The product of two polynomials on the same domain.
pub fn mul(&self, o: &Self) -> Result<Self> {
if !self.same_domain(o) {
return Err(Error::Invalid("Bernstein product of different domains".into()));
}
let factors: Vec<Factor> = self.factors.iter().zip(&o.factors).map(|(a, b)| Factor { dim: a.dim, deg: a.deg + b.deg }).collect();
let mut out = Self::zero(&factors)?;
let out_w = out.weights();
let terms =
|p: &Self| -> Vec<(Multi, f64)> { p.multis().into_iter().map(|m| (m, p.get(&m) * p.weight(&m))).filter(|(_, c)| *c != 0.0).collect() };
let right = terms(o);
for (a, ca) in terms(self) {
for (b, cb) in &right {
let mut k = a;
for (x, y) in k.iter_mut().zip(b) {
*x += y;
}
let s = out.slot(&k);
if let (Some(c), Some(w)) = (out.coef.get_mut(s), out_w.get(s)) {
*c += ca * cb / w;
}
}
}
Ok(out)
}
/// `self + s · o`, both of the same domain and degrees.
pub fn axpy(&self, s: f64, o: &Self) -> Result<Self> {
if self.factors != o.factors {
return Err(Error::Invalid("Bernstein sum of different spaces".into()));
}
let coef = self.coef.iter().zip(&o.coef).map(|(a, b)| a + s * b).collect();
Ok(Self { factors: self.factors.clone(), coef })
}
/// The derivative along reference direction `k` (1..=dim) of factor `f`: the direction from
/// that factor's vertex 0 to its vertex `k`, with the other coordinates held fixed.
pub fn derivative(&self, f: usize, k: usize) -> Result<Self> {
let fac = self.factors.get(f).copied().ok_or_else(|| Error::Invalid(format!("no factor {f}")))?;
if k == 0 || k > fac.dim || fac.deg == 0 {
return Err(Error::Invalid(format!("no derivative {k} of factor {fac:?}")));
}
let mut factors = self.factors.clone();
if let Some(x) = factors.get_mut(f) {
x.deg -= 1;
}
let mut out = Self::zero(&factors)?;
let pos = self.offset(f) + k - 1;
for b in out.multis() {
let mut hi = b;
if let Some(x) = hi.get_mut(pos) {
*x += 1;
}
let v = fac.deg as f64 * (self.get(&hi) - self.get(&b));
out.add_at(&b, v);
}
Ok(out)
}
/// Split factor `f` at the midpoint of its edge `(i, j)` (vertex numbers 0..=dim) with de
/// Casteljau's algorithm. The first piece keeps vertex `i` (vertex `j` becomes the midpoint),
/// the second keeps vertex `j` (vertex `i` becomes the midpoint).
pub fn bisect(&self, f: usize, i: usize, j: usize) -> Result<(Self, Self)> {
let fac = self.factors.get(f).copied().ok_or_else(|| Error::Invalid(format!("no factor {f}")))?;
if i == j || i > fac.dim || j > fac.dim {
return Err(Error::Invalid(format!("no edge ({i}, {j}) in {fac:?}")));
}
let off = self.offset(f);
let shift = |start: &Multi, r: usize| {
let mut m = *start;
if i > 0
&& let Some(x) = m.get_mut(off + i - 1)
{
*x -= r;
}
if j > 0
&& let Some(x) = m.get_mut(off + j - 1)
{
*x += r;
}
m
};
let (mut a, mut b) = (self.clone(), self.clone());
// Each line starts where component j is zero; walking it moves units from i to j.
for start in self.multis() {
if self.component(&start, f, j) != 0 {
continue;
}
let s = self.component(&start, f, i);
let line: Vec<Multi> = (0..=s).map(|r| shift(&start, r)).collect();
let mut tri: Vec<f64> = line.iter().map(|m| self.get(m)).collect();
let n = tri.len();
let mut left = vec![0.0; n];
let mut right = vec![0.0; n];
for level in 0..n {
let last = n - 1 - level;
if let (Some(l), Some(&first)) = (left.get_mut(level), tri.first()) {
*l = first;
}
if let (Some(r), Some(&end)) = (right.get_mut(last), tri.get(last)) {
*r = end;
}
for r in 0..last {
if let (Some(&x), Some(&y)) = (tri.get(r), tri.get(r + 1))
&& let Some(t) = tri.get_mut(r)
{
*t = 0.5 * (x + y);
}
}
}
for ((m, l), r) in line.iter().zip(&left).zip(&right) {
let s = self.slot(m);
if let Some(c) = a.coef.get_mut(s) {
*c = *l;
}
if let Some(c) = b.coef.get_mut(s) {
*c = *r;
}
}
}
Ok((a, b))
}
/// The smallest and largest coefficient: bounds of the polynomial on its domain.
pub fn bounds(&self) -> (f64, f64) {
self.multis().iter().map(|m| self.get(m)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), c| (lo.min(c), hi.max(c)))
}
/// The value at a vertex of the domain: one vertex number (0..=dim) per factor.
pub fn at_vertex(&self, verts: &[usize]) -> f64 {
let mut m = [0; FREE];
for (f, (fac, &v)) in self.factors.iter().zip(verts).enumerate() {
if v > 0
&& v <= fac.dim
&& let Some(x) = m.get_mut(self.offset(f) + v - 1)
{
*x = fac.deg;
}
}
self.get(&m)
}
/// Every vertex of the domain, as one vertex number per factor.
pub fn vertices(&self) -> Vec<Vec<usize>> {
self.factors.iter().fold(vec![Vec::new()], |acc, f| {
acc.into_iter()
.flat_map(|v| {
(0..=f.dim).map(move |k| {
let mut v = v.clone();
v.push(k);
v
})
})
.collect()
})
}
/// The values at every vertex of the domain.
pub fn vertex_values(&self) -> Vec<f64> {
self.vertices().iter().map(|v| self.at_vertex(v)).collect()
}
/// The integral over the reference domain (unit segment, unit right triangle and unit right
/// tetrahedron factors). Every basis function of a factor has the same integral,
/// `1 / (dim! · C(deg + dim, dim))`.
pub fn integral(&self) -> f64 {
let per: f64 = self.factors.iter().map(|f| factorial(f.deg) / factorial(f.deg + f.dim)).product();
self.multis().iter().map(|m| self.get(m)).sum::<f64>() * per
}
/// The value at a point given by barycentric coordinates per factor (`dim + 1` each).
pub fn eval(&self, bary: &[&[f64]]) -> f64 {
self.multis()
.iter()
.map(|m| {
let mut t = self.get(m) * self.weight(m);
for (f, fac) in self.factors.iter().enumerate() {
for c in 0..=fac.dim {
let l = bary.get(f).and_then(|b| b.get(c)).copied().unwrap_or(0.0);
t *= l.powi(self.component(m, f, c) as i32);
}
}
t
})
.sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn seg(deg: usize) -> Factor {
Factor { dim: 1, deg }
}
/// A pseudo-random polynomial (deterministic).
fn sample(factors: &[Factor], seed: u64) -> Bernstein {
let mut p = Bernstein::zero(factors).unwrap();
let mut s = seed;
for m in p.multis() {
s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
p.set(m, ((s >> 33) as f64 / (1u64 << 31) as f64) - 0.5);
}
p
}
fn points(dim: usize) -> Vec<Vec<f64>> {
match dim {
1 => vec![vec![0.7, 0.3], vec![0.1, 0.9], vec![1.0, 0.0]],
2 => vec![vec![0.2, 0.3, 0.5], vec![0.6, 0.1, 0.3], vec![0.0, 0.0, 1.0]],
_ => vec![vec![0.1, 0.2, 0.3, 0.4], vec![0.25; 4], vec![0.0, 1.0, 0.0, 0.0]],
}
}
fn domains() -> Vec<Vec<Factor>> {
vec![vec![seg(2), seg(1), seg(3)], vec![Factor { dim: 2, deg: 2 }, seg(1)], vec![Factor { dim: 3, deg: 2 }]]
}
/// Every combination of sample points, one per factor.
fn grid(factors: &[Factor]) -> Vec<Vec<Vec<f64>>> {
factors.iter().fold(vec![Vec::new()], |acc, f| {
acc.into_iter().flat_map(|v| points(f.dim).into_iter().map(move |p| [v.clone(), vec![p]].concat())).collect()
})
}
fn eval(p: &Bernstein, x: &[Vec<f64>]) -> f64 {
let refs: Vec<&[f64]> = x.iter().map(|v| v.as_slice()).collect();
p.eval(&refs)
}
#[test]
fn product_matches_pointwise_product() {
for d in domains() {
let a = sample(&d, 1);
let b = sample(&d.iter().map(|f| Factor { dim: f.dim, deg: 1 }).collect::<Vec<_>>(), 2);
let ab = a.mul(&b).unwrap();
for x in grid(&d) {
assert!((eval(&ab, &x) - eval(&a, &x) * eval(&b, &x)).abs() < 1e-12);
}
}
}
#[test]
fn derivative_matches_finite_difference() {
for d in domains() {
let p = sample(&d, 3);
for (f, fac) in d.iter().enumerate() {
for k in 1..=fac.dim {
let dp = p.derivative(f, k).unwrap();
for x in grid(&d) {
// Move along direction k: λ_k += h, λ_0 -= h.
let h = 1e-6;
let mut plus = x.clone();
let mut minus = x.clone();
plus[f][k] += h;
plus[f][0] -= h;
minus[f][k] -= h;
minus[f][0] += h;
let fd = (eval(&p, &plus) - eval(&p, &minus)) / (2.0 * h);
assert!((eval(&dp, &x) - fd).abs() < 1e-6, "{fd} vs {}", eval(&dp, &x));
}
}
}
}
}
#[test]
fn bisection_reproduces_the_polynomial() {
for d in domains() {
let p = sample(&d, 4);
for (f, fac) in d.iter().enumerate() {
for (i, j) in [(0, 1), (1, 0), (fac.dim, 0)] {
if i == j {
continue;
}
let (a, b) = p.bisect(f, i, j).unwrap();
for x in grid(&d) {
// Piece a: vertex j is the midpoint, so λ_i += λ_j / 2, λ_j /= 2.
let mut xa = x.clone();
let lj = xa[f][j];
xa[f][i] += lj / 2.0;
xa[f][j] = lj / 2.0;
assert!((eval(&a, &x) - eval(&p, &xa)).abs() < 1e-12);
let mut xb = x.clone();
let li = xb[f][i];
xb[f][j] += li / 2.0;
xb[f][i] = li / 2.0;
assert!((eval(&b, &x) - eval(&p, &xb)).abs() < 1e-12);
}
}
}
}
}
#[test]
fn vertices_bounds_and_integral() {
for d in domains() {
let p = sample(&d, 5);
let (lo, hi) = p.bounds();
for v in p.vertices() {
let x: Vec<Vec<f64>> = d.iter().zip(&v).map(|(f, &k)| (0..=f.dim).map(|c| if c == k { 1.0 } else { 0.0 }).collect()).collect();
let value = eval(&p, &x);
assert!((p.at_vertex(&v) - value).abs() < 1e-12);
assert!(lo <= value && value <= hi);
}
}
// ∫ over the unit tetrahedron of 1 is 1/6; over the triangle × segment of 1 is 1/2.
let mut one = Bernstein::zero(&[Factor { dim: 3, deg: 2 }]).unwrap();
for m in one.multis() {
one.set(m, 1.0);
}
assert!((one.integral() - 1.0 / 6.0).abs() < 1e-15);
let mut one = Bernstein::zero(&[Factor { dim: 2, deg: 1 }, seg(2)]).unwrap();
for m in one.multis() {
one.set(m, 1.0);
}
assert!((one.integral() - 0.5).abs() < 1e-15);
}
#[test]
fn rejects_unsupported_domains() {
assert!(Bernstein::zero(&[Factor { dim: 3, deg: 1 }, seg(1)]).is_err());
assert!(Bernstein::zero(&[seg(MAX_DEGREE + 1)]).is_err());
let p = Bernstein::zero(&[Factor { dim: 3, deg: 1 }]).unwrap();
assert!(p.derivative(0, 4).is_err());
assert!(p.bisect(0, 2, 2).is_err());
assert!(p.mul(&Bernstein::zero(&[seg(1), seg(1), seg(1)]).unwrap()).is_err());
}
}
+325
View File
@@ -0,0 +1,325 @@
//! The Jacobian determinant of a solid element as an exact polynomial, its certified sign and
//! the element's exact volume.
//!
//! The geometry map of every solid element ModelCraft knows is a polynomial on its reference
//! domain, so its Jacobian determinant is one too: constant for tet4, cubic for tet10, degree
//! (2, 2) on triangle × segment for wedge6, degree (2, 2, 2) for hex8 and (5, 5, 5) for hex20. It
//! is built here in Bernstein form ([`super::bernstein`]) and its sign is settled by bounds and
//! subdivision, the method of Johnen, Remacle and Geuzaine (J. Comput. Phys. 233, 2013).
//!
//! Positive corner Jacobians do not make a trilinear hexahedron valid: its determinant has
//! degree 2 in each variable and can be negative inside while positive at all eight corners
//! (Marschner, Palmer, Zhang, Solomon, *Hexahedral mesh repair via sum-of-squares relaxation*,
//! CGF 39(5), 2020; Knupp, *Achieving finite element mesh quality via optimization of the
//! Jacobian matrix norm and associated quantities*, IJNME 48, 2000). Sampling at Gauss points is
//! no proof either. The check here is a proof: `Valid` means `det J > 0` on the whole element.
//!
//! Node orders (corners first, then mid-side nodes):
//! - tet4/tet10: mid-side nodes 5–10 on edges 1-2, 2-3, 3-1, 1-4, 2-4, 3-4; positive when node 4
//! lies on the side of face 1-2-3 that its right-hand normal points to.
//! - wedge6: triangle 1-2-3, then 4-5-6 above it; positive when 4-5-6 lies on the side of 1-2-3's
//! right-hand normal.
//! - hex8/hex20: face 1-2-3-4, then 5-6-7-8 above it; mid-side nodes 9–12 on the edges of 1-2-3-4,
//! 13–16 on those of 5-6-7-8, 17–20 on edges 1-5, 2-6, 3-7, 4-8.
use modelcraft_model::ElementKind;
use super::bernstein::{Bernstein, Factor};
use crate::{Error, Result};
/// Reference coordinates of the hex corners on the unit cube, in node order.
pub(crate) const HEX_CORNERS: [[usize; 3]; 8] = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0], [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]];
/// Hex edges in the order of hex20's mid-side nodes.
pub(crate) const HEX_EDGES: [(usize, usize); 12] = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)];
/// Tet edges in the order of tet10's mid-side nodes.
pub(crate) const TET_EDGES: [(usize, usize); 6] = [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)];
/// A Jacobian determinant is positive when it exceeds this fraction of its largest magnitude.
/// Anything smaller is degenerate to rounding and counts as invalid.
pub const RELATIVE_TOLERANCE: f64 = 1e-12;
/// Subdivision caps for the certificate: depth of one piece and pieces per element.
pub const MAX_DEPTH: u32 = 30;
pub const MAX_PIECES: usize = 4096;
/// Whether an element's Jacobian determinant is positive everywhere.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Validity {
/// Proven positive on the whole element.
Valid,
/// Zero or negative somewhere (degenerate or inverted): a point where it is was found.
Invalid,
/// The subdivision caps were reached without a proof either way. Treat as invalid.
Undetermined,
}
/// The certified Jacobian check of one solid element.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct JacobianCheck {
pub validity: Validity,
/// A lower bound of `min det J` divided by an upper bound of `max |det J|`, in `[-1, 1]`;
/// 1 for an affine element (straight-sided tet, parallelepiped). For a valid element it comes
/// from the subdivision that proved it, so it is conservative.
pub ratio: f64,
/// The exact signed volume (`∫ det J` over the reference domain).
pub volume: f64,
}
/// The geometry map's Jacobian of one element: its columns (each three coordinate polynomials)
/// and its determinant.
pub(crate) struct Jacobian {
pub columns: [[Bernstein; 3]; 3],
pub det: Bernstein,
}
fn domain(kind: ElementKind) -> Result<(Vec<Factor>, [(usize, usize); 3])> {
let seg = |deg| Factor { dim: 1, deg };
Ok(match kind {
ElementKind::Tet4 => (vec![Factor { dim: 3, deg: 1 }], [(0, 1), (0, 2), (0, 3)]),
ElementKind::Tet10 => (vec![Factor { dim: 3, deg: 2 }], [(0, 1), (0, 2), (0, 3)]),
ElementKind::Wedge6 => (vec![Factor { dim: 2, deg: 1 }, seg(1)], [(0, 1), (0, 2), (1, 1)]),
ElementKind::Hex8 => (vec![seg(1); 3], [(0, 1), (1, 1), (2, 1)]),
ElementKind::Hex20 => (vec![seg(2); 3], [(0, 1), (1, 1), (2, 1)]),
other => return Err(Error::Invalid(format!("{} is not a solid element", other.name()))),
})
}
/// Free components of a simplex vertex `v` scaled by `deg` (vertex 0 is all zeros).
fn simplex_vertex(v: usize, deg: usize) -> [usize; 3] {
let mut m = [0; 3];
if v > 0
&& let Some(x) = m.get_mut(v - 1)
{
*x = deg;
}
m
}
fn mid(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1]), 0.5 * (a[2] + b[2])]
}
/// The quadratic Bernstein control point between two end points for a curve through `m` at the
/// parameter midpoint: `2m − (a + b)/2`.
fn quadratic_control(a: [f64; 3], m: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[2.0 * m[0] - 0.5 * (a[0] + b[0]), 2.0 * m[1] - 0.5 * (a[1] + b[1]), 2.0 * m[2] - 0.5 * (a[2] + b[2])]
}
/// The 20-node serendipity hexahedron's shape functions at reference point `x ∈ [-1, 1]³`.
fn serendipity(x: [f64; 3], xyz: &[[f64; 3]]) -> [f64; 3] {
let mut out = [0.0; 3];
let mut add = |n: f64, p: Option<&[f64; 3]>| {
if let Some(p) = p {
for (o, c) in out.iter_mut().zip(p) {
*o += n * c;
}
}
};
let corner = |k: usize| HEX_CORNERS.get(k).map(|c| c.map(|v| 2.0 * v as f64 - 1.0)).unwrap_or([0.0; 3]);
for k in 0..8 {
let c = corner(k);
let n = 0.125 * (1.0 + x[0] * c[0]) * (1.0 + x[1] * c[1]) * (1.0 + x[2] * c[2]) * (x[0] * c[0] + x[1] * c[1] + x[2] * c[2] - 2.0);
add(n, xyz.get(k));
}
for (e, &(a, b)) in HEX_EDGES.iter().enumerate() {
let r = mid(corner(a), corner(b));
let mut n = 0.25;
for (xi, ri) in x.iter().zip(r) {
n *= if ri == 0.0 { 1.0 - xi * xi } else { 1.0 + xi * ri };
}
add(n, xyz.get(8 + e));
}
out
}
/// The geometry map in Bernstein form: one polynomial per coordinate.
fn geometry(kind: ElementKind, xyz: &[[f64; 3]]) -> Result<[Bernstein; 3]> {
let (factors, _) = domain(kind)?;
let zero = Bernstein::zero(&factors)?;
let mut g = [zero.clone(), zero.clone(), zero];
let mut put = |m: [usize; 3], p: [f64; 3]| {
for (poly, c) in g.iter_mut().zip(p) {
poly.set(m, c);
}
};
let node = |i: usize| xyz.get(i).copied().ok_or_else(|| Error::Invalid(format!("{} needs {} nodes", kind.name(), kind.node_count())));
match kind {
ElementKind::Tet4 => {
for v in 0..4 {
put(simplex_vertex(v, 1), node(v)?);
}
}
ElementKind::Tet10 => {
for v in 0..4 {
put(simplex_vertex(v, 2), node(v)?);
}
for (e, &(a, b)) in TET_EDGES.iter().enumerate() {
let (ma, mb) = (simplex_vertex(a, 1), simplex_vertex(b, 1));
put([ma[0] + mb[0], ma[1] + mb[1], ma[2] + mb[2]], quadratic_control(node(a)?, node(4 + e)?, node(b)?));
}
}
ElementKind::Wedge6 => {
for (i, base) in [[0, 0], [1, 0], [0, 1]].into_iter().enumerate() {
put([base[0], base[1], 0], node(i)?);
put([base[0], base[1], 1], node(i + 3)?);
}
}
ElementKind::Hex8 => {
for (i, c) in HEX_CORNERS.iter().enumerate() {
put(*c, node(i)?);
}
}
ElementKind::Hex20 => {
if xyz.len() < 20 {
return Err(Error::Invalid("hex20 needs 20 nodes".into()));
}
// The serendipity map lies in the triquadratic space, so interpolating it at the 27
// triquadratic nodes reproduces it; then convert each axis from Lagrange to Bernstein.
let mut v = [[[[0.0; 3]; 3]; 3]; 3];
for (a, plane) in v.iter_mut().enumerate() {
for (b, row) in plane.iter_mut().enumerate() {
for (c, p) in row.iter_mut().enumerate() {
*p = serendipity([a as f64 - 1.0, b as f64 - 1.0, c as f64 - 1.0], xyz);
}
}
}
// One full pass per axis (the conversion is a tensor product).
for axis in 0..3 {
for i in 0..3 {
for j in 0..3 {
let at = |k: usize| match axis {
0 => (k, i, j),
1 => (i, k, j),
_ => (i, j, k),
};
let r = [0, 1, 2].map(|k| {
let (a, b, c) = at(k);
v[a][b][c]
});
let (a, b, c) = at(1);
v[a][b][c] = quadratic_control(r[0], r[1], r[2]);
}
}
}
for (a, plane) in v.iter().enumerate() {
for (b, row) in plane.iter().enumerate() {
for (c, p) in row.iter().enumerate() {
put([a, b, c], *p);
}
}
}
}
other => return Err(Error::Invalid(format!("{} is not a solid element", other.name()))),
}
Ok(g)
}
/// The Jacobian of a solid element whose node coordinates are already checked.
pub(crate) fn jacobian(kind: ElementKind, xyz: &[[f64; 3]]) -> Result<Jacobian> {
let (_, dirs) = domain(kind)?;
let g = geometry(kind, xyz)?;
let column = |(f, k): (usize, usize)| -> Result<[Bernstein; 3]> { Ok([g[0].derivative(f, k)?, g[1].derivative(f, k)?, g[2].derivative(f, k)?]) };
let columns = [column(dirs[0])?, column(dirs[1])?, column(dirs[2])?];
let [a, b, c] = &columns;
// det [a b c] = a · (b × c)
let cross = |i: usize, j: usize| -> Result<Bernstein> { b[i].mul(&c[j])?.axpy(-1.0, &b[j].mul(&c[i])?) };
let det = a[0].mul(&cross(1, 2)?)?.axpy(1.0, &a[1].mul(&cross(2, 0)?)?)?.axpy(1.0, &a[2].mul(&cross(0, 1)?)?)?;
Ok(Jacobian { columns, det })
}
/// A piece of the reference domain during certification: the determinant on it and its vertices
/// in reference coordinates, per factor.
struct Piece {
det: Bernstein,
/// The determinant's smallest coefficient on this piece.
min: f64,
verts: Vec<Vec<[f64; 3]>>,
depth: u32,
}
impl Piece {
fn new(det: Bernstein, verts: Vec<Vec<[f64; 3]>>, depth: u32) -> Self {
let min = det.bounds().0;
Piece { det, min, verts, depth }
}
/// The longest edge over all factors (first on ties, so the choice is deterministic).
fn longest_edge(&self) -> (usize, usize, usize) {
let mut best = (0, 0, 1);
let mut len = -1.0;
for (f, vs) in self.verts.iter().enumerate() {
for (i, a) in vs.iter().enumerate() {
for (j, b) in vs.iter().enumerate().skip(i + 1) {
let l = (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2);
if l > len {
len = l;
best = (f, i, j);
}
}
}
}
best
}
fn split(&self) -> Result<(Piece, Piece)> {
let (f, i, j) = self.longest_edge();
let (a, b) = self.det.bisect(f, i, j)?;
let vs = self.verts.get(f).ok_or_else(|| Error::Failed("certificate: lost a factor".into()))?;
let (pi, pj) = match (vs.get(i), vs.get(j)) {
(Some(p), Some(q)) => (*p, *q),
_ => return Err(Error::Failed("certificate: lost a vertex".into())),
};
let m = mid(pi, pj);
let child = |det: Bernstein, replace: usize| {
let mut verts = self.verts.clone();
if let Some(v) = verts.get_mut(f).and_then(|vs| vs.get_mut(replace)) {
*v = m;
}
Piece::new(det, verts, self.depth + 1)
};
Ok((child(a, j), child(b, i)))
}
}
/// Decide the sign of a Jacobian determinant on the whole reference domain.
pub(crate) fn certify(det: Bernstein) -> Result<JacobianCheck> {
let (lo, hi) = det.bounds();
let volume = det.integral();
let scale = lo.abs().max(hi.abs());
if !scale.is_finite() || !volume.is_finite() {
return Err(Error::Invalid("the element's Jacobian is not a finite number".into()));
}
let tol = RELATIVE_TOLERANCE * scale;
let ratio_of = |v: f64| if scale > 0.0 { v / scale } else { 0.0 };
let verts = det.factors().iter().map(|f| (0..=f.dim).map(|k| simplex_vertex(k, 1).map(|x| x as f64)).collect()).collect();
let mut stack = vec![Piece::new(det, verts, 0)];
let mut lower = f64::INFINITY;
let mut undetermined = false;
let mut pieces = 0;
while let Some(piece) = stack.pop() {
pieces += 1;
if pieces > MAX_PIECES {
undetermined = true;
break;
}
if piece.det.vertex_values().iter().any(|v| !(*v > tol)) {
return Ok(JacobianCheck { validity: Validity::Invalid, ratio: ratio_of(lo), volume });
}
if piece.min > tol {
lower = lower.min(piece.min);
continue;
}
if piece.depth >= MAX_DEPTH {
undetermined = true;
continue;
}
let (a, b) = piece.split()?;
// Look at the more doubtful piece first: a negative value is found sooner.
let (first, second) = if a.min <= b.min { (a, b) } else { (b, a) };
stack.push(second);
stack.push(first);
}
if undetermined {
return Ok(JacobianCheck { validity: Validity::Undetermined, ratio: ratio_of(lo), volume });
}
Ok(JacobianCheck { validity: Validity::Valid, ratio: ratio_of(lower), volume })
}
+228
View File
@@ -0,0 +1,228 @@
//! Element quality: certified validity, exact volume and shape metrics, and the validity gate
//! every mesher runs before its mesh reaches the model.
//!
//! Sources (see `docs/meshing/literature.md`):
//! - Validity is a proof that `det J > 0` on the whole element, from the Jacobian determinant in
//! Bernstein form (Johnen, Remacle, Geuzaine, J. Comput. Phys. 233:359–372, 2013). Corner
//! Jacobians are not enough for a trilinear hexahedron (Marschner, Palmer, Zhang, Solomon,
//! CGF 39(5):133–147, 2020); see [`jacobian`].
//! - Tetrahedron shape: Shewchuk, *What is a good linear finite element?*, 2002; Klingner and
//! Shewchuk, *Aggressive tetrahedral mesh improvement*, IMR 2007.
//! - Hexahedron, wedge and quadrilateral shape: Knupp, *Algebraic mesh quality metrics*, SIAM J.
//! Sci. Comput. 23(1):193–218, 2001.
//!
//! Inputs are hostile: coordinates must be finite and within [`modelcraft_model::MAX_COORD`],
//! and every metric has a defined value on degenerate and inverted elements.
//!
//! Linear tetrahedra take their validity from the exact `orient3d` ([`crate::predicates`]); the
//! other kinds from the floating-point Bernstein certificate, which counts a Jacobian within
//! [`jacobian::RELATIVE_TOLERANCE`] of zero as degenerate.
mod bernstein;
pub mod jacobian;
mod report;
mod shape;
use modelcraft_model::{ElementKind, check_coordinates};
pub use jacobian::{JacobianCheck, Validity};
pub use report::{GateOptions, GateReport, MetricStats, QualityReport, enclosed_volume, quality_report, validity_gate, validity_gate_elements};
use crate::predicates::{Point3, Sign, orient3d};
use crate::{Error, Result};
/// A quality metric. Angles are in degrees.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Metric {
/// Smallest normalized corner Jacobian, in `[-1, 1]`; 1 for a cube, a regular tetrahedron
/// and a square. Hexahedra also measure the centre.
ScaledJacobian,
/// Certified lower bound of `min det J` over an upper bound of `max |det J|`, in `[-1, 1]`;
/// 1 for affine elements. See [`JacobianCheck::ratio`].
JacobianRatio,
/// Knupp's shape metric of the hexahedron corners (and centre), in `[0, 1]`; 1 for a cube.
Shape,
/// Smallest dihedral angle of a tetrahedron (70.53° for the regular one).
MinDihedral,
/// Largest dihedral angle of a tetrahedron.
MaxDihedral,
/// Three times the inradius over the circumradius, signed by orientation; 1 for the regular
/// tetrahedron.
RadiusRatio,
/// Longest edge over smallest altitude, normalized so the regular tetrahedron and the
/// equilateral triangle score 1; `+∞` when degenerate.
AspectRatio,
/// `6√2 V / ℓ_rms³`, signed; 1 for the regular tetrahedron.
VolumeLength,
/// Longest over shortest edge; `+∞` when an edge has zero length.
EdgeRatio,
/// Smallest corner angle of a triangle or quadrilateral.
MinAngle,
/// Largest corner angle of a triangle or quadrilateral.
MaxAngle,
}
impl Metric {
pub fn name(self) -> &'static str {
match self {
Metric::ScaledJacobian => "scaled_jacobian",
Metric::JacobianRatio => "jacobian_ratio",
Metric::Shape => "shape",
Metric::MinDihedral => "min_dihedral",
Metric::MaxDihedral => "max_dihedral",
Metric::RadiusRatio => "radius_ratio",
Metric::AspectRatio => "aspect_ratio",
Metric::VolumeLength => "volume_length",
Metric::EdgeRatio => "edge_ratio",
Metric::MinAngle => "min_angle",
Metric::MaxAngle => "max_angle",
}
}
/// Whether a larger value is a better element.
pub fn higher_is_better(self) -> bool {
!matches!(self, Metric::MaxDihedral | Metric::AspectRatio | Metric::EdgeRatio | Metric::MaxAngle)
}
/// The fixed histogram range; values outside it fall in the first or last bin.
pub fn range(self) -> (f64, f64) {
match self {
Metric::ScaledJacobian | Metric::JacobianRatio | Metric::RadiusRatio | Metric::VolumeLength => (-1.0, 1.0),
Metric::Shape => (0.0, 1.0),
Metric::MinDihedral | Metric::MaxDihedral | Metric::MinAngle | Metric::MaxAngle => (0.0, 180.0),
Metric::AspectRatio | Metric::EdgeRatio => (1.0, 11.0),
}
}
}
/// The quality of one element.
#[derive(Debug, Clone, PartialEq)]
pub struct ElementQuality {
pub kind: ElementKind,
/// Signed volume of a solid (exact for curved elements), area of a shell.
pub size: f64,
/// The certified Jacobian check, for solids.
pub jacobian: Option<JacobianCheck>,
pub metrics: Vec<(Metric, f64)>,
}
impl ElementQuality {
pub fn get(&self, metric: Metric) -> Option<f64> {
self.metrics.iter().find(|(m, _)| *m == metric).map(|(_, v)| *v)
}
pub fn validity(&self) -> Option<Validity> {
self.jacobian.map(|j| j.validity)
}
}
/// Whether [`element_quality`] measures this kind (solids and shells; not rods or beams).
pub fn is_measured(kind: ElementKind) -> bool {
kind.dimension() >= 2
}
/// Check the node count and coordinates, and translate the element so its first node is at the
/// origin (less cancellation far from the origin).
fn local(kind: ElementKind, xyz: &[[f64; 3]]) -> Result<Vec<[f64; 3]>> {
if xyz.len() != kind.node_count() {
return Err(Error::Invalid(format!("{} needs {} nodes, got {}", kind.name(), kind.node_count(), xyz.len())));
}
check_coordinates(xyz.as_flattened())?;
let o = xyz.first().copied().unwrap_or([0.0; 3]);
Ok(xyz.iter().map(|p| shape::sub(*p, o)).collect())
}
/// The first `N` points (corner nodes).
fn corners<const N: usize>(p: &[[f64; 3]]) -> Result<[[f64; 3]; N]> {
p.get(..N).and_then(|s| <[[f64; 3]; N]>::try_from(s).ok()).ok_or_else(|| Error::Invalid(format!("need {N} corner nodes")))
}
/// The Jacobian matrix's columns as polynomials (each three coordinates).
type Columns = [[bernstein::Bernstein; 3]; 3];
/// The certified check of a solid, and its Jacobian columns when they were built. A linear
/// tetrahedron's Jacobian is constant: its sign is the exact `orient3d` of the input
/// coordinates, its volume the determinant.
fn checked(kind: ElementKind, xyz: &[[f64; 3]], p: &[[f64; 3]]) -> Result<(Option<Columns>, JacobianCheck)> {
if kind == ElementKind::Tet4
&& let Some(sign) = exact_tet_sign(xyz)
{
let [a, b, c, d]: [[f64; 3]; 4] = corners(p)?;
let volume = shape::det(shape::sub(b, a), shape::sub(c, a), shape::sub(d, a)) / 6.0;
let (validity, ratio) = match sign {
Sign::Positive => (Validity::Valid, 1.0),
Sign::Negative => (Validity::Invalid, -1.0),
Sign::Zero => (Validity::Invalid, 0.0),
};
return Ok((None, JacobianCheck { validity, ratio, volume }));
}
let jacobian::Jacobian { columns, det } = jacobian::jacobian(kind, p)?;
Ok((Some(columns), jacobian::certify(det)?))
}
/// The exact orientation of a tetrahedron (positive for node 4 on the side of face 1-2-3's
/// right-hand normal), or `None` when a coordinate is outside the predicates' domain.
fn exact_tet_sign(xyz: &[[f64; 3]]) -> Option<Sign> {
let p = |i: usize| xyz.get(i).and_then(|c| Point3::try_from(*c).ok());
// orient3d(a, b, c, d) is positive when d lies below the plane of a, b, c seen
// counterclockwise, i.e. opposite to our convention.
Some(match orient3d(&p(0)?, &p(1)?, &p(2)?, &p(3)?) {
Sign::Positive => Sign::Negative,
Sign::Negative => Sign::Positive,
Sign::Zero => Sign::Zero,
})
}
/// The certified Jacobian check and exact volume of a solid element (tet4, tet10, wedge6, hex8,
/// hex20) from its node coordinates in node order.
pub fn element_validity(kind: ElementKind, xyz: &[[f64; 3]]) -> Result<JacobianCheck> {
let p = local(kind, xyz)?;
checked(kind, xyz, &p).map(|(_, check)| check)
}
/// Knupp's scaled Jacobian and shape of a hexahedron: the Jacobian matrix at the eight corners
/// and the centre (hex20 uses its curved map).
fn hex_metrics(columns: &Columns) -> (f64, f64) {
let at = |value: &dyn Fn(&bernstein::Bernstein) -> f64| {
let [a, b, c] = columns.each_ref().map(|col| col.each_ref().map(value));
shape::jacobian_metrics(a, b, c)
};
let half: &[f64] = &[0.5, 0.5];
let centre = at(&|q| q.eval(&[half, half, half]));
jacobian::HEX_CORNERS.iter().map(|corner| at(&|q| q.at_vertex(corner))).fold(centre, |(s, k), (s2, k2)| (s.min(s2), k.min(k2)))
}
/// Every metric of one element from its node coordinates in node order. Quadratic elements are
/// certified on their curved geometry; tet10 and wedge shape metrics are those of their corners.
/// Rods and beams are an error ([`is_measured`]).
pub fn element_quality(kind: ElementKind, xyz: &[[f64; 3]]) -> Result<ElementQuality> {
let p = local(kind, xyz)?;
let mut metrics = match kind {
ElementKind::Tri3 | ElementKind::Tri6 => {
let (area, metrics) = shape::tri(corners(&p)?);
return Ok(ElementQuality { kind, size: area, jacobian: None, metrics });
}
ElementKind::Quad4 | ElementKind::Quad8 => {
let (area, metrics) = shape::quad(corners(&p)?);
return Ok(ElementQuality { kind, size: area, jacobian: None, metrics });
}
ElementKind::Rod2 | ElementKind::Beam2 => return Err(Error::Invalid(format!("quality metrics are not defined for {}", kind.name()))),
ElementKind::Tet4 | ElementKind::Tet10 => shape::tet(corners(&p)?),
ElementKind::Wedge6 => {
let c: [[f64; 3]; 6] = corners(&p)?;
let edges = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
vec![(Metric::ScaledJacobian, shape::wedge_scaled_jacobian(c)), (Metric::EdgeRatio, shape::edge_ratio(&c, &edges))]
}
ElementKind::Hex8 | ElementKind::Hex20 => vec![(Metric::EdgeRatio, shape::edge_ratio(&corners::<8>(&p)?, &jacobian::HEX_EDGES))],
};
let (columns, check) = checked(kind, xyz, &p)?;
if let Some(columns) = columns.as_ref().filter(|_| matches!(kind, ElementKind::Hex8 | ElementKind::Hex20)) {
let (scaled, knupp) = hex_metrics(columns);
metrics.extend([(Metric::ScaledJacobian, scaled), (Metric::Shape, knupp)]);
}
metrics.push((Metric::JacobianRatio, check.ratio));
Ok(ElementQuality { kind, size: check.volume, jacobian: Some(check), metrics })
}
#[cfg(test)]
mod tests;
+271
View File
@@ -0,0 +1,271 @@
//! Mesh-wide quality: statistics per metric, and the validity gate meshers run on their output.
use std::collections::{BTreeMap, BTreeSet};
use modelcraft_model::{Element, ElementKind, Model, check_coordinates};
use super::{ElementQuality, Metric, Validity, element_quality, element_validity, is_measured};
use crate::{Error, Result};
/// The most histogram bins a report may ask for.
pub const MAX_BINS: usize = 1000;
/// Statistics of one metric over the measured elements.
#[derive(Debug, Clone, PartialEq)]
pub struct MetricStats {
pub metric: Metric,
/// Elements that have this metric.
pub count: usize,
pub min: f64,
pub max: f64,
/// Mean of the finite values (`None` when there are none).
pub mean: Option<f64>,
/// The element with the worst value (the first one on ties).
pub worst: u32,
/// Counts over [`Metric::range`] in equal bins; values outside the range fall in the end bins.
pub histogram: Vec<usize>,
}
/// Quality of a set of elements.
#[derive(Debug, Clone, PartialEq)]
pub struct QualityReport {
/// Elements measured (solids and shells).
pub measured: usize,
/// Elements of kinds with no quality metrics (rods, beams), by kind.
pub skipped: Vec<(ElementKind, usize)>,
/// Solids proven valid.
pub valid: usize,
pub invalid: Vec<u32>,
pub undetermined: Vec<u32>,
/// Total signed volume of the solids and total area of the shells.
pub volume: f64,
pub area: f64,
/// One entry per metric present, in [`Metric`] declaration order.
pub metrics: Vec<MetricStats>,
}
fn coordinates(model: &Model, label: u32, element: &Element) -> Result<Vec<[f64; 3]>> {
element
.nodes
.iter()
.map(|n| {
model.node(*n).map(|node| node.xyz).ok_or_else(|| Error::Model(modelcraft_model::Error::NotFound(format!("element {label}: node {n}"))))
})
.collect()
}
/// The elements to look at: all of them, or the given labels (sorted, without repeats).
fn selection<'a>(model: &'a Model, labels: Option<&[u32]>) -> Result<Vec<(u32, &'a Element)>> {
match labels {
None => Ok(model.elements().iter().map(|(l, e)| (*l, e)).collect()),
Some(labels) => labels
.iter()
.collect::<BTreeSet<_>>()
.into_iter()
.map(|l| model.element(*l).map(|e| (*l, e)).ok_or_else(|| Error::Model(modelcraft_model::Error::NotFound(format!("element {l}")))))
.collect(),
}
}
/// Quality statistics of the model's elements, or of the given ones, with `bins` histogram bins
/// (1..=[`MAX_BINS`]) per metric. Deterministic: elements are visited in label order.
pub fn quality_report(model: &Model, labels: Option<&[u32]>, bins: usize) -> Result<QualityReport> {
if !(1..=MAX_BINS).contains(&bins) {
return Err(Error::Invalid(format!("bins must be in 1..={MAX_BINS}, got {bins}")));
}
let mut report = QualityReport {
measured: 0,
skipped: Vec::new(),
valid: 0,
invalid: Vec::new(),
undetermined: Vec::new(),
volume: 0.0,
area: 0.0,
metrics: Vec::new(),
};
let mut skipped: BTreeMap<ElementKind, usize> = BTreeMap::new();
let mut stats: BTreeMap<Metric, (MetricStats, f64, usize)> = BTreeMap::new();
for (label, element) in selection(model, labels)? {
if !is_measured(element.kind) {
*skipped.entry(element.kind).or_insert(0) += 1;
continue;
}
let q: ElementQuality = element_quality(element.kind, &coordinates(model, label, element)?)?;
report.measured += 1;
match q.validity() {
Some(Validity::Valid) => report.valid += 1,
Some(Validity::Invalid) => report.invalid.push(label),
Some(Validity::Undetermined) => report.undetermined.push(label),
None => {}
}
if element.kind.dimension() == 3 {
report.volume += q.size;
} else {
report.area += q.size;
}
for (metric, v) in q.metrics {
let (s, sum, finite) = stats.entry(metric).or_insert_with(|| {
(
MetricStats { metric, count: 0, min: f64::INFINITY, max: f64::NEG_INFINITY, mean: None, worst: label, histogram: vec![0; bins] },
0.0,
0,
)
});
if v.is_nan() {
continue;
}
let worse = if metric.higher_is_better() { v < s.min } else { v > s.max };
if worse || s.count == 0 {
s.worst = label;
}
s.count += 1;
s.min = s.min.min(v);
s.max = s.max.max(v);
if v.is_finite() {
*sum += v;
*finite += 1;
}
let (lo, hi) = metric.range();
// `as` saturates: −∞ lands in bin 0, +∞ in the last bin.
let bin = (((v - lo) / (hi - lo)) * bins as f64).floor().max(0.0) as usize;
if let Some(b) = s.histogram.get_mut(bin.min(bins - 1)) {
*b += 1;
}
}
}
report.skipped = skipped.into_iter().collect();
report.metrics = stats
.into_values()
.map(|(mut s, sum, finite)| {
s.mean = (finite > 0).then(|| sum / finite as f64);
s
})
.collect();
Ok(report)
}
/// What the validity gate checks besides element validity.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GateOptions {
/// The volume the mesh must fill: the boundary's enclosed volume ([`enclosed_volume`]).
pub expected_volume: Option<f64>,
/// Relative tolerance on the volume. Curved (quadratic) meshes of a faceted boundary need a
/// looser one than linear meshes.
pub volume_tolerance: f64,
}
impl Default for GateOptions {
fn default() -> Self {
Self { expected_volume: None, volume_tolerance: 1e-9 }
}
}
/// The result of the validity gate.
#[derive(Debug, Clone, PartialEq)]
pub struct GateReport {
/// Solid elements checked (other kinds are ignored).
pub checked: usize,
pub invalid: Vec<u32>,
pub undetermined: Vec<u32>,
/// Total signed volume of the solids.
pub volume: f64,
pub options: GateOptions,
}
impl GateReport {
/// Whether the volume matches the expected one (true when none is expected).
pub fn volume_matches(&self) -> bool {
self.options.expected_volume.is_none_or(|v| (self.volume - v).abs() <= self.options.volume_tolerance * v.abs())
}
/// Every solid is proven valid and the volume matches.
pub fn passed(&self) -> bool {
self.invalid.is_empty() && self.undetermined.is_empty() && self.volume_matches()
}
/// The report, or [`Error::Failed`] describing why the gate failed.
pub fn into_result(self) -> Result<Self> {
if self.passed() {
return Ok(self);
}
let mut why = Vec::new();
let list = |v: &[u32]| v.iter().take(10).map(u32::to_string).collect::<Vec<_>>().join(", ");
if !self.invalid.is_empty() {
why.push(format!("{} inverted or degenerate elements ({})", self.invalid.len(), list(&self.invalid)));
}
if !self.undetermined.is_empty() {
why.push(format!("{} elements whose validity could not be proven ({})", self.undetermined.len(), list(&self.undetermined)));
}
if let (false, Some(v)) = (self.volume_matches(), self.options.expected_volume) {
why.push(format!("mesh volume {} differs from the boundary's {v}", self.volume));
}
Err(Error::Failed(format!("mesh validity gate: {}", why.join("; "))))
}
}
/// Accumulates the gate's report one element at a time.
struct Gate(GateReport);
impl Gate {
fn new(options: &GateOptions) -> Result<Self> {
if !(options.volume_tolerance >= 0.0 && options.volume_tolerance.is_finite()) || options.expected_volume.is_some_and(|v| !v.is_finite()) {
return Err(Error::Invalid("gate options must be finite, with a non-negative tolerance".into()));
}
Ok(Gate(GateReport { checked: 0, invalid: Vec::new(), undetermined: Vec::new(), volume: 0.0, options: *options }))
}
fn add(&mut self, label: u32, kind: ElementKind, xyz: &[[f64; 3]]) -> Result<()> {
if kind.dimension() != 3 {
return Ok(());
}
let check = element_validity(kind, xyz)?;
let r = &mut self.0;
r.checked += 1;
r.volume += check.volume;
match check.validity {
Validity::Valid => {}
Validity::Invalid => r.invalid.push(label),
Validity::Undetermined => r.undetermined.push(label),
}
Ok(())
}
}
/// The validity gate every mesher runs on its output before it reaches the model: each solid
/// element's Jacobian determinant is proven positive everywhere, and the elements' total volume
/// equals the volume the boundary encloses. Elements come as `(label, kind, node coordinates in
/// node order)`, straight from the mesher's arrays; other than solids are ignored. Call
/// [`GateReport::into_result`] to turn a failure into an error.
pub fn validity_gate_elements<I, E>(elements: I, options: &GateOptions) -> Result<GateReport>
where
I: IntoIterator<Item = (u32, ElementKind, E)>,
E: AsRef<[[f64; 3]]>,
{
let mut gate = Gate::new(options)?;
for (label, kind, xyz) in elements {
gate.add(label, kind, xyz.as_ref())?;
}
Ok(gate.0)
}
/// [`validity_gate_elements`] over a model's elements.
pub fn validity_gate(model: &Model, options: &GateOptions) -> Result<GateReport> {
let mut gate = Gate::new(options)?;
for (label, element) in model.elements() {
gate.add(*label, element.kind, &coordinates(model, *label, element)?)?;
}
Ok(gate.0)
}
/// The signed volume enclosed by a closed triangle surface (positive when the triangles' right-
/// hand normals point outwards), by the divergence theorem.
pub fn enclosed_volume(points: &[[f64; 3]], triangles: &[[usize; 3]]) -> Result<f64> {
check_coordinates(points.as_flattened())?;
let o = points.first().copied().unwrap_or([0.0; 3]);
let mut six_v = 0.0;
for t in triangles {
let p = |i: usize| points.get(i).map(|p| super::shape::sub(*p, o)).ok_or_else(|| Error::Invalid(format!("triangle {t:?}: no point {i}")));
six_v += super::shape::det(p(t[0])?, p(t[1])?, p(t[2])?);
}
Ok(six_v / 6.0)
}
+182
View File
@@ -0,0 +1,182 @@
//! Shape metrics from corner positions and corner Jacobians.
//!
//! - Tetrahedra: Shewchuk, *What is a good linear finite element? Interpolation, conditioning,
//! anisotropy, and quality measures*, 2002 (dihedral angles, radius ratio, aspect ratio), and
//! the volume–length measure Klingner and Shewchuk recommend for mesh improvement (IMR 2007, §2).
//! - Hexahedra, wedges and quadrilaterals: Knupp's algebraic metrics built on the corner Jacobian
//! matrices (Knupp, *Algebraic mesh quality metrics*, SIAM J. Sci. Comput. 23(1), 2001): the
//! scaled Jacobian `det(A) / (|a1| |a2| |a3|)` and the shape metric `3 det(A)^{2/3} / |A|²_F`.
//!
//! Every metric is defined for degenerate input (no division by zero, no NaN): a degenerate
//! element scores 0 on bounded metrics and `+∞` on aspect and edge ratios.
use std::f64::consts::SQRT_2;
use super::Metric;
pub(crate) type P = [f64; 3];
pub(crate) fn sub(a: P, b: P) -> P {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
pub(crate) fn dot(a: P, b: P) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
pub(crate) fn cross(a: P, b: P) -> P {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
pub(crate) fn norm(a: P) -> f64 {
dot(a, a).sqrt()
}
pub(crate) fn det(a: P, b: P, c: P) -> f64 {
dot(a, cross(b, c))
}
/// The angle between two vectors in degrees, 0 when either is zero.
fn angle(a: P, b: P) -> f64 {
norm(cross(a, b)).atan2(dot(a, b)).to_degrees()
}
/// `num / den`, or `fallback` when the denominator is zero or the quotient is not finite.
fn ratio(num: f64, den: f64, fallback: f64) -> f64 {
let r = num / den;
if den != 0.0 && r.is_finite() { r } else { fallback }
}
/// Normalized scaled Jacobian of one corner: the determinant of its three unit edge vectors.
fn corner_det(a: P, b: P, c: P) -> f64 {
ratio(det(a, b, c), norm(a) * norm(b) * norm(c), 0.0)
}
/// Largest over smallest edge length.
pub(crate) fn edge_ratio(p: &[P], edges: &[(usize, usize)]) -> f64 {
let (mut lo, mut hi) = (f64::INFINITY, 0.0_f64);
for &(a, b) in edges {
if let (Some(&x), Some(&y)) = (p.get(a), p.get(b)) {
let l = norm(sub(x, y));
lo = lo.min(l);
hi = hi.max(l);
}
}
ratio(hi, lo, f64::INFINITY).max(1.0)
}
/// Tetrahedron metrics on corners `p` (positive orientation as in [`super::jacobian`]).
pub(crate) fn tet(p: [P; 4]) -> Vec<(Metric, f64)> {
let [a, b, c, d] = p;
let volume = det(sub(b, a), sub(c, a), sub(d, a)) / 6.0;
let edges = super::jacobian::TET_EDGES.map(|(i, j)| norm(sub(p[j], p[i])));
let faces =
[cross(sub(c, b), sub(d, b)), cross(sub(d, a), sub(c, a)), cross(sub(b, a), sub(d, a)), cross(sub(c, a), sub(b, a))].map(|n| 0.5 * norm(n));
let area: f64 = faces.iter().sum();
let l_max = edges.iter().fold(0.0_f64, |m, &l| m.max(l));
let a_max = faces.iter().fold(0.0_f64, |m, &l| m.max(l));
let l_rms = (edges.iter().map(|l| l * l).sum::<f64>() / 6.0).sqrt();
// Dihedral angle at edge (i, j): the angle between the other two vertices as seen
// perpendicular to the edge.
let mut dihedral = (180.0_f64, 0.0_f64);
// The two vertices off each edge of TET_EDGES, in the same order.
let opposite = [(2, 3), (0, 3), (1, 3), (1, 2), (0, 2), (0, 1)];
for ((i, j), (k, l)) in super::jacobian::TET_EDGES.into_iter().zip(opposite) {
let e = sub(p[j], p[i]);
let ee = dot(e, e);
let perp = |v: P| {
let w = sub(v, p[i]);
let t = ratio(dot(w, e), ee, 0.0);
sub(w, [t * e[0], t * e[1], t * e[2]])
};
let th = angle(perp(p[k]), perp(p[l]));
dihedral = (dihedral.0.min(th), dihedral.1.max(th));
}
// Circumradius R = |num| / (12 |V|) with num = |a|²(b×c) + |b|²(c×a) + |c|²(a×b) for edge
// vectors a, b, c from one vertex; inradius r = 3V / area; radius ratio 3r/R.
let (u, v, w) = (sub(b, a), sub(c, a), sub(d, a));
let (bc, ca, ab) = (cross(v, w), cross(w, u), cross(u, v));
let (lu, lv, lw) = (dot(u, u), dot(v, v), dot(w, w));
let num = norm([lu * bc[0] + lv * ca[0] + lw * ab[0], lu * bc[1] + lv * ca[1] + lw * ab[1], lu * bc[2] + lv * ca[2] + lw * ab[2]]);
let radius_ratio = ratio(108.0 * volume * volume.abs(), area * num, 0.0);
// Smallest altitude 3|V| / largest face; normalized so the regular tetrahedron scores 1.
let aspect = ratio((2.0_f64 / 3.0).sqrt() * l_max * a_max, 3.0 * volume.abs(), f64::INFINITY);
let volume_length = ratio(6.0 * SQRT_2 * volume, l_rms.powi(3), 0.0);
// Scaled Jacobian: 6V over the largest product of the three edge lengths at a corner, × √2.
let corner = [(0, 1, 2, 3), (1, 2, 0, 3), (2, 0, 1, 3), (3, 0, 2, 1)]
.map(|(o, x, y, z)| norm(sub(p[x], p[o])) * norm(sub(p[y], p[o])) * norm(sub(p[z], p[o])))
.iter()
.fold(0.0_f64, |m, &x| m.max(x));
let scaled = ratio(SQRT_2 * 6.0 * volume, corner, 0.0);
vec![
(Metric::ScaledJacobian, scaled),
(Metric::MinDihedral, dihedral.0),
(Metric::MaxDihedral, dihedral.1),
(Metric::RadiusRatio, radius_ratio),
(Metric::AspectRatio, aspect.max(1.0)),
(Metric::VolumeLength, volume_length),
]
}
/// Triangle area and metrics.
pub(crate) fn tri(p: [P; 3]) -> (f64, Vec<(Metric, f64)>) {
let area = 0.5 * norm(cross(sub(p[1], p[0]), sub(p[2], p[0])));
let angles = [0, 1, 2].map(|i| angle(sub(p[(i + 1) % 3], p[i]), sub(p[(i + 2) % 3], p[i])));
let l_max = (0..3).map(|i| norm(sub(p[(i + 1) % 3], p[i]))).fold(0.0_f64, f64::max);
// Longest edge over smallest altitude, normalized so the equilateral triangle scores 1.
let aspect = ratio(3.0_f64.sqrt() * l_max * l_max, 4.0 * area, f64::INFINITY).max(1.0);
(
area,
vec![
(Metric::MinAngle, angles.iter().fold(180.0, |m: f64, &a| m.min(a))),
(Metric::MaxAngle, angles.iter().fold(0.0, |m: f64, &a| m.max(a))),
(Metric::AspectRatio, aspect),
],
)
}
/// Quadrilateral area (of its projection on the mean plane) and metrics. The scaled Jacobian of
/// a corner is measured against the normal of the diagonals, so a concave or bow-tie
/// quadrilateral scores ≤ 0.
pub(crate) fn quad(p: [P; 4]) -> (f64, Vec<(Metric, f64)>) {
let n = cross(sub(p[2], p[0]), sub(p[3], p[1]));
let area = 0.5 * norm(n);
let mut scaled = f64::INFINITY;
let mut angles = (180.0_f64, 0.0_f64);
for i in 0..4 {
let next = sub(p[(i + 1) % 4], p[i]);
let prev = sub(p[(i + 3) % 4], p[i]);
scaled = scaled.min(ratio(dot(cross(next, prev), n), norm(next) * norm(prev) * norm(n), 0.0));
let a = angle(next, prev);
angles = (angles.0.min(a), angles.1.max(a));
}
let edges = [(0, 1), (1, 2), (2, 3), (3, 0)];
(
area,
vec![
(Metric::ScaledJacobian, scaled),
(Metric::MinAngle, angles.0),
(Metric::MaxAngle, angles.1),
(Metric::EdgeRatio, edge_ratio(&p, &edges)),
],
)
}
/// Wedge scaled Jacobian: the smallest determinant of the three unit edge vectors at a corner,
/// taken in right-handed order (1 for a right prism on a right-angled corner, sin 60° ≈ 0.866 on
/// an equilateral triangle).
pub(crate) fn wedge_scaled_jacobian(p: [P; 6]) -> f64 {
[(0, 1, 2, 3), (1, 2, 0, 4), (2, 0, 1, 5), (3, 5, 4, 0), (4, 3, 5, 1), (5, 4, 3, 2)]
.map(|(o, a, b, c)| corner_det(sub(p[a], p[o]), sub(p[b], p[o]), sub(p[c], p[o])))
.iter()
.fold(f64::INFINITY, |m, &x| m.min(x))
}
/// Scaled Jacobian and Knupp shape of one Jacobian matrix `[a b c]` (hexahedron corners and centre).
pub(crate) fn jacobian_metrics(a: P, b: P, c: P) -> (f64, f64) {
let d = det(a, b, c);
let frob = dot(a, a) + dot(b, b) + dot(c, c);
let shape = if d > 0.0 { ratio(3.0 * d.cbrt().powi(2), frob, 0.0) } else { 0.0 };
(corner_det(a, b, c), shape)
}
+302
View File
@@ -0,0 +1,302 @@
//! Analytic tests: every expected value is closed-form.
use modelcraft_model::{ElementKind, Model};
use super::*;
const UNIT_CUBE: [[f64; 3]; 8] = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [1., 1., 1.], [0., 1., 1.]];
const RIGHT_TET: [[f64; 3]; 4] = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]];
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-12 * (1.0 + b.abs())
}
fn regular_tet() -> [[f64; 3]; 4] {
let s = 1.0 / 2.0_f64.sqrt();
// Alternate cube corners: edge √2·(1/√2) = 1.
[[0., 0., 0.], [s, 0., s], [s, s, 0.], [0., s, s]]
}
fn metric(q: &ElementQuality, m: Metric) -> f64 {
q.get(m).unwrap()
}
/// Quadratic element nodes from corner nodes with straight edges.
fn midpoints(corners: &[[f64; 3]], edges: &[(usize, usize)]) -> Vec<[f64; 3]> {
let mut out = corners.to_vec();
for &(a, b) in edges {
out.push([0.5 * (corners[a][0] + corners[b][0]), 0.5 * (corners[a][1] + corners[b][1]), 0.5 * (corners[a][2] + corners[b][2])]);
}
out
}
#[test]
fn regular_tetrahedron_scores_one() {
let p = regular_tet();
let q = element_quality(ElementKind::Tet4, &p).unwrap();
assert!(close(q.size, 1.0 / (6.0 * 2.0_f64.sqrt())), "{}", q.size);
assert_eq!(q.validity(), Some(Validity::Valid));
let dihedral = (1.0_f64 / 3.0).acos().to_degrees();
assert!(close(metric(&q, Metric::MinDihedral), dihedral));
assert!(close(metric(&q, Metric::MaxDihedral), dihedral));
for m in [Metric::RadiusRatio, Metric::AspectRatio, Metric::VolumeLength, Metric::ScaledJacobian, Metric::JacobianRatio] {
assert!(close(metric(&q, m), 1.0), "{m:?} = {}", metric(&q, m));
}
}
#[test]
fn right_corner_tetrahedron() {
let q = element_quality(ElementKind::Tet4, &RIGHT_TET).unwrap();
assert!(close(q.size, 1.0 / 6.0));
assert!(close(metric(&q, Metric::MinDihedral), (1.0 / 3.0_f64.sqrt()).acos().to_degrees()));
assert!(close(metric(&q, Metric::MaxDihedral), 90.0));
// Inradius 1/(3 + √3), circumradius √3/2.
let r = 1.0 / (3.0 + 3.0_f64.sqrt());
assert!(close(metric(&q, Metric::RadiusRatio), 3.0 * r / (3.0_f64.sqrt() / 2.0)));
// Corner at (1,0,0): edges 1, √2, √2 → 6V/2 · √2.
assert!(close(metric(&q, Metric::ScaledJacobian), 2.0_f64.sqrt() / 2.0));
// ℓ_rms² = (3·1 + 3·2)/6 = 3/2.
assert!(close(metric(&q, Metric::VolumeLength), 2.0_f64.sqrt() / 1.5_f64.powf(1.5)));
}
#[test]
fn inverted_and_flat_tetrahedra() {
let mut p = regular_tet();
p.swap(1, 2);
let q = element_quality(ElementKind::Tet4, &p).unwrap();
assert!(q.size < 0.0);
assert_eq!(q.validity(), Some(Validity::Invalid));
assert!(close(metric(&q, Metric::ScaledJacobian), -1.0));
assert!(close(metric(&q, Metric::RadiusRatio), -1.0));
assert!(close(metric(&q, Metric::JacobianRatio), -1.0));
let flat = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [1., 1., 0.]];
let q = element_quality(ElementKind::Tet4, &flat).unwrap();
assert_eq!(q.size, 0.0);
assert_eq!(q.validity(), Some(Validity::Invalid));
assert_eq!(metric(&q, Metric::RadiusRatio), 0.0);
assert_eq!(metric(&q, Metric::AspectRatio), f64::INFINITY);
assert!(q.metrics.iter().all(|(_, v)| !v.is_nan()));
// A sliver with an exactly positive orientation is valid, however thin (exact orient3d).
let sliver = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0.3, 0.3, 1e-30]];
assert_eq!(element_validity(ElementKind::Tet4, &sliver).unwrap().validity, Validity::Valid);
let below = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0.3, 0.3, -1e-30]];
assert_eq!(element_validity(ElementKind::Tet4, &below).unwrap().validity, Validity::Invalid);
// All four nodes at one point.
let q = element_quality(ElementKind::Tet4, &[[1., 2., 3.]; 4]).unwrap();
assert_eq!(q.validity(), Some(Validity::Invalid));
assert!(q.metrics.iter().all(|(_, v)| !v.is_nan()));
}
#[test]
fn unit_cube_scores_one() {
for (kind, nodes) in [(ElementKind::Hex8, UNIT_CUBE.to_vec()), (ElementKind::Hex20, midpoints(&UNIT_CUBE, &jacobian::HEX_EDGES))] {
let q = element_quality(kind, &nodes).unwrap();
assert!(close(q.size, 1.0), "{kind:?} {}", q.size);
assert_eq!(q.validity(), Some(Validity::Valid));
for m in [Metric::ScaledJacobian, Metric::JacobianRatio, Metric::Shape, Metric::EdgeRatio] {
assert!(close(metric(&q, m), 1.0), "{kind:?} {m:?} = {}", metric(&q, m));
}
}
}
#[test]
fn sheared_hexahedron() {
// x += s·z: a parallelepiped, so det J is constant and the volume stays 1.
let s = 0.75;
let p: Vec<[f64; 3]> = UNIT_CUBE.iter().map(|c| [c[0] + s * c[2], c[1], c[2]]).collect();
let q = element_quality(ElementKind::Hex8, &p).unwrap();
assert!(close(q.size, 1.0));
assert_eq!(q.validity(), Some(Validity::Valid));
assert!(close(metric(&q, Metric::JacobianRatio), 1.0));
assert!(close(metric(&q, Metric::ScaledJacobian), 1.0 / (1.0 + s * s).sqrt()));
}
#[test]
fn mirrored_hexahedron_is_inverted() {
let p: Vec<[f64; 3]> = UNIT_CUBE.iter().map(|c| [-c[0], c[1], c[2]]).collect();
let q = element_quality(ElementKind::Hex8, &p).unwrap();
assert!(close(q.size, -1.0));
assert_eq!(q.validity(), Some(Validity::Invalid));
assert!(close(metric(&q, Metric::ScaledJacobian), -1.0));
assert_eq!(metric(&q, Metric::Shape), 0.0);
}
/// The top face is the bottom face mapped by A = diag(−2, −3) (centred on the axis). The
/// trilinear map is x = ((1−w) I + w A) p, z = w, so det J = (1 − 3w)(1 − 4w): 1 at the bottom
/// corners, 6 at the top corners, 1/2 at the centre, −1/48 at w = 7/24, and the volume is
/// ∫(1 − 7w + 12w²) = 3/2.
fn inside_out_hex() -> Vec<[f64; 3]> {
let bottom = [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]];
let mut p: Vec<[f64; 3]> = bottom.iter().map(|b| [b[0], b[1], 0.0]).collect();
p.extend(bottom.iter().map(|b| [-2.0 * b[0], -3.0 * b[1], 1.0]));
p
}
#[test]
fn hex_with_positive_corners_inverted_inside_is_invalid() {
let p = inside_out_hex();
// Every corner Jacobian (three edge vectors in right-handed order) is positive.
let e = |a: usize, b: usize| shape::sub(p[b], p[a]);
for (o, x, y, z) in [(0, 1, 3, 4), (1, 2, 0, 5), (2, 3, 1, 6), (3, 0, 2, 7), (4, 7, 5, 0), (5, 4, 6, 1), (6, 5, 7, 2), (7, 6, 4, 3)] {
assert!(shape::det(e(o, x), e(o, y), e(o, z)) > 0.0, "corner {o}");
}
let q = element_quality(ElementKind::Hex8, &p).unwrap();
assert!(metric(&q, Metric::ScaledJacobian) > 0.0, "corners and centre look fine");
assert!(close(q.size, 1.5));
assert_eq!(q.validity(), Some(Validity::Invalid));
assert!(metric(&q, Metric::JacobianRatio) < 0.0);
}
#[test]
fn quarter_point_hex20_is_degenerate() {
// Move the mid-side node of edge 1-2 (node 9) along the edge to parameter m. On that edge
// x'(0) = 4m − 1, so the element is valid for 1/4 < m < 3/4 and degenerate at m = 1/4.
for (m, valid) in [(0.5, true), (0.3, true), (0.7, true), (0.25, false), (0.2, false), (0.8, false)] {
let mut p = midpoints(&UNIT_CUBE, &jacobian::HEX_EDGES);
p[8] = [m, 0.0, 0.0];
let check = element_validity(ElementKind::Hex20, &p).unwrap();
assert_eq!(check.validity == Validity::Valid, valid, "m = {m}: {check:?}");
assert!(check.validity != Validity::Undetermined);
}
}
#[test]
fn tet10_with_a_moved_mid_side_node() {
// Moving node 5 (edge 1-2) to parameter m gives det J = det_lin (1 + 4(m − ½)(λ1 − λ2)),
// extreme at the two edge ends: valid iff |m − ½| < ¼, ratio (1 − 4d)/(1 + 4d).
for m in [0.5, 0.3, 0.6, 0.25, 0.1, 0.9] {
let mut p = midpoints(&RIGHT_TET, &jacobian::TET_EDGES);
p[4] = [m, 0.0, 0.0];
let q = element_quality(ElementKind::Tet10, &p).unwrap();
let d: f64 = (m - 0.5_f64).abs();
let valid = d < 0.25;
assert_eq!(q.validity() == Some(Validity::Valid), valid, "m = {m}");
if valid {
assert!(close(metric(&q, Metric::JacobianRatio), (1.0 - 4.0 * d) / (1.0 + 4.0 * d)), "m = {m}");
}
// The node moves along a straight edge, so the volume is unchanged.
assert!(close(q.size, 1.0 / 6.0), "m = {m}: {}", q.size);
}
}
#[test]
fn wedges() {
let right = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.], [1., 0., 1.], [0., 1., 1.]];
let q = element_quality(ElementKind::Wedge6, &right).unwrap();
assert!(close(q.size, 0.5));
assert_eq!(q.validity(), Some(Validity::Valid));
assert!(close(metric(&q, Metric::ScaledJacobian), 0.5_f64.sqrt()));
assert!(close(metric(&q, Metric::JacobianRatio), 1.0));
assert!(close(metric(&q, Metric::EdgeRatio), 2.0_f64.sqrt()));
let h = 3.0_f64.sqrt() / 2.0;
let equilateral = [[0., 0., 0.], [1., 0., 0.], [0.5, h, 0.], [0., 0., 1.], [1., 0., 1.], [0.5, h, 1.]];
let q = element_quality(ElementKind::Wedge6, &equilateral).unwrap();
assert!(close(metric(&q, Metric::ScaledJacobian), h));
let upside_down: Vec<[f64; 3]> = right.iter().map(|p| [p[0], p[1], -p[2]]).collect();
let q = element_quality(ElementKind::Wedge6, &upside_down).unwrap();
assert!(close(q.size, -0.5));
assert_eq!(q.validity(), Some(Validity::Invalid));
}
#[test]
fn shells() {
let h = 3.0_f64.sqrt() / 2.0;
let q = element_quality(ElementKind::Tri3, &[[0., 0., 0.], [1., 0., 0.], [0.5, h, 0.]]).unwrap();
assert!(close(q.size, h / 2.0));
assert!(close(metric(&q, Metric::MinAngle), 60.0) && close(metric(&q, Metric::MaxAngle), 60.0));
assert!(close(metric(&q, Metric::AspectRatio), 1.0));
assert_eq!(q.validity(), None);
let square = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]];
let q = element_quality(ElementKind::Quad4, &square).unwrap();
assert!(close(q.size, 1.0));
assert!(close(metric(&q, Metric::ScaledJacobian), 1.0));
assert!(close(metric(&q, Metric::MinAngle), 90.0));
// A concave quadrilateral: corner 3 pushed past the diagonal.
let dart = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0.7, 0.3, 0.]];
assert!(metric(&element_quality(ElementKind::Quad4, &dart).unwrap(), Metric::ScaledJacobian) < 0.0);
assert!(element_quality(ElementKind::Rod2, &[[0.; 3], [1., 0., 0.]]).is_err());
}
#[test]
fn hostile_coordinates_are_errors() {
let mut p = UNIT_CUBE;
p[3][1] = f64::NAN;
assert!(element_quality(ElementKind::Hex8, &p).is_err());
p[3][1] = 1e300;
assert!(element_validity(ElementKind::Hex8, &p).is_err());
assert!(element_quality(ElementKind::Hex8, &UNIT_CUBE[..7]).is_err());
assert!(element_validity(ElementKind::Tri3, &[[0.; 3]; 3]).is_err());
// Far from the origin and at the coordinate limit: still exact.
let far: Vec<[f64; 3]> = UNIT_CUBE.iter().map(|c| [c[0] * 1e9, c[1] * 1e9, c[2] * 1e9 - 1e9]).collect();
let q = element_quality(ElementKind::Hex8, &far).unwrap();
assert_eq!(q.validity(), Some(Validity::Valid));
assert!(close(q.size, 1e27));
}
fn model_of(elements: &[(ElementKind, Vec<[f64; 3]>)]) -> Model {
let mut m = Model::default();
let mut next = 1;
for (e, (kind, nodes)) in elements.iter().enumerate() {
let mut labels = Vec::new();
for p in nodes {
m.set_node(next, *p).unwrap();
labels.push(next);
next += 1;
}
m.set_element(e as u32 + 1, *kind, labels).unwrap();
}
m
}
#[test]
fn report_and_gate() {
let mut flipped = UNIT_CUBE.to_vec();
flipped.iter_mut().for_each(|p| p[0] = -p[0] - 2.0);
let model = model_of(&[
(ElementKind::Hex8, UNIT_CUBE.to_vec()),
(ElementKind::Hex8, inside_out_hex()),
(ElementKind::Tet4, regular_tet().to_vec()),
(ElementKind::Hex8, flipped),
(ElementKind::Rod2, vec![[0.; 3], [1., 0., 0.]]),
]);
let r = quality_report(&model, None, 4).unwrap();
assert_eq!(r.measured, 4);
assert_eq!(r.skipped, vec![(ElementKind::Rod2, 1)]);
assert_eq!((r.valid, r.invalid.clone()), (2, vec![2, 4]));
let sj = r.metrics.iter().find(|s| s.metric == Metric::ScaledJacobian).unwrap();
assert_eq!((sj.count, sj.worst, sj.min, sj.histogram.iter().sum::<usize>()), (4, 4, -1.0, 4));
assert_eq!(sj.histogram[0], 1);
assert_eq!(r.metrics.iter().find(|s| s.metric == Metric::RadiusRatio).unwrap().count, 1);
assert!(quality_report(&model, None, 0).is_err());
assert!(quality_report(&model, Some(&[99]), 4).is_err());
assert_eq!(quality_report(&model, Some(&[1, 1, 3]), 4).unwrap().measured, 2);
let gate = validity_gate(&model, &GateOptions::default()).unwrap();
assert_eq!((gate.checked, gate.invalid.clone()), (4, vec![2, 4]));
assert!(gate.into_result().is_err());
// The same gate straight from a mesher's arrays.
let raw = validity_gate_elements([(7, ElementKind::Hex8, inside_out_hex()), (8, ElementKind::Hex8, UNIT_CUBE.to_vec())], &GateOptions::default())
.unwrap();
assert_eq!((raw.checked, raw.invalid), (2, vec![7]));
// A unit cube split into six tetrahedra around the 1-7 diagonal passes against its boundary.
let c = UNIT_CUBE;
let tets = [[0, 1, 2, 6], [0, 2, 3, 6], [0, 3, 7, 6], [0, 7, 4, 6], [0, 4, 5, 6], [0, 5, 1, 6]];
let model = model_of(&tets.map(|t| (ElementKind::Tet4, t.iter().map(|&i| c[i]).collect())));
let faces = [[0, 3, 2], [0, 2, 1], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
let boundary = enclosed_volume(&c, &faces).unwrap();
assert!(close(boundary, 1.0));
let ok = validity_gate(&model, &GateOptions { expected_volume: Some(boundary), ..Default::default() }).unwrap();
assert!(ok.passed(), "{ok:?}");
let short = validity_gate(&model, &GateOptions { expected_volume: Some(1.1), ..Default::default() }).unwrap();
assert!(!short.volume_matches() && short.into_result().is_err());
assert!(validity_gate(&model, &GateOptions { expected_volume: Some(f64::NAN), ..Default::default() }).is_err());
assert!(enclosed_volume(&c, &[[0, 1, 99]]).is_err());
}
+24 -6
View File
@@ -47,9 +47,17 @@ pub struct Node {
pub xyz: [f64; 3],
}
/// Element topology. Node order follows each family's usual corner-first convention (corners,
/// then mid-side nodes), which all three target formats share for these shapes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
/// Element topology. Node order is corners first, then mid-side nodes, with positive volume for
/// a right-handed ordering:
/// - tet10: mid-side nodes 5–10 on edges 1-2, 2-3, 3-1, 1-4, 2-4, 3-4;
/// - wedge6: triangle 1-2-3, then 4-5-6 on the side its right-hand normal points to;
/// - hex8/hex20: face 1-2-3-4, then 5-6-7-8 above it; hex20's mid-side nodes 9–12 lie on the
/// edges of 1-2-3-4, 13–16 on those of 5-6-7-8, and 17–20 on edges 1-5, 2-6, 3-7, 4-8.
///
/// This is the Abaqus (and VTK) order. Formats that order nodes differently, such as Nastran's
/// CHEXA with 20 nodes (vertical edges before the top face's edges), are reordered by their deck
/// readers and writers; the model never stores another order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ElementKind {
/// Two-node axial member.
@@ -148,6 +156,15 @@ pub struct Model {
elements: BTreeMap<u32, Element>,
}
/// Check that every coordinate is a finite number within ±[`MAX_COORD`] (the rule for nodes,
/// shared by everything that takes coordinates from outside).
pub fn check_coordinates(xyz: &[f64]) -> Result<()> {
match xyz.iter().find(|c| !(c.abs() <= MAX_COORD)) {
Some(c) => Err(Error::Invalid(format!("coordinate {c} is not a finite number within ±{MAX_COORD}"))),
None => Ok(()),
}
}
fn check_label(what: &str, label: u32) -> Result<()> {
if label == 0 || label > MAX_LABEL {
return Err(Error::Invalid(format!("{what} label {label} is outside 1..={MAX_LABEL}")));
@@ -175,9 +192,10 @@ impl Model {
/// Add a node, or move an existing one (decks redefine nodes the same way).
pub fn set_node(&mut self, label: u32, xyz: [f64; 3]) -> Result<()> {
check_label("node", label)?;
if let Some(c) = xyz.iter().find(|c| !(c.abs() <= MAX_COORD)) {
return Err(Error::Invalid(format!("node {label}: coordinate {c} is not a finite number within ±{MAX_COORD}")));
}
check_coordinates(&xyz).map_err(|e| match e {
Error::Invalid(m) => Error::Invalid(format!("node {label}: {m}")),
e => e,
})?;
if !self.nodes.contains_key(&label) && self.nodes.len() >= MAX_NODES {
return Err(Error::Limit(format!("a model holds at most {MAX_NODES} nodes")));
}
+13
View File
@@ -746,6 +746,19 @@ Delaunay methods with Voronoi/ODT improvement, then (2) all-hex and hex-dominant
- **HexaLab** (MIT, https://github.com/mlivesu/HexaLab, https://www.hexalab.net) is READ OK. It is a hex quality
viewer whose metrics match the literature's.
**B6.6 Element validity and quality metrics (implemented in `modelcraft-mesh::quality`).** **Verdict: BUILD ON.**
- **Johnen, Remacle, Geuzaine 2013.** *Geometrical validity of curvilinear finite elements.* J. Comput. Phys.
233:359–372, 2013. The Jacobian
determinant in Bernstein form is bounded by its coefficients, equals them at the vertices and converges under
subdivision: a proof of `det J > 0` on the whole element. We use it for every solid kind, including the trilinear
hex whose corner Jacobians do not certify it (B6.3). (The paper's authors also wrote Gmsh code, which stays
forbidden; the paper is enough.)
- **Shewchuk 2002.** *What is a good linear finite element? Interpolation, conditioning, anisotropy, and quality
measures.* Unpublished preprint, https://people.eecs.berkeley.edu/~jrs/papers/elemj.pdf. Tet dihedral angles,
radius ratio and aspect ratio; with Klingner–Shewchuk's volume–length measure (A3.6).
- **Knupp 2001.** *Algebraic mesh quality metrics.* SIAM J. Sci. Comput. 23(1):193–218. Corner-Jacobian scaled Jacobian and
shape metrics for hexes, wedges and quads.
---
## Part C: Datasets and benchmarks