forked from 1b-agent/fieldcraft-upstream
ui: Mesh Quality shows counts, a histogram per metric and the worst elements (#113)
The context panel reads mesh.quality's reply as the counts (an area only for a surface mesh), each metric's min, mean and max over its histogram (the bad end warm), and its worst element: a click selects and frames it. Invalid and undetermined elements have a button that selects and frames them. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
d1557a87fe
commit
c0cbcadf3d
2 files changed
+181
No files matched your search
@@ -1595,6 +1595,37 @@ impl ModelApp {
|
||||
self.cam.fit_in(&b, aspect, free);
|
||||
}
|
||||
|
||||
/// Select elements `labels` in the viewport and frame them.
|
||||
pub fn show_elements(&mut self, labels: &[u32]) {
|
||||
self.select(labels.iter().map(|l| selection::Item::Element(*l)), selection::Mode::Replace);
|
||||
self.frame_elements(labels);
|
||||
}
|
||||
|
||||
/// Frame elements `labels` (as drawn undeformed), keeping the direction of view; a tiny
|
||||
/// element is framed with some of the model around it.
|
||||
pub fn frame_elements(&mut self, labels: &[u32]) {
|
||||
let model = &self.session.model;
|
||||
let mut b = Aabb3::EMPTY;
|
||||
for e in labels.iter().filter_map(|l| model.element(*l)) {
|
||||
for n in e.nodes.iter().filter_map(|n| model.node(*n)) {
|
||||
b.add(Vec3::from(n.xyz));
|
||||
}
|
||||
}
|
||||
if b.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.reframe = None;
|
||||
self.scene();
|
||||
// At least an eighth of the model across.
|
||||
let pad = (self.drawn_bounds().diagonal() / 8.0 - b.diagonal()).max(0.0) / 2.0;
|
||||
let d = Vec3::new(pad, pad, pad);
|
||||
let (lo, hi) = (b.min - d, b.max + d);
|
||||
b.add(lo);
|
||||
b.add(hi);
|
||||
let (aspect, free) = self.free_part();
|
||||
self.cam.fit_in(&b, aspect, free);
|
||||
}
|
||||
|
||||
/// The viewport's aspect and the part of it the overlays leave free, as fractions (the whole
|
||||
/// of a square view before the viewport is first drawn).
|
||||
fn free_part(&self) -> (f64, [f64; 4]) {
|
||||
|
||||
@@ -212,6 +212,8 @@ enum Act {
|
||||
Rerun,
|
||||
/// Pick a design entity in the viewport (a body, face or edge from the Bodies list).
|
||||
Pick(fieldcraft_model::GeometryRef),
|
||||
/// Select elements in the viewport and frame them (Mesh Quality's worst and invalid ones).
|
||||
Elements(Vec<u32>),
|
||||
}
|
||||
|
||||
/// The reply's section of the context panel.
|
||||
@@ -238,6 +240,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
c if SOLVES.contains(&c) => solve_summary(ui, t, &reply.value, units),
|
||||
"model.activity" => activity(ui, t, &reply.value),
|
||||
"geometry.bodies" => bodies(ui, t, &reply.value, &mut acts),
|
||||
"mesh.quality" => quality(ui, t, &reply.value, &mut acts),
|
||||
_ => value(ui, t, egui::Id::new("fc_reply"), &reply.value, 0),
|
||||
}
|
||||
for a in acts {
|
||||
@@ -252,6 +255,7 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let _ = app.run(&reply.command, reply.params.clone());
|
||||
}
|
||||
Act::Pick(g) => app.select([crate::selection::Item::Geometry(g)], crate::selection::Mode::Replace),
|
||||
Act::Elements(labels) => app.show_elements(&labels),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +329,133 @@ const MAX_ENTITIES: usize = 500;
|
||||
/// and its size; a click on the name picks the body, and its faces and edges unfold, by
|
||||
/// persistent name (edges with their lengths), each picked by a click: the names the seed and set
|
||||
/// forms ask for. Design geometry is in millimetres.
|
||||
/// Mesh Quality's metrics: key, label, and whether a low value is the bad end.
|
||||
const METRICS: [(&str, &str, bool); 4] = [
|
||||
("scaled_jacobian", "Scaled Jacobian", true),
|
||||
("jacobian_ratio", "Jacobian ratio", true),
|
||||
("shape", "Shape", true),
|
||||
("edge_ratio", "Edge ratio", false),
|
||||
];
|
||||
|
||||
/// Elements a Select button selects at most.
|
||||
const MAX_SELECT: usize = 10_000;
|
||||
|
||||
/// `mesh.quality`'s reply: the counts, then each metric's range, histogram and worst element (a
|
||||
/// click selects and frames it), and buttons selecting the invalid and undetermined elements.
|
||||
fn quality(ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec<Act>) {
|
||||
let n = |k: &str| v.get(k).and_then(Value::as_u64).unwrap_or(0);
|
||||
let labels = |k: &str| -> Vec<u32> {
|
||||
v.pointer(&format!("/{k}/labels"))
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|l| l.as_u64()?.try_into().ok())
|
||||
.take(MAX_SELECT)
|
||||
.collect()
|
||||
};
|
||||
let (invalid, undetermined) =
|
||||
(v.pointer("/invalid/count").and_then(Value::as_u64).unwrap_or(0), v.pointer("/undetermined/count").and_then(Value::as_u64).unwrap_or(0));
|
||||
egui::Grid::new("fc_quality_counts").num_columns(2).spacing(vec2(10.0, 2.0)).show(ui, |ui| {
|
||||
let mut row = |k: &str, text: RichText| {
|
||||
ui.label(RichText::new(k).color(t.text_dim));
|
||||
ui.label(text);
|
||||
ui.end_row();
|
||||
};
|
||||
row("Elements", RichText::new(count(n("measured"))));
|
||||
row("Valid", RichText::new(count(n("valid"))));
|
||||
row("Invalid", if invalid > 0 { RichText::new(count(invalid)).color(t.error) } else { RichText::new("0") });
|
||||
if undetermined > 0 {
|
||||
row("Undetermined", RichText::new(count(undetermined)).color(t.warm));
|
||||
}
|
||||
if let Some(vol) = v.get("volume").and_then(Value::as_f64).filter(|x| *x > 0.0) {
|
||||
row("Volume", RichText::new(number(vol)));
|
||||
}
|
||||
// A surface mesh's area; a solid mesh has none to show.
|
||||
if let Some(area) = v.get("area").and_then(Value::as_f64).filter(|x| *x > 0.0) {
|
||||
row("Area", RichText::new(number(area)));
|
||||
}
|
||||
});
|
||||
for (key, what) in [("invalid", "invalid"), ("undetermined", "undetermined")] {
|
||||
let l = labels(key);
|
||||
if !l.is_empty()
|
||||
&& ui
|
||||
.button(format!("Select the {} {what} elements", count(l.len() as u64)))
|
||||
.on_hover_text("Select and frame them in the viewport")
|
||||
.clicked()
|
||||
{
|
||||
acts.push(Act::Elements(l));
|
||||
}
|
||||
}
|
||||
for (key, label, low_bad) in METRICS {
|
||||
let Some(m) = v.pointer(&format!("/metrics/{key}")).filter(|m| m.is_object()) else { continue };
|
||||
let f = |k: &str| m.get(k).and_then(Value::as_f64);
|
||||
ui.add_space(6.0);
|
||||
ui.label(RichText::new(label).strong());
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"min {} · mean {} · max {}",
|
||||
f("min").map_or("—".into(), number),
|
||||
f("mean").map_or("—".into(), number),
|
||||
f("max").map_or("—".into(), number)
|
||||
))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
let counts: Vec<u64> = m.pointer("/histogram/counts").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_u64).collect();
|
||||
let range: Vec<f64> = m.pointer("/histogram/range").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect();
|
||||
if let ([lo, hi], false) = (range.as_slice(), counts.is_empty()) {
|
||||
histogram(ui, t, &counts, (*lo, *hi), low_bad);
|
||||
}
|
||||
if let Some(worst) = m.get("worst").and_then(Value::as_u64).and_then(|w| u32::try_from(w).ok()) {
|
||||
let value = if low_bad { f("min") } else { f("max") };
|
||||
let text = format!("Worst: element {worst}{}", value.map(|x| format!(" ({})", number(x))).unwrap_or_default());
|
||||
if ui.link(text).on_hover_text("Select and frame it in the viewport").clicked() {
|
||||
acts.push(Act::Elements(vec![worst]));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(skipped) = v.get("skipped").and_then(Value::as_object).filter(|s| !s.is_empty()) {
|
||||
ui.add_space(6.0);
|
||||
let text: Vec<String> = skipped.iter().map(|(k, n)| format!("{k}: {}", n.as_u64().map_or_else(|| n.to_string(), count))).collect();
|
||||
ui.label(RichText::new(format!("Not measured: {}", text.join(", "))).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
|
||||
/// A histogram of `counts` over `range`, its bars shaded from the bad end (warm) to the good
|
||||
/// one (accent); the range's ends under it.
|
||||
fn histogram(ui: &mut egui::Ui, t: &Tokens, counts: &[u64], range: (f64, f64), low_bad: bool) {
|
||||
let width = ui.available_width().clamp(120.0, 320.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 48.0), egui::Sense::hover());
|
||||
let p = ui.painter_at(rect);
|
||||
p.rect_filled(rect, 2.0, t.panel_header);
|
||||
let top = counts.iter().copied().max().unwrap_or(0).max(1) as f32;
|
||||
let w = rect.width() / counts.len() as f32;
|
||||
let bins = counts.len().max(1) as f32;
|
||||
for (i, c) in counts.iter().enumerate() {
|
||||
let h = (*c as f32 / top).sqrt() * (rect.height() - 2.0);
|
||||
let x = rect.left() + i as f32 * w;
|
||||
let good = if low_bad { (i as f32 + 0.5) / bins } else { 1.0 - (i as f32 + 0.5) / bins };
|
||||
let color = if good < 0.3 { t.warm } else { t.accent };
|
||||
if *c > 0 {
|
||||
p.rect_filled(
|
||||
egui::Rect::from_min_max(egui::pos2(x + 1.0, rect.bottom() - h.max(1.0)), egui::pos2(x + w - 1.0, rect.bottom())),
|
||||
0.0,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
let step = (range.1 - range.0) / bins as f64;
|
||||
resp.on_hover_ui(|ui| {
|
||||
for (i, c) in counts.iter().enumerate() {
|
||||
let a = range.0 + step * i as f64;
|
||||
ui.label(format!("{} – {}: {}", number(a), number(a + step), count(*c)));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(number(range.0)).small().color(t.text_dim));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| ui.label(RichText::new(number(range.1)).small().color(t.text_dim)));
|
||||
});
|
||||
}
|
||||
|
||||
fn bodies(ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec<Act>) {
|
||||
let q = crate::describe::quantity;
|
||||
let pick = |body: &str, entity: fieldcraft_model::Entity| fieldcraft_model::GeometryRef { body: body.to_string(), entity };
|
||||
@@ -666,6 +797,25 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Mesh Quality's reply draws as counts, histograms and worst elements, and a worst element
|
||||
/// is selected and framed (#113).
|
||||
#[test]
|
||||
fn mesh_quality_shows_its_metrics_and_frames_the_worst_element() {
|
||||
let mut app = ModelApp::new(fieldcraft_engine::Session::default());
|
||||
app.open_sample("cantilever");
|
||||
app.run("mesh.quality", json!({})).unwrap();
|
||||
assert_eq!(app.reply.as_ref().map(|r| r.command.as_str()), Some("mesh.quality"));
|
||||
let t = Tokens::get(false);
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui, &t));
|
||||
out.textures_delta.clear();
|
||||
let worst = app.reply.as_ref().and_then(|r| r.value.pointer("/metrics/scaled_jacobian/worst")?.as_u64()).unwrap() as u32;
|
||||
let before = app.cam;
|
||||
app.show_elements(&[worst]);
|
||||
assert_eq!(app.pick.selection.summary(), "1 element");
|
||||
assert_ne!(app.cam, before, "framed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summaries_are_sentences() {
|
||||
let check = json!({"ready": false, "errors": 2, "warnings": 1, "diagnostics": []});
|
||||
|
||||
Reference in new issue
Block a user