diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 47dbd4f..e71fe39 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -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 |
diff --git a/assets/icons/mesh.quality.mark.svg b/assets/icons/mesh.quality.mark.svg
new file mode 100644
index 0000000..1e4df40
--- /dev/null
+++ b/assets/icons/mesh.quality.mark.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/assets/icons/mesh.quality.mark.vectorcraft b/assets/icons/mesh.quality.mark.vectorcraft
new file mode 100644
index 0000000..7f37fe2
Binary files /dev/null and b/assets/icons/mesh.quality.mark.vectorcraft differ
diff --git a/assets/icons/src/mesh.quality.mark.json b/assets/icons/src/mesh.quality.mark.json
new file mode 100644
index 0000000..9c3ee2c
--- /dev/null
+++ b/assets/icons/src/mesh.quality.mark.json
@@ -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"}]}
diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs
index a8933c0..a149666 100644
--- a/crates/engine/src/catalogue.rs
+++ b/crates/engine/src/catalogue.rs
@@ -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"]),
diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs
index f4050d9..2c98800 100644
--- a/crates/engine/src/cmd/forms.rs
+++ b/crates/engine/src/cmd/forms.rs
@@ -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,
diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs
index b02d1fe..5e29ee5 100644
--- a/crates/engine/src/cmd/mesh.rs
+++ b/crates/engine/src/cmd/mesh.rs
@@ -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 {
}))
}
+/// 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 {
+ let mut limits: std::collections::BTreeMap = 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::(), 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 = 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::>(),
+ })
+ })
+ .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::>(),
+ "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 = 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::>(), [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();
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index e91601f..52161d0 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -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,
diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs
index 0c8e44d..6c7034d 100644
--- a/crates/engine/src/view.rs
+++ b/crates/engine/src/view.rs
@@ -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 {
+ 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, 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, 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);
diff --git a/docs/features.md b/docs/features.md
index 6e01156..5f4fd3e 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -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` |