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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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

+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}")))
}