forked from 1b-agent/fieldcraft-upstream
engine: mesh.quality.mark marks the elements below quality limits as a set
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
33d69fc53d
commit
75b8c7e26e
10 files changed
+237
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user