forked from 1b-agent/fieldcraft-upstream
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
847 lines
39 KiB
Rust
847 lines
39 KiB
Rust
//! What a command run from the UI answered, made readable: a one-line summary for the status bar
|
||
//! ([`summary`]) and, when the reply holds more than that line, the whole reply in the context
|
||
//! panel ([`show`]): Check Model as a list of its diagnostics with a way to each fix, the Step
|
||
//! Table as a grid of steps, any other reply as labelled rows and tables. Programmatic callers
|
||
//! (the control channel's `engine.execute`, MCP, scripts) still get the reply's JSON.
|
||
|
||
use egui::{RichText, vec2};
|
||
use serde_json::{Map, Value};
|
||
|
||
use crate::browser::{self, Def};
|
||
use crate::frame::{Level, heading};
|
||
use crate::results::{components, count, number, with_unit};
|
||
use crate::theme::{Tokens, label_font};
|
||
use crate::{ModelApp, Tab};
|
||
use fieldcraft_engine::results::Quantity;
|
||
use fieldcraft_model::UnitSystem;
|
||
|
||
/// The reply shown in the context panel.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Reply {
|
||
pub command: String,
|
||
pub label: String,
|
||
pub params: Value,
|
||
pub value: Value,
|
||
}
|
||
|
||
/// The solvers whose reply is a step's summary ([`solve_summary`]).
|
||
const SOLVES: [&str; 6] = ["solve.static", "solve.nonlinear", "solve.heat", "solve.frequency", "solve.buckle", "solve.explicit"];
|
||
|
||
/// Replies that only read the model, refreshed when it changes; any other reply closes then.
|
||
pub const LIVE: [&str; 2] = ["model.check", "model.activity"];
|
||
|
||
/// The status line's text (after the command's label) is cut to this many characters.
|
||
const MAX_LINE: usize = 120;
|
||
/// Table rows shown before "… n more".
|
||
const MAX_ROWS: usize = 200;
|
||
/// Table columns shown at most.
|
||
const MAX_COLUMNS: usize = 8;
|
||
/// Nesting shown at most; deeper values are compact text.
|
||
const MAX_DEPTH: usize = 4;
|
||
|
||
/// One line saying what `command` answered: the reply's own `summary`, a sentence for the
|
||
/// commands with a well-known reply, otherwise its top-level numbers and words. Never JSON.
|
||
pub fn summary(command: &str, v: &Value) -> String {
|
||
summary_in(command, v, UnitSystem::Unspecified)
|
||
}
|
||
|
||
/// [`summary`] with the numbers' units in `units`.
|
||
pub fn summary_in(command: &str, v: &Value, units: UnitSystem) -> String {
|
||
if let Some(s) = v.get("summary").and_then(Value::as_str) {
|
||
return one_line(s);
|
||
}
|
||
if SOLVES.contains(&command) {
|
||
return one_line(&solve_line(v, units));
|
||
}
|
||
let count = |key: &str| v.get(key).map_or(0, |x| x.as_array().map(Vec::len).or_else(|| x.as_object().map(Map::len)).unwrap_or(0));
|
||
let line = match command {
|
||
"model.check" => {
|
||
let (e, w) = (v["errors"].as_u64().unwrap_or(0), v["warnings"].as_u64().unwrap_or(0));
|
||
if e + w == 0 { "ready to solve, no issues".to_string() } else { format!("{}, {}", plural(e, "error"), plural(w, "warning")) }
|
||
}
|
||
"model.activity" => format!(
|
||
"{}, {}, {}",
|
||
plural(count("steps") as u64, "step"),
|
||
plural(count("boundary_conditions") as u64, "boundary condition"),
|
||
plural(count("loads") as u64, "load")
|
||
),
|
||
"geometry.bodies" => match v.as_array().map(Vec::len) {
|
||
Some(0) => "no bodies (open a design first)".to_string(),
|
||
Some(n) => plural(n as u64, "body").replace("bodys", "bodies"),
|
||
None => scalars(v),
|
||
},
|
||
"model.info" => {
|
||
let elements: u64 = v["elements"].as_object().into_iter().flatten().filter_map(|(_, n)| n.as_u64()).sum();
|
||
format!(
|
||
"{}, {}, units {}",
|
||
plural(v["nodes"].as_u64().unwrap_or(0), "node"),
|
||
plural(elements, "element"),
|
||
v["units"].as_str().unwrap_or("—")
|
||
)
|
||
}
|
||
"interaction.contact.find" => {
|
||
format!("{} among {}", plural(count("touching") as u64, "touching pair"), plural(v["parts"].as_u64().unwrap_or(0), "part"))
|
||
}
|
||
"results.reactions" => match serde_json::from_value::<Vec<f64>>(v["total"]["force"].clone()) {
|
||
Ok(f) => format!("total reaction force ({})", with_unit(components(&f, 0.0), Quantity::Force.unit(units).as_deref())),
|
||
Err(_) => scalars(v),
|
||
},
|
||
_ => scalars(v),
|
||
};
|
||
if line.is_empty() { "done".into() } else { one_line(&line) }
|
||
}
|
||
|
||
/// The status line after `command` changed the model or the display, written for the person
|
||
/// rather than from the command's label: "Showing Load deformed ×2", "Mode 2: 1637 Hz", "Added
|
||
/// load Push". Commands without a sentence of their own say "<label> done".
|
||
pub fn done(command: &str, label: &str, params: &Value, v: &Value) -> String {
|
||
let view = v.get("view").filter(|x| x.is_object());
|
||
let step = view.and_then(|x| x["step"].as_str()).unwrap_or("the step");
|
||
let line = match command {
|
||
"results.deformed" if view.is_some_and(|x| x["deformed"] == false) => Some(format!("Showing {step} undeformed")),
|
||
"results.deformed" => {
|
||
let scale = view.and_then(|x| x["scale"].as_f64()).map(|f| format!(" ×{}", crate::describe::quantity(f))).unwrap_or_default();
|
||
Some(format!("Showing {step} deformed{scale}"))
|
||
}
|
||
"results.contour" => Some(match v.pointer("/contour/label").and_then(Value::as_str) {
|
||
Some(field) => format!("Showing {field} on {step}"),
|
||
None => format!("Contours off on {step}"),
|
||
}),
|
||
"results.modes" => {
|
||
let mode = view.and_then(|x| x["mode"].as_u64()).unwrap_or(1);
|
||
let hz = v
|
||
.get("modes")
|
||
.and_then(Value::as_array)
|
||
.and_then(|m| m.iter().find(|m| m["mode"].as_u64() == Some(mode)))
|
||
.and_then(|m| m["frequency"].as_f64());
|
||
Some(match hz {
|
||
Some(hz) => format!("Mode {mode}: {} Hz", crate::describe::quantity(hz)),
|
||
None => format!("Showing mode {mode} of {step}"),
|
||
})
|
||
}
|
||
"results.animate" => Some(if view.is_some_and(|x| x["animate"] == true) { "Animating" } else { "Animation stopped" }.to_string()),
|
||
"results.frame" => Some(match view.and_then(|x| x["frame"].as_u64()) {
|
||
Some(f) => format!("Showing {step} frame {f}"),
|
||
None => format!("Showing {step}, last frame"),
|
||
}),
|
||
"results.clear" => Some("Back to the model".to_string()),
|
||
"file.sample" => {
|
||
let label = v.get("sample").and_then(Value::as_str).and_then(fieldcraft_engine::sample::find).map(|s| s.label);
|
||
label.map(|l| format!("Opened the {l} sample"))
|
||
}
|
||
"solve.run" => v.get("steps").and_then(Value::as_array).map(|s| format!("Solved {}", plural(s.len() as u64, "step"))),
|
||
c if c.starts_with("solve.") => Some(format!("Solved {step}", step = params["step"].as_str().unwrap_or("the step"))),
|
||
_ => None,
|
||
};
|
||
if let Some(line) = line {
|
||
return line;
|
||
}
|
||
// A definition added, changed or deleted, by its kind and name.
|
||
let name = params.get("name").and_then(Value::as_str);
|
||
let def = Def::ALL
|
||
.into_iter()
|
||
.find_map(|kind| ["create", "edit", "delete"].into_iter().find(|verb| kind.command(verb) == command).map(|verb| (kind, verb)));
|
||
match (def, name) {
|
||
(Some((kind, verb)), Some(name)) => {
|
||
let did = match verb {
|
||
"create" => "Added",
|
||
"edit" => "Changed",
|
||
_ => "Deleted",
|
||
};
|
||
format!("{did} {} {name}", kind.label().to_lowercase())
|
||
}
|
||
_ => format!("{label} done"),
|
||
}
|
||
}
|
||
|
||
/// Whether the reply says more than its summary line, so it is worth the panel.
|
||
pub fn is_rich(v: &Value) -> bool {
|
||
match v {
|
||
Value::Object(m) => m.iter().any(|(k, x)| k != "summary" && !x.is_null()),
|
||
Value::Array(a) => !a.is_empty(),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
fn plural(n: u64, word: &str) -> String {
|
||
if n == 1 { format!("1 {word}") } else { format!("{n} {word}s") }
|
||
}
|
||
|
||
/// A scalar as short text.
|
||
fn scalar(v: &Value) -> Option<String> {
|
||
match v {
|
||
Value::Bool(b) => Some(if *b { "yes" } else { "no" }.into()),
|
||
Value::Number(n) => Some(
|
||
n.as_u64().map(|u| u.to_string()).or_else(|| n.as_i64().map(|i| i.to_string())).unwrap_or_else(|| number(n.as_f64().unwrap_or(f64::NAN))),
|
||
),
|
||
Value::String(s) => Some(s.clone()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The top-level scalars as `key value` pairs (lists as their length).
|
||
fn scalars(v: &Value) -> String {
|
||
match v {
|
||
Value::Object(m) => {
|
||
let parts: Vec<String> = m
|
||
.iter()
|
||
.filter_map(|(k, x)| match x {
|
||
Value::Array(a) => Some(format!("{} {}", k.replace('_', " "), a.len())),
|
||
_ => scalar(x).map(|s| format!("{} {s}", k.replace('_', " "))),
|
||
})
|
||
.collect();
|
||
parts.join(", ")
|
||
}
|
||
Value::Array(a) => plural(a.len() as u64, "item"),
|
||
other => scalar(other).unwrap_or_default(),
|
||
}
|
||
}
|
||
|
||
/// `s` on one line of at most [`MAX_LINE`] characters.
|
||
fn one_line(s: &str) -> String {
|
||
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
if s.chars().count() > MAX_LINE { format!("{}…", s.chars().take(MAX_LINE).collect::<String>()) } else { s }
|
||
}
|
||
|
||
/// What a click in the panel asks for, done after drawing.
|
||
enum Act {
|
||
Close,
|
||
Select(Def, String),
|
||
Start(&'static str),
|
||
Tab(Tab),
|
||
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.
|
||
pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
|
||
let Some(reply) = app.reply.clone() else { return };
|
||
let mut acts = Vec::new();
|
||
heading(ui, t, Level::Title, |ui| {
|
||
ui.label(RichText::new(reply.label.to_uppercase()).font(label_font(ui.ctx(), 12.0)));
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||
if ui.small_button("×").on_hover_text("Close").clicked() {
|
||
acts.push(Act::Close);
|
||
}
|
||
if ui.small_button("Run again").on_hover_text(reply.command.as_str()).clicked() {
|
||
acts.push(Act::Rerun);
|
||
}
|
||
});
|
||
});
|
||
ui.add_space(4.0);
|
||
ui.add(egui::Label::new(RichText::new(summary_in(&reply.command, &reply.value, app.session.model.units())).color(t.text_dim)).wrap());
|
||
ui.add_space(4.0);
|
||
let units = app.session.model.units();
|
||
match reply.command.as_str() {
|
||
"model.check" => diagnostics(app, ui, t, &reply.value, &mut acts),
|
||
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 {
|
||
match a {
|
||
Act::Close => app.reply = None,
|
||
Act::Select(kind, name) => app.selected = Some((kind, name)),
|
||
Act::Start(id) => {
|
||
let _ = app.start(id);
|
||
}
|
||
Act::Tab(tab) => app.ui.select_tab(tab),
|
||
Act::Rerun => {
|
||
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),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The definition a diagnostic's subject names (``section `web` ``), when the model has it.
|
||
fn subject_definition(app: &ModelApp, subject: &str) -> Option<(Def, String)> {
|
||
let (kind, rest) = subject.split_once(" `")?;
|
||
let name = rest.strip_suffix('`')?;
|
||
let kind: Def = serde_json::from_value(Value::String(kind.replace(' ', "_"))).ok()?;
|
||
browser::definition(&app.session.model, kind, name).map(|_| (kind, name.to_string()))
|
||
}
|
||
|
||
/// Where a whole-model diagnostic is fixed: a button's text, tooltip and what it does.
|
||
fn model_fix(message: &str) -> Option<(&'static str, Act)> {
|
||
if message.contains("mesh has no elements") {
|
||
Some(("Mesh…", Act::Tab(Tab::Mesh)))
|
||
} else if message.contains("no unit system") {
|
||
Some(("Units…", Act::Start("model.units")))
|
||
} else if message.contains("no analysis step") {
|
||
Some(("Step…", Act::Start("model.step.create")))
|
||
} else if message.contains("have no section") {
|
||
Some(("Section…", Act::Start("model.section.create")))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Check Model: each diagnostic with its severity, the item it is about and its message, and a
|
||
/// button to where it is fixed.
|
||
fn diagnostics(app: &ModelApp, ui: &mut egui::Ui, t: &Tokens, v: &Value, acts: &mut Vec<Act>) {
|
||
let list = v["diagnostics"].as_array().map(Vec::as_slice).unwrap_or_default();
|
||
if list.is_empty() {
|
||
ui.label(RichText::new("✔ The model is ready to solve.").color(t.ok));
|
||
return;
|
||
}
|
||
for (i, d) in list.iter().enumerate() {
|
||
let error = d["severity"] == "error";
|
||
let subject = d["subject"].as_str().unwrap_or_default();
|
||
let message = d["message"].as_str().unwrap_or_default();
|
||
ui.horizontal_top(|ui| {
|
||
let (mark, colour, tip) = if error { ("⛔", t.error, "Error: blocks solving") } else { ("⚠", t.warm, "Warning") };
|
||
ui.label(RichText::new(mark).color(colour)).on_hover_text(tip);
|
||
ui.vertical(|ui| {
|
||
ui.set_width(ui.available_width() - 70.0);
|
||
ui.label(RichText::new(subject).strong());
|
||
ui.add(egui::Label::new(message).wrap());
|
||
});
|
||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
|
||
if let Some((kind, name)) = subject_definition(app, subject) {
|
||
if ui.small_button("Show").on_hover_text(format!("Select {subject} to see and edit it")).clicked() {
|
||
acts.push(Act::Select(kind, name));
|
||
}
|
||
} else if subject == "model"
|
||
&& let Some((text, act)) = model_fix(message)
|
||
&& ui.small_button(text).clicked()
|
||
{
|
||
acts.push(act);
|
||
}
|
||
});
|
||
});
|
||
if i + 1 < list.len() {
|
||
ui.separator();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Faces or edges listed under a body at most.
|
||
const MAX_ENTITIES: usize = 500;
|
||
|
||
/// Geometry › Bodies: one entry per body, its name first, then volume, area, face and edge counts
|
||
/// 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 };
|
||
for (i, b) in v.as_array().into_iter().flatten().enumerate() {
|
||
let name = b["name"].as_str().unwrap_or_default();
|
||
let faces = b["faces"].as_array().map(Vec::as_slice).unwrap_or_default();
|
||
let edges = b["edges"].as_array().map(Vec::as_slice).unwrap_or_default();
|
||
let corner = |k: usize| -> Option<[f64; 3]> { serde_json::from_value(b["bbox"][k].clone()).ok() };
|
||
let size =
|
||
corner(0).zip(corner(1)).map(|(lo, hi)| format!("{} × {} × {} mm", q(sig(hi[0] - lo[0])), q(sig(hi[1] - lo[1])), q(sig(hi[2] - lo[2]))));
|
||
let id = egui::Id::new(("fc_reply_body", i));
|
||
if ui.link(RichText::new(name).strong()).on_hover_text("Pick this body in the viewport").clicked() {
|
||
acts.push(Act::Pick(pick(name, fieldcraft_model::Entity::Body)));
|
||
}
|
||
egui::Grid::new(id.with("grid")).num_columns(2).spacing(vec2(10.0, 2.0)).show(ui, |ui| {
|
||
let mut row = |k: &str, text: String| {
|
||
ui.label(RichText::new(k).color(t.text_dim));
|
||
ui.label(text);
|
||
ui.end_row();
|
||
};
|
||
if let Some(v) = b["volume"].as_f64() {
|
||
row("Volume", format!("{} mm³", q(sig(v))));
|
||
}
|
||
if let Some(a) = b["area"].as_f64() {
|
||
row("Area", format!("{} mm²", q(sig(a))));
|
||
}
|
||
row("Faces, edges", format!("{}, {}", faces.len(), edges.len()));
|
||
if let Some(size) = size {
|
||
row("Size", size);
|
||
}
|
||
});
|
||
body_list(ui, t, id.with("faces"), "Faces", faces, &|f| (f["name"].as_str().unwrap_or_default().to_string(), None), name, acts);
|
||
body_list(
|
||
ui,
|
||
t,
|
||
id.with("edges"),
|
||
"Edges",
|
||
edges,
|
||
&|e| (e["name"].as_str().unwrap_or_default().to_string(), e["length"].as_f64()),
|
||
name,
|
||
acts,
|
||
);
|
||
ui.separator();
|
||
}
|
||
}
|
||
|
||
/// A body's faces or edges, folded: each name picks it in the viewport (edges with their length).
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn body_list(
|
||
ui: &mut egui::Ui,
|
||
t: &Tokens,
|
||
id: egui::Id,
|
||
title: &str,
|
||
items: &[Value],
|
||
read: &dyn Fn(&Value) -> (String, Option<f64>),
|
||
body: &str,
|
||
acts: &mut Vec<Act>,
|
||
) {
|
||
if items.is_empty() {
|
||
return;
|
||
}
|
||
egui::CollapsingHeader::new(RichText::new(format!("{title} ({})", items.len())).size(12.0)).id_salt(id).show(ui, |ui| {
|
||
for item in items.iter().take(MAX_ENTITIES) {
|
||
let (name, length) = read(item);
|
||
ui.horizontal(|ui| {
|
||
if ui.link(&name).on_hover_text("Pick it in the viewport").clicked() {
|
||
let entity =
|
||
if title == "Edges" { fieldcraft_model::Entity::Edge(name.clone()) } else { fieldcraft_model::Entity::Face(name.clone()) };
|
||
acts.push(Act::Pick(fieldcraft_model::GeometryRef { body: body.to_string(), entity }));
|
||
}
|
||
if let Some(l) = length {
|
||
ui.label(RichText::new(format!("{} mm", crate::describe::quantity(sig(l)))).color(t.text_dim));
|
||
}
|
||
});
|
||
}
|
||
more_than(ui, t, items.len(), MAX_ENTITIES);
|
||
});
|
||
}
|
||
|
||
/// `v` to 3 significant figures.
|
||
fn sig(v: f64) -> f64 {
|
||
if v == 0.0 || !v.is_finite() {
|
||
return v;
|
||
}
|
||
let scale = 10f64.powi(2 - v.abs().log10().floor() as i32);
|
||
(v * scale).round() / scale
|
||
}
|
||
|
||
fn more_than(ui: &mut egui::Ui, t: &Tokens, n: usize, max: usize) {
|
||
if n > max {
|
||
ui.label(RichText::new(format!("… {} more", n - max)).small().color(t.text_dim));
|
||
}
|
||
}
|
||
|
||
/// The Step Table: each boundary condition and load against the steps, ticked where active.
|
||
fn activity(ui: &mut egui::Ui, t: &Tokens, v: &Value) {
|
||
let steps: Vec<&str> = v["steps"].as_array().into_iter().flatten().filter_map(|s| s["name"].as_str()).collect();
|
||
if steps.is_empty() {
|
||
ui.label(RichText::new("No analysis steps yet: Steps › Step creates one.").color(t.text_dim));
|
||
return;
|
||
}
|
||
egui::ScrollArea::horizontal().id_salt("fc_reply_activity").show(ui, |ui| {
|
||
egui::Grid::new("fc_reply_activity_grid").striped(true).spacing(vec2(10.0, 3.0)).show(ui, |ui| {
|
||
ui.label("");
|
||
for (s, step) in steps.iter().zip(v["steps"].as_array().into_iter().flatten()) {
|
||
ui.label(RichText::new(*s).strong()).on_hover_text(step["procedure"].as_str().unwrap_or_default());
|
||
}
|
||
ui.end_row();
|
||
for (key, kind) in [("boundary_conditions", "BC"), ("loads", "Load")] {
|
||
for (name, active) in v[key].as_object().into_iter().flatten() {
|
||
ui.label(name.as_str()).on_hover_text(kind);
|
||
for s in &steps {
|
||
let on = active.as_array().is_some_and(|a| a.iter().any(|x| x == *s));
|
||
ui.label(if on { RichText::new("✔").color(t.ok) } else { RichText::new("—").color(t.text_dim) });
|
||
}
|
||
ui.end_row();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/// A solved step's peaks in one line: `max |U| 0.05063 mm, max Mises 1,368 MPa, 115,665 DOFs`.
|
||
fn solve_line(v: &Value, units: UnitSystem) -> String {
|
||
let mut parts = Vec::new();
|
||
let peak = |key: &str| v.get(key).and_then(|p| p.get("value")).and_then(Value::as_f64);
|
||
for (key, what, q) in [
|
||
("max_displacement", "max |U|", Quantity::Length),
|
||
("max_von_mises", "max Mises", Quantity::Stress),
|
||
("max_temperature", "max T", Quantity::Temperature),
|
||
] {
|
||
if let Some(x) = peak(key) {
|
||
parts.push(format!("{what} {}", with_unit(number(x), q.unit(units).as_deref())));
|
||
}
|
||
}
|
||
if let Some(m) = v["modes"].as_array().and_then(|m| m.first()) {
|
||
if let Some(f) = m["frequency"].as_f64() {
|
||
parts.push(format!("mode 1 at {} Hz", number(f)));
|
||
} else if let Some(l) = m["load_factor"].as_f64() {
|
||
parts.push(format!("first load factor {}", number(l)));
|
||
}
|
||
}
|
||
if let Some(n) = v["dofs"].as_u64() {
|
||
parts.push(format!("{} DOFs", count(n)));
|
||
}
|
||
parts.join(", ")
|
||
}
|
||
|
||
/// What a solve summary's row shows: a number of a quantity, a node's peak, a vector, a count,
|
||
/// or text.
|
||
enum Row {
|
||
Number(Quantity),
|
||
Peak(Quantity),
|
||
Vector(Quantity),
|
||
Count,
|
||
Text,
|
||
}
|
||
|
||
/// A solve's summary: the step's peaks and totals with their units first, round-off as 0, rows
|
||
/// that do not apply left out (a steady step's end time), counts with thousands separators;
|
||
/// lists (modes, contacts) as tables, and the solver's internals folded under Details.
|
||
fn solve_summary(ui: &mut egui::Ui, t: &Tokens, v: &Value, units: UnitSystem) {
|
||
let Some(m) = v.as_object() else { return value(ui, t, egui::Id::new("fc_reply_solve"), v, 0) };
|
||
let steady = m.get("frames").and_then(Value::as_u64).is_some_and(|n| n <= 1);
|
||
let rows: [(&str, &str, Row); 18] = [
|
||
("step", "Step", Row::Text),
|
||
("max_displacement", "Max |U|", Row::Peak(Quantity::Length)),
|
||
("max_von_mises", "Max Mises", Row::Peak(Quantity::Stress)),
|
||
("max_temperature", "Max T", Row::Peak(Quantity::Temperature)),
|
||
("min_temperature", "Min T", Row::Peak(Quantity::Temperature)),
|
||
("max_plastic_strain", "Max plastic strain", Row::Peak(Quantity::None)),
|
||
("applied_total", "Applied force", Row::Vector(Quantity::Force)),
|
||
("reaction_total", "Reaction force", Row::Vector(Quantity::Force)),
|
||
("heat_supplied", "Heat flow through", Row::Number(Quantity::HeatFlow)),
|
||
("total_mass", "Total mass", Row::Number(Quantity::Mass)),
|
||
("load_factor", "Load factor", Row::Number(Quantity::None)),
|
||
("time", "Time reached", Row::Number(Quantity::Time)),
|
||
("end_time", "End time", Row::Number(Quantity::Time)),
|
||
("completed", "Completed", Row::Text),
|
||
("energy_balance", "Energy balance error", Row::Number(Quantity::None)),
|
||
("dofs", "Degrees of freedom", Row::Count),
|
||
("elements", "Elements", Row::Count),
|
||
("frames", "Frames", Row::Count),
|
||
];
|
||
let mut shown: Vec<&str> = Vec::new();
|
||
egui::Grid::new("fc_reply_solve").num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| {
|
||
for (key, label, row) in &rows {
|
||
let Some(x) = m.get(*key).filter(|x| !x.is_null()) else { continue };
|
||
shown.push(key);
|
||
// A steady step has no time, and one frame.
|
||
if steady && matches!(*key, "end_time" | "frames") {
|
||
continue;
|
||
}
|
||
let text = match row {
|
||
Row::Number(q) => x.as_f64().map(|f| with_unit(number(f), q.unit(units).as_deref())),
|
||
Row::Peak(q) => x["value"].as_f64().map(|f| {
|
||
let at = x["node"].as_u64().map_or_else(String::new, |n| format!(" at node {n}"));
|
||
format!("{}{at}", with_unit(number(f), q.unit(units).as_deref()))
|
||
}),
|
||
Row::Vector(q) => {
|
||
serde_json::from_value::<Vec<f64>>(x.clone()).ok().map(|c| with_unit(components(&c, 0.0), q.unit(units).as_deref()))
|
||
}
|
||
Row::Count => x.as_u64().map(count),
|
||
Row::Text => inline(x),
|
||
};
|
||
if let Some(text) = text {
|
||
ui.label(RichText::new(*label).color(t.text_dim));
|
||
ui.add(egui::Label::new(text).wrap());
|
||
ui.end_row();
|
||
}
|
||
}
|
||
});
|
||
// Heat removed is the supplied flow with the other sign: one row says it.
|
||
shown.push("heat_removed");
|
||
if let Some(modes) = m.get("modes").and_then(Value::as_array).filter(|a| !a.is_empty()) {
|
||
shown.push("modes");
|
||
egui::CollapsingHeader::new(RichText::new(format!("Modes ({})", modes.len())).size(12.0))
|
||
.id_salt("fc_reply_solve_modes")
|
||
.default_open(true)
|
||
.show(ui, |ui| {
|
||
egui::Grid::new("fc_reply_solve_modes_grid").striped(true).spacing(vec2(10.0, 2.0)).show(ui, |ui| {
|
||
let buckling = modes.iter().any(|x| x.get("load_factor").is_some());
|
||
ui.label(RichText::new("Mode").small().color(t.text_dim));
|
||
ui.label(RichText::new(if buckling { "Load factor" } else { "Frequency [Hz]" }).small().color(t.text_dim));
|
||
ui.end_row();
|
||
for x in modes.iter().take(MAX_ROWS) {
|
||
ui.label(x["mode"].as_u64().map_or_else(String::new, |n| n.to_string()));
|
||
ui.label(x.get(if buckling { "load_factor" } else { "frequency" }).and_then(Value::as_f64).map_or("—".into(), number));
|
||
ui.end_row();
|
||
}
|
||
});
|
||
more(ui, t, modes.len());
|
||
});
|
||
}
|
||
if let Some(w) = m.get("warnings").and_then(Value::as_array) {
|
||
shown.push("warnings");
|
||
for x in w {
|
||
ui.add(egui::Label::new(RichText::new(format!("⚠ {}", inline(x).unwrap_or_else(|| crate::context::value_text(x)))).color(t.warm)).wrap());
|
||
}
|
||
}
|
||
// The rest: the solver's internals (equations, factor entries, Lanczos vectors…) and lists.
|
||
let rest: serde_json::Map<String, Value> = m
|
||
.iter()
|
||
.filter(|(k, x)| !shown.contains(&k.as_str()) && !x.is_null() && x.as_array().is_none_or(|a| !a.is_empty()))
|
||
.map(|(k, x)| (k.clone(), x.clone()))
|
||
.collect();
|
||
if !rest.is_empty() {
|
||
egui::CollapsingHeader::new(RichText::new("Details").size(12.0)).id_salt("fc_reply_solve_details").default_open(false).show(ui, |ui| {
|
||
let rest = Value::Object(rest.into_iter().map(|(k, x)| (k, x.as_u64().map_or(x.clone(), |n| Value::String(count(n))))).collect());
|
||
value(ui, t, egui::Id::new("fc_reply_solve_details"), &rest, 1);
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Any reply: objects as labelled rows, lists of objects as tables, nested parts folded.
|
||
fn value(ui: &mut egui::Ui, t: &Tokens, id: egui::Id, v: &Value, depth: usize) {
|
||
match v {
|
||
Value::Object(m) => {
|
||
egui::Grid::new(id).num_columns(2).spacing(vec2(10.0, 3.0)).show(ui, |ui| {
|
||
for (k, x) in m.iter().filter(|(k, x)| k.as_str() != "summary" && inline(x).is_some()) {
|
||
ui.label(RichText::new(k.replace('_', " ")).color(t.text_dim));
|
||
ui.add(egui::Label::new(inline(x).unwrap_or_default()).wrap());
|
||
ui.end_row();
|
||
}
|
||
});
|
||
for (k, x) in m.iter().filter(|(_, x)| inline(x).is_none()) {
|
||
let title = match x {
|
||
Value::Array(a) => format!("{} ({})", k.replace('_', " "), a.len()),
|
||
_ => k.replace('_', " "),
|
||
};
|
||
egui::CollapsingHeader::new(RichText::new(title).size(12.0)).id_salt(id.with(k)).default_open(depth == 0).show(ui, |ui| {
|
||
value(ui, t, id.with(k), x, depth + 1);
|
||
});
|
||
}
|
||
}
|
||
Value::Array(a) if depth < MAX_DEPTH && a.iter().all(Value::is_object) => table(ui, t, id, a),
|
||
Value::Array(a) if depth < MAX_DEPTH => {
|
||
for (i, x) in a.iter().take(MAX_ROWS).enumerate() {
|
||
value(ui, t, id.with(i), x, depth + 1);
|
||
}
|
||
more(ui, t, a.len());
|
||
}
|
||
other => {
|
||
ui.add(egui::Label::new(inline(other).unwrap_or_else(|| crate::context::value_text(other))).wrap());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A value short enough for one cell: scalars, and lists of up to four scalars.
|
||
fn inline(v: &Value) -> Option<String> {
|
||
match v {
|
||
Value::Null => Some("—".into()),
|
||
Value::Array(a) if a.len() <= 4 && a.iter().all(|x| !x.is_object() && !x.is_array()) => {
|
||
Some(a.iter().filter_map(scalar).collect::<Vec<_>>().join(", "))
|
||
}
|
||
Value::Array(_) | Value::Object(_) => None,
|
||
other => scalar(other),
|
||
}
|
||
}
|
||
|
||
/// A list of objects as a table: the columns are their keys (first seen first).
|
||
fn table(ui: &mut egui::Ui, t: &Tokens, id: egui::Id, rows: &[Value]) {
|
||
let mut columns: Vec<&str> = Vec::new();
|
||
for r in rows.iter().take(MAX_ROWS) {
|
||
for k in r.as_object().into_iter().flat_map(Map::keys) {
|
||
if !columns.contains(&k.as_str()) && columns.len() < MAX_COLUMNS {
|
||
columns.push(k);
|
||
}
|
||
}
|
||
}
|
||
egui::ScrollArea::horizontal().id_salt(id.with("scroll")).show(ui, |ui| {
|
||
egui::Grid::new(id).striped(true).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
|
||
for c in &columns {
|
||
ui.label(RichText::new(c.replace('_', " ")).small().color(t.text_dim));
|
||
}
|
||
ui.end_row();
|
||
for r in rows.iter().take(MAX_ROWS) {
|
||
for c in &columns {
|
||
ui.add(
|
||
egui::Label::new(r.get(*c).map_or_else(String::new, |x| inline(x).unwrap_or_else(|| crate::context::value_text(x)))).extend(),
|
||
);
|
||
}
|
||
ui.end_row();
|
||
}
|
||
});
|
||
});
|
||
more(ui, t, rows.len());
|
||
}
|
||
|
||
fn more(ui: &mut egui::Ui, t: &Tokens, n: usize) {
|
||
if n > MAX_ROWS {
|
||
ui.label(RichText::new(format!("… {} more", n - MAX_ROWS)).small().color(t.text_dim));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use serde_json::json;
|
||
|
||
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": []});
|
||
assert_eq!(summary("model.check", &check), "2 errors, 1 warning");
|
||
assert_eq!(summary("model.check", &json!({"errors": 0, "warnings": 0})), "ready to solve, no issues");
|
||
let activity = json!({"boundary_conditions": {"Clamp": ["Load"]}, "loads": {}, "steps": [{"name": "Load"}]});
|
||
assert_eq!(summary("model.activity", &activity), "1 step, 1 boundary condition, 0 loads");
|
||
assert_eq!(summary("geometry.bodies", &json!([])), "no bodies (open a design first)");
|
||
assert_eq!(summary("geometry.bodies", &json!([{}, {}])), "2 bodies");
|
||
assert_eq!(summary("mesh.info", &json!({"dofs": 945, "modes": [1, 2], "x": {"y": 1}})), "dofs 945, modes 2");
|
||
let solved = json!({"dofs": 115665, "max_displacement": {"node": 17, "value": 0.0506333}, "max_von_mises": {"node": 3, "value": 1368.07}});
|
||
assert_eq!(summary_in("solve.static", &solved, UnitSystem::MmTonneS), "max |U| 0.05063 mm, max Mises 1,368 MPa, 115,665 DOFs");
|
||
assert_eq!(summary("any", &json!({"summary": "a\nb"})), "a b");
|
||
assert_eq!(summary("any", &json!({})), "done");
|
||
assert!(summary("any", &json!({"s": "x".repeat(500)})).chars().count() <= MAX_LINE + 1);
|
||
}
|
||
|
||
#[test]
|
||
fn model_level_diagnostics_lead_to_their_fix() {
|
||
let mut s = fieldcraft_engine::Session::default();
|
||
let check = s.execute("model.check", &json!({})).unwrap();
|
||
let messages: Vec<&str> = check["diagnostics"].as_array().unwrap().iter().filter_map(|d| d["message"].as_str()).collect();
|
||
assert!(messages.len() >= 3, "{check}");
|
||
for m in messages {
|
||
assert!(model_fix(m).is_some(), "no fix for `{m}`");
|
||
}
|
||
}
|
||
}
|