mesh: the tet report carries the scaled Jacobian; both metrics tested on the same tets (#547)

The dihedral range and mesh.quality's scaled Jacobian were both right:
they are computed by the same function on the same final corners (tet4
and tet10 give identical numbers), but a dihedral range does not bound
the corner scaled Jacobian. A needle has dihedral angles near 60 and 90
degrees and a nearly flat apex corner. Before 417c0c88 sphere-on-plate
at size 4 had dihedrals 36.3-141.6 and 106 needles below SJ 0.2 (min
0.095); now 36.0-137.6 and min SJ 0.351.

The mesher's report now carries min_scaled_jacobian so it cannot read
as all-good while needles remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-17 08:12:09 -04:00
1 parent e315b2bef7
commit 7d419b73f4
4 files changed
+78 -1

No files matched your search

+1
View File
@@ -250,6 +250,7 @@ pub(super) fn quality_json(q: &TetQuality) -> Value {
"max_dihedral": q.max_dihedral,
"min_radius_ratio": q.min_radius_ratio,
"mean_radius_ratio": q.mean_radius_ratio,
"min_scaled_jacobian": q.min_scaled_jacobian,
"slivers": q.slivers,
"inadmissible": q.inadmissible,
})
+37
View File
@@ -44,6 +44,43 @@ fn regular_tetrahedron_scores_one() {
}
}
/// The dihedral angles and the scaled Jacobian measure different things on the same corners
/// (#547): a flat tetrahedron is low on both; a needle has every dihedral angle between 60° and
/// 90° but a nearly flat apex corner, so only the scaled Jacobian sees it. A tet10 with straight
/// edges scores what its corners score, and the tet mesher's report reads the same numbers.
#[test]
fn dihedral_angles_and_scaled_jacobian_on_the_same_tetrahedra() {
let both = |p: &[[f64; 3]]| {
let q = element_quality(ElementKind::Tet4, p).unwrap();
(metric(&q, Metric::MinDihedral), metric(&q, Metric::MaxDihedral), metric(&q, Metric::ScaledJacobian))
};
let (lo, hi, sj) = both(&regular_tet());
assert!((lo - 70.528_779).abs() < 1e-5 && (hi - lo).abs() < 1e-9 && close(sj, 1.0), "{lo} {hi} {sj}");
let flat = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [1., 1., 0.01]];
let (lo, hi, sj) = both(&flat);
assert!(lo < 2.0 && hi > 178.0 && sj > 0.0 && sj < 0.02, "{lo} {hi} {sj}");
// Equilateral base of side 1, apex 12 above its centre.
let h = 3.0_f64.sqrt() / 2.0;
let needle = [[0., 0., 0.], [1., 0., 0.], [0.5, h, 0.], [0.5, h / 3.0, 12.0]];
let (lo, hi, sj) = both(&needle);
assert!(lo > 59.0 && hi < 91.0, "{lo} {hi}");
// Apex corner: 6V = (√3/2)·12, edges √(1/3 + 144) each.
let edge = (1.0 / 3.0 + 144.0_f64).sqrt();
assert!(close(sj, 2.0_f64.sqrt() * h * 12.0 / edge.powi(3)), "{sj}");
assert!(sj < 0.01);
for p in [regular_tet(), flat, needle] {
let tet10 = element_quality(ElementKind::Tet10, &midpoints(&p, &jacobian::TET_EDGES)).unwrap();
let (lo, hi, sj) = both(&p);
assert!(close(metric(&tet10, Metric::MinDihedral), lo) && close(metric(&tet10, Metric::MaxDihedral), hi));
assert!(close(metric(&tet10, Metric::ScaledJacobian), sj));
let r = crate::tet::tet_quality_stats(&p, &[[0, 1, 2, 3]]).unwrap();
assert!(close(r.min_dihedral, lo) && close(r.max_dihedral, hi) && close(r.min_scaled_jacobian, sj), "{r:?}");
}
}
#[test]
fn right_corner_tetrahedron() {
let q = element_quality(ElementKind::Tet4, &RIGHT_TET).unwrap();
+7 -1
View File
@@ -140,6 +140,10 @@ pub struct TetQuality {
pub max_dihedral: f64,
pub min_radius_ratio: f64,
pub mean_radius_ratio: f64,
/// The smallest scaled Jacobian at the worst corner (as `mesh.quality` reports it). The
/// dihedral range does not bound it: a needle has every dihedral angle near 60° or 90° and a
/// nearly flat corner (#547).
pub min_scaled_jacobian: f64,
/// Tetrahedra with a dihedral angle below 10° or above 170°.
pub slivers: usize,
/// Tetrahedra not admissible by FieldCraft's validity criterion.
@@ -149,7 +153,7 @@ pub struct TetQuality {
/// The quality of tetrahedra given by corner coordinates, with the metrics `mesh.quality`
/// reports ([`crate::quality::element_quality`]).
pub fn tet_quality_stats(points: &[[f64; 3]], tets: &[[u32; 4]]) -> Result<TetQuality> {
let mut s = TetQuality { min_dihedral: 180.0, min_radius_ratio: 1.0, ..TetQuality::default() };
let mut s = TetQuality { min_dihedral: 180.0, min_radius_ratio: 1.0, min_scaled_jacobian: 1.0, ..TetQuality::default() };
let mut sum = 0.0;
for t in tets {
let p = |v: u32| points.get(v as usize).copied().ok_or_else(|| Error::Failed("quality: vertex id".into()));
@@ -159,6 +163,7 @@ pub fn tet_quality_stats(points: &[[f64; 3]], tets: &[[u32; 4]]) -> Result<TetQu
s.min_dihedral = s.min_dihedral.min(lo);
s.max_dihedral = s.max_dihedral.max(hi);
s.min_radius_ratio = s.min_radius_ratio.min(rr);
s.min_scaled_jacobian = s.min_scaled_jacobian.min(m(Metric::ScaledJacobian));
sum += rr;
s.slivers += usize::from(!(lo >= 10.0 && hi <= 170.0));
s.inadmissible += usize::from(q.validity() != Some(Validity::Valid));
@@ -167,6 +172,7 @@ pub fn tet_quality_stats(points: &[[f64; 3]], tets: &[[u32; 4]]) -> Result<TetQu
if tets.is_empty() {
s.min_dihedral = 0.0;
s.min_radius_ratio = 0.0;
s.min_scaled_jacobian = 0.0;
} else {
s.mean_radius_ratio = sum / tets.len() as f64;
}
+33
View File
@@ -0,0 +1,33 @@
//! Sphere-on-plate at size 4 (#547): the tet mesher's report and `mesh.quality` measure the same
//! final elements, tet4 and tet10 alike, and no needle is left below the skill's scaled Jacobian
//! limit although every dihedral angle is fine.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use fieldcraft_engine::Session;
use fieldcraft_testkit::corpus;
use serde_json::json;
#[test]
fn sphere_on_plate_report_and_quality_agree() {
let parts = corpus::parts().unwrap();
let p = parts.iter().find(|p| p.name == "sphere-on-plate").unwrap();
let dir = std::env::temp_dir().join(format!("fieldcraft-tet-metric-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("part.solidcraft");
std::fs::write(&path, corpus::design_bytes(p).unwrap()).unwrap();
for order in ["linear", "quadratic"] {
let mut s = Session::default();
s.execute("geometry.open", &json!({"path": path})).unwrap();
let r = s.execute("mesh.tet.generate", &json!({"body": "Plate", "size": 4.0, "order": order})).unwrap();
let mesh = &r["bodies"][0]["quality"]["mesh"];
let q = s.execute("mesh.quality", &json!({})).unwrap();
let m = &q["metrics"];
let f = |v: &serde_json::Value| v.as_f64().unwrap();
assert_eq!(f(&mesh["min_dihedral"]), f(&m["min_dihedral"]["min"]), "{order}");
assert_eq!(f(&mesh["max_dihedral"]), f(&m["max_dihedral"]["max"]), "{order}");
assert_eq!(f(&mesh["min_scaled_jacobian"]), f(&m["scaled_jacobian"]["min"]), "{order}");
assert!(f(&mesh["min_dihedral"]) > 30.0 && f(&mesh["max_dihedral"]) < 145.0, "{order}: {mesh}");
assert!(f(&mesh["min_scaled_jacobian"]) >= 0.3, "{order}: {mesh}");
}
let _ = std::fs::remove_dir_all(&dir);
}