engine: mesh.quality.mark marks the elements below quality limits as a set

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 23:32:47 -04:00
1 parent 33d69fc53d
commit 75b8c7e26e
10 files changed
+237 -1

No files matched your search

+1
View File
@@ -93,6 +93,7 @@ is committed and listed here with its exports.
| assets/icons/mesh.order.quadratic.svg, assets/icons/mesh.order.quadratic.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.order.quadratic.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quad.faces.svg, assets/icons/mesh.quad.faces.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quad.faces.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quality.svg, assets/icons/mesh.quality.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.quality.mark.svg, assets/icons/mesh.quality.mark.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.quality.mark.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/results.export.vtu.svg, assets/icons/results.export.vtu.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/results.export.vtu.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.export.stl.svg, assets/icons/mesh.export.stl.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.export.stl.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
| assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 |
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>mesh.quality.mark</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 3 L11 3 L11 11 L3 11 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 3 L21 3 L21 11 L13 11 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 13 L11 13 L11 21 L3 21 Z" fill="#2d4e70" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13 14 L21 13 L20 21 L14 19 Z" fill="#f0b44a" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"shapes": [{"rect": [3, 3, 8, 8], "fill": "soft", "stroke": "line"}, {"rect": [13, 3, 8, 8], "fill": "soft", "stroke": "line"}, {"rect": [3, 13, 8, 8], "fill": "soft", "stroke": "line"}, {"d": "M13 14 L21 13 L20 21 L14 19 Z", "fill": "warm", "stroke": "line"}]}
+7
View File
@@ -405,6 +405,13 @@ pub static CATALOGUE: &[Capability] = &[
soon("mesh.region.remesh", "Remesh Region", Mesh, "Edit", "M6", "Remesh a region in place; split and collapse elements"),
soon("mesh.import.orphan", "Orphan Mesh", Mesh, "Edit", "M6", "Import a mesh without geometry"),
ready("mesh.quality", "Quality", Mesh, "Verify", "Quality metrics and the certified validity check, failing elements listed"),
ready(
"mesh.quality.mark",
"Mark Bad Elements",
Mesh,
"Verify",
"Mark the elements below quality limits as a set, located and drawn in the marker colour",
),
soon("mesh.verify", "Verify Set", Mesh, "Verify", "M2", "Quality and validity per set"),
ready("mesh.export.stl", "Export STL", Mesh, "Verify", "Write the mesh's outer surface (or a set's faces) as binary STL"),
ready_more("mesh.tag", "Tag Geometry", Mesh, "Verify", "Tag elements, faces, edges and nodes with the geometry they mesh", &["mesh.tag.clear"]),
+19
View File
@@ -1275,6 +1275,25 @@ pub const QUALITY: &[Field] = &[
int("bins", "Histogram bins", 1, 1000).help("Number of bars in the quality histogram.").def("10"),
];
pub const QUALITY_MARK: &[Field] = &[
labels("elements", "Elements", LabelOf::Element).help("The elements to check; all when empty.").hint("all"),
group(
"limits",
"Limits",
&[
num("scaled_jacobian", "Scaled Jacobian at least", D::None)
.help("Elements with a lower scaled Jacobian are marked (1 is a perfect element).")
.hint("0.2"),
num("min_dihedral", "Min dihedral at least", D::Degree).help("Tetrahedra with a smaller dihedral angle are marked.").hint("10"),
num("max_dihedral", "Max dihedral at most", D::Degree).help("Tetrahedra with a larger dihedral angle are marked.").hint("165"),
num("aspect_ratio", "Aspect ratio at most", D::None).help("Elements with a larger aspect ratio are marked.").hint("10"),
num("min_angle", "Min angle at least", D::Degree).help("Shells with a smaller corner angle are marked.").hint("15"),
num("max_angle", "Max angle at most", D::Degree).help("Shells with a larger corner angle are marked.").hint("165"),
],
)
.help("Elements worse than a limit are marked; a limit left empty keeps its default."),
];
pub const SURFACE_CHECK: &[Field] = &[
reference("body", "Body", C::Body).help("The body whose surface to check.").req().hint("or an imported surface"),
SURFACE,
+174
View File
@@ -67,6 +67,16 @@ pub const QUALITY: CommandSpec = CommandSpec {
run: quality_report,
};
pub const QUALITY_MARK: CommandSpec = CommandSpec {
id: "mesh.quality.mark",
label: "Mark Bad Elements",
params: r#"{"limits"?: {"scaled_jacobian": 0.2, "min_dihedral": 10, "max_dihedral": 165, "aspect_ratio": 10, "min_angle": 15, "max_angle": 165} (the defaults; given limits are merged over them, null drops one; any metric of mesh.quality may be limited: a floor for those where higher is better, a ceiling for max_dihedral, aspect_ratio, edge_ratio, max_angle and skew; angles in degrees), "elements"?: [labels] (default all)} — after meshing, marks the elements that fail a limit (and every solid not proven valid) as the element set `quality_marked`, replaced on every run and removed when none fail; the viewport and `fieldcraft-cli snapshot` draw its elements in the marker colour. Replies `summary`, `measured`, `marked`, `invalid`, the `limits` used and `elements` (worst first: not valid, then lowest scaled Jacobian; at most 1000, `truncated` says when more): each `label`, `kind`, `body`, `at` (its centroid), `valid` and `failed` ({metric, value, limit} per limit it fails). One undo step"#,
form: Some(super::forms::QUALITY_MARK),
kind: CommandKind::Model,
requires: &[Requirement::Mesh],
run: quality_mark,
};
pub const EXPORT_STL: CommandSpec = CommandSpec {
id: "mesh.export.stl",
label: "Export STL",
@@ -650,6 +660,103 @@ fn quality_report(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
/// The element set `mesh.quality.mark` keeps the marked elements in.
pub const MARKED_SET: &str = "quality_marked";
/// The default limits of `mesh.quality.mark`.
const MARK_LIMITS: [(quality::Metric, f64); 6] = [
(quality::Metric::ScaledJacobian, 0.2),
(quality::Metric::MinDihedral, 10.0),
(quality::Metric::MaxDihedral, 165.0),
(quality::Metric::AspectRatio, 10.0),
(quality::Metric::MinAngle, 15.0),
(quality::Metric::MaxAngle, 165.0),
];
/// Most marked elements `mesh.quality.mark` lists.
const MAX_MARKED_LISTED: usize = 1000;
fn quality_mark(s: &mut Session, p: &Value) -> Result<Value> {
let mut limits: std::collections::BTreeMap<quality::Metric, f64> = MARK_LIMITS.into_iter().collect();
match p.get("limits") {
None | Some(Value::Null) => {}
Some(Value::Object(given)) => {
for (name, v) in given {
let metric = quality::Metric::parse(name).ok_or_else(|| {
let known: Vec<&str> = quality::Metric::ALL.iter().map(|m| m.name()).collect();
EngineError::Params(format!("no quality metric `{}` (one of {})", name.chars().take(40).collect::<String>(), known.join(", ")))
})?;
match v {
Value::Null => {
limits.remove(&metric);
}
v => {
let limit = v
.as_f64()
.filter(|x| x.is_finite())
.ok_or_else(|| EngineError::Params(format!("the limit of `{name}` must be a number")))?;
limits.insert(metric, limit);
}
}
}
}
Some(_) => return Err(EngineError::Params("`limits` must be an object of metric names and numbers".into())),
}
let selection = match p.get("elements") {
None => None,
Some(_) => Some(labels(p, "elements", MAX_ELEMENTS)?),
};
let limits: Vec<(quality::Metric, f64)> = limits.into_iter().collect();
let (measured, marked) = quality::mark_elements(&s.model, selection.as_deref(), &limits)?;
let set = fieldcraft_model::Set { elements: marked.iter().map(|m| m.label).collect(), ..Default::default() };
if set.elements.is_empty() {
if s.model.set(MARKED_SET).is_some() {
s.model.remove_set(MARKED_SET)?;
}
} else if s.model.set(MARKED_SET).is_some() {
s.model.replace_set(MARKED_SET, set)?;
} else {
s.model.add_set(MARKED_SET, set)?;
}
let invalid = marked.iter().filter(|m| m.is_invalid()).count();
let c = s.model.classification();
let body = |label: u32| c.elements().get(&label).and_then(|t| t.region).and_then(|r| c.entity(r)).map(|g| g.body.clone());
let elements: Vec<Value> = marked
.iter()
.take(MAX_MARKED_LISTED)
.map(|m| {
json!({
"label": m.label,
"kind": m.kind.name(),
"body": body(m.label),
"at": m.centroid,
"valid": m.validity.map(|v| v == quality::Validity::Valid),
"failed": m.failed.iter().map(|(metric, v, limit)| json!({"metric": metric.name(), "value": number(*v), "limit": limit})).collect::<Vec<_>>(),
})
})
.collect();
let mut summary = format!("{} of {measured} elements marked", marked.len());
if invalid > 0 {
summary.push_str(&format!(", {invalid} not valid"));
}
if let Some(worst) = marked.first().and_then(|m| m.scaled_jacobian) {
summary.push_str(&format!("; worst scaled Jacobian {worst:.3}"));
}
if !marked.is_empty() {
summary.push_str(&format!(" (set {MARKED_SET})"));
}
Ok(json!({
"summary": summary,
"set": (!marked.is_empty()).then_some(MARKED_SET),
"measured": measured,
"marked": marked.len(),
"invalid": invalid,
"limits": limits.iter().map(|(m, v)| (m.name().to_string(), json!(v))).collect::<serde_json::Map<_, _>>(),
"elements": elements,
"truncated": marked.len() > MAX_MARKED_LISTED,
}))
}
/// A quality report in a line, for the status bar and the CLI: the elements measured, how many
/// are invalid or undetermined, and the scaled Jacobian's minimum and mean.
fn quality_summary(r: &QualityReport) -> String {
@@ -825,6 +932,73 @@ mod tests {
}
}
fn mark_session() -> Session {
let mut s = Session::default();
let corners = json!([[0, 0, 0], [2, 0, 0], [2, 1, 0], [0, 1, 0], [0, 0, 1], [2, 0, 1], [2, 1, 1], [0, 1, 1]]);
s.execute("mesh.block", &json!({"body": "Bar", "kind": "hex8", "corners": corners, "divisions": [2, 1, 1]})).unwrap();
s
}
/// Two good hexes mark nothing; a corner pulled in marks its element, located, as the set
/// the viewport draws; looser limits empty it; undo brings it back.
#[test]
fn marks_bad_elements_as_a_set() {
let mut s = mark_session();
let r = s.execute("mesh.quality.mark", &json!({})).unwrap();
assert_eq!((r["measured"].as_u64(), r["marked"].as_u64()), (Some(2), Some(0)), "{r}");
assert!(s.model.set(super::MARKED_SET).is_none() && r["set"].is_null());
assert_eq!(r["limits"]["scaled_jacobian"], 0.2);
// Pull the far top corner of the second hex nearly onto its middle.
let (&corner, _) = s.model.nodes().iter().find(|(_, n)| n.xyz == [2.0, 1.0, 1.0]).unwrap();
let (&bad, _) = s.model.elements().iter().find(|(_, e)| e.nodes.contains(&corner)).unwrap();
s.execute("mesh.node.add", &json!({"label": corner, "xyz": [1.7, 0.7, 0.7]})).unwrap();
let r = s.execute("mesh.quality.mark", &json!({})).unwrap();
assert_eq!((r["marked"].as_u64(), r["invalid"].as_u64()), (Some(1), Some(0)), "{r}");
assert_eq!(r["set"], super::MARKED_SET);
let e = &r["elements"][0];
assert_eq!((e["label"].as_u64(), e["kind"].as_str(), e["body"].as_str()), (Some(bad as u64), Some("hex8"), Some("Bar")), "{e}");
assert!(e["failed"].as_array().unwrap().iter().any(|f| f["metric"] == "scaled_jacobian"), "{e}");
let at: Vec<f64> = e["at"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!(at[0] > 1.0 && at[0] < 2.0, "{at:?}");
assert!(r["summary"].as_str().unwrap().starts_with("1 of 2 elements marked"), "{r}");
assert_eq!(s.model.set(super::MARKED_SET).unwrap().elements, [bad]);
assert_eq!(crate::view::marked(&s).into_iter().collect::<Vec<_>>(), [bad]);
// The picture draws its faces in the marker colour, and only its.
let p = crate::view::Palette::LIGHT;
let (scene, _, _) = crate::view::scene(&s, &p);
let red = scene.tris.iter().filter(|t| t.color == p.marked).count();
assert_eq!(red, 5 * 2, "the bad hex's outer faces (all but the shared one), as triangles");
// Without limits the (valid) element passes and the set goes.
let all_off = json!({"limits": {"scaled_jacobian": null, "min_dihedral": null, "max_dihedral": null, "aspect_ratio": null, "min_angle": null, "max_angle": null}});
let r = s.execute("mesh.quality.mark", &all_off).unwrap();
assert_eq!(r["marked"], 0, "{r}");
assert!(s.model.set(super::MARKED_SET).is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.model.set(super::MARKED_SET).unwrap().elements, [bad], "undo restores the marks");
// Only the elements asked about.
let other = s.model.elements().keys().copied().find(|l| *l != bad).unwrap();
assert_eq!(s.execute("mesh.quality.mark", &json!({"elements": [other]})).unwrap()["marked"], 0);
}
#[test]
fn mark_rejects_hostile_parameters() {
let mut s = Session::default();
assert!(s.execute("mesh.quality.mark", &json!({})).is_err(), "needs a mesh");
let mut s = mark_session();
for p in [
json!({"limits": {"nope": 1}}),
json!({"limits": {"scaled_jacobian": "low"}}),
json!({"limits": {"scaled_jacobian": true}}),
json!({"limits": [0.2]}),
json!({"limits": {"x".repeat(10000): 1}}),
json!({"elements": [0]}),
json!({"elements": [99]}),
json!({"elements": "all"}),
] {
assert!(s.execute("mesh.quality.mark", &p).is_err(), "{p}");
}
}
#[test]
fn reports_quality_and_inverted_elements() {
let mut s = Session::default();
+1
View File
@@ -75,6 +75,7 @@ pub static ALL: &[CommandSpec] = &[
mesh::ELEMENT_REMOVE,
replace::CLEAR,
mesh::QUALITY,
mesh::QUALITY_MARK,
mesh::EXPORT_STL,
mesh::DELAUNAY_POINTS,
mesh::SURFACE_CHECK,
+22
View File
@@ -41,6 +41,8 @@ pub struct Palette {
pub contour: [Rgb; 5],
/// Values above a clamped legend's maximum.
pub contour_over: Rgb,
/// The elements `mesh.quality.mark` marked.
pub marked: Rgb,
/// The analysis setup's symbols.
pub glyphs: GlyphColors,
}
@@ -73,6 +75,7 @@ impl Palette {
no_result: Rgb(0x9a, 0xa0, 0xaa),
contour: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)],
contour_over: CONTOUR_OVER,
marked: Rgb(0xe0, 0x2e, 0x2e),
glyphs: GlyphColors {
constraint: Rgb(0x00, 0x8a, 0xa8),
load: Rgb(0xd0, 0x24, 0x24),
@@ -218,6 +221,24 @@ fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene {
s
}
/// The elements `mesh.quality.mark` marked (its set), drawn in the marker colour.
pub fn marked(s: &Session) -> std::collections::BTreeSet<u32> {
s.model.set(crate::cmd::mesh::MARKED_SET).map(|set| set.elements.iter().copied().collect()).unwrap_or_default()
}
/// Colour the faces of the `marked` elements in `scene`, whose triangles start with the element
/// faces of `display` in order (as [`model_scene`] and the app's viewport build them).
pub fn mark(scene: &mut Scene, display: &DisplayMesh, marked: &std::collections::BTreeSet<u32>, color: Rgb) {
if marked.is_empty() {
return;
}
for (t, d) in scene.tris.iter_mut().zip(&display.tris) {
if marked.contains(&d.element) {
t.color = color;
}
}
}
/// The model part of a picture: the model or the results view's plot, the shape the symbols sit
/// on (the deformed one of a plot), the box around it and what it shows.
struct Picture {
@@ -251,6 +272,7 @@ fn picture(s: &Session, p: &Palette, section: Option<Section>, toward: Vec3) ->
let glyphs = plot.map_or_else(|| s.glyphs(), |(v, _)| s.step_glyphs(&v.step));
let Some((view, result)) = plot else {
let mut scene = model_scene(&base, p);
mark(&mut scene, &base, &marked(s), p.marked);
let bounds = base.bounds;
let shows = |n: &str| s.shows(fieldcraft_io::ItemKind::Datum, n);
let datums = fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &shows, None, &p.glyphs, &mut scene);
+2 -1
View File
@@ -6,7 +6,7 @@ Every entry is a toolbar button. **Ready** entries run their command; **coming s
shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,
the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
76 ready, 39 coming soon.
77 ready, 39 coming soon.
## Geometry
@@ -57,6 +57,7 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands.
| Edit | Remesh Region — Remesh a region in place; split and collapse elements | coming soon (M6) | |
| Edit | Orphan Mesh — Import a mesh without geometry | coming soon (M6) | |
| Verify | Quality — Quality metrics and the certified validity check, failing elements listed | ready | `mesh.quality` |
| Verify | Mark Bad Elements — Mark the elements below quality limits as a set, located and drawn in the marker colour | ready | `mesh.quality.mark` |
| Verify | Verify Set — Quality and validity per set | coming soon (M2) | |
| Verify | Export STL — Write the mesh's outer surface (or a set's faces) as binary STL | ready | `mesh.export.stl` |
| Verify | Tag Geometry — Tag elements, faces, edges and nodes with the geometry they mesh | ready | `mesh.tag`, `mesh.tag.clear` |