ui: results show units, clean numbers, extremes, reaction sums and a solve summary

One formatter (four significant digits, thousands separators, round-off as
0) and the model's units through every results readout:
- legend title with its unit, a mode shape normalised; Max/Min with units,
  clickable to show and probe the node; markers at both in the viewport
  (#321, #330)
- the deformation scale field shows the scale Deformed would use, never 0,
  in the shared format; true scale named (#325, #326)
- setup symbols off over results by default (Show › Loads & BCs), and then
  only the shown step's (#327)
- reaction sums in the results panel however they are run: per set and in
  all, force and moment with units, applied beside reacted (#328)
- the probe: U [mm], |U|, Mises, stresses and principals with units, the
  label kept inside the viewport, rows in the results panel (#329)
- hiding the mesh over a plot hides the element edges, not the contours
  (#317); a hidden body's contours go with it, the range follows what is
  shown (#365)
- the solve summary: peaks with units, n/a rows left out, counts grouped,
  internals under Details (#321, #319)
- the energies plot: axes with units, a cursor at the frame (click or drag
  to move it), hover to read (#331)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 09:36:54 -04:00
1 parent 91e8467e4f
commit 7a078b1c04
5 files changed
+675 -146

No files matched your search

+113 -31
View File
@@ -61,6 +61,7 @@ use std::sync::mpsc::{Receiver, Sender};
use fieldcraft_engine::catalogue::{self, Tab, Workspace};
use fieldcraft_engine::{EngineError, Session};
use fieldcraft_model::UnitSystem;
use fieldcraft_render::glyphs::{self, Definition, Drawn, Glyphs};
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView, Vec3};
use serde::{Deserialize, Serialize};
@@ -91,6 +92,11 @@ pub struct UiState {
pub show_loads: bool,
/// Reference point, coupling and rigid wall symbols.
pub show_interactions: bool,
/// The setup's symbols over a results plot (those of the step shown); off by default, so
/// they do not cover the contours.
pub result_symbols: bool,
/// Markers at a contour's largest and smallest value.
pub result_extremes: bool,
/// The setup symbols' size on screen, in points (held to
/// [`fieldcraft_render::glyphs::SIZE_RANGE`]).
pub symbol_size: f64,
@@ -123,6 +129,8 @@ impl Default for UiState {
show_mesh: true,
show_loads: true,
show_interactions: true,
result_symbols: false,
result_extremes: true,
symbol_size: glyphs::DEFAULT_SIZE,
perspective: false,
dark: true,
@@ -294,7 +302,8 @@ pub struct Viewport {
pub menu_target: Option<menu::Target>,
}
/// A probed node: its values as lines of text, for the step, mode and frame they were read from.
/// A probed node: its values as (what, value) rows and as lines of text, for the step, mode
/// and frame they were read from.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Probe {
pub node: u32,
@@ -302,6 +311,7 @@ pub struct Probe {
pub mode: usize,
/// The explicit field frame (`None`: the last).
pub frame: Option<usize>,
pub rows: Vec<(String, String)>,
pub lines: Vec<String>,
}
@@ -377,6 +387,8 @@ pub struct ModelApp {
/// The setup's symbols, a layer of their own drawn at the camera's scale, its key and what
/// it drew.
symbols: (Option<u64>, Scene, Drawn),
/// The symbols of the step a results plot shows, and the model revision and step they are of.
step_glyphs: (Option<(u64, String)>, Glyphs),
/// The theme last applied to the context.
styled: Option<bool>,
/// Input injected by the control channel, fed to egui one pointer event per frame.
@@ -490,6 +502,7 @@ impl ModelApp {
prepared: (None, results::Prepared::default()),
scene: (None, Scene::default(), Aabb3::EMPTY),
symbols: (None, Scene::default(), Drawn::default()),
step_glyphs: (None, Glyphs::default()),
styled: None,
synthetic: Vec::new(),
synthetic_mods: egui::Modifiers::default(),
@@ -587,6 +600,12 @@ impl ModelApp {
}
// The results display commands reply with what is shown, which the viewport shows.
let display = (id.starts_with("results.") && id != "results.reactions") || id == "solve.run";
// The reaction sums show in the results panel, however they were asked for.
if id == "results.reactions"
&& let Ok(v) = &r
{
self.reactions = Some(v.clone());
}
if r.is_ok() && (changed || display) {
self.follow_stage(id);
}
@@ -595,7 +614,7 @@ impl ModelApp {
// A reply reads as one line; the whole of a rich one shows in the context panel (the
// reaction sums have their own place in the results panel).
Ok(v) => {
let mut line = format!("{label}: {}", reply::summary(id, v));
let mut line = format!("{label}: {}", reply::summary_in(id, v, self.session.model.units()));
if reply::is_rich(v) && !(id == "results.reactions" && self.plot().is_some()) {
line.push_str(" (details in the panel)");
self.reply = Some(reply::Reply { command: id.to_string(), label: label.to_string(), params: params.clone(), value: v.clone() });
@@ -879,31 +898,15 @@ impl ModelApp {
return Err(e);
}
};
let at = reply.pointer("/nodes/0").cloned().unwrap_or(Value::Null);
let vector = |key: &str| at.get(key).and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok());
let triple = |v: [f64; 3]| format!("{}, {}, {}", results::number(v[0]), results::number(v[1]), results::number(v[2]));
let mut lines = vec![format!("Node {node}")];
if let Some(u) = vector("displacement") {
lines.push(format!("U {} |U| {}", triple(u), results::number(fieldcraft_engine::results::magnitude(u))));
}
if let Some(rf) = vector("reaction").filter(|r| r.iter().any(|c| *c != 0.0)) {
lines.push(format!("RF {}", triple(rf)));
}
if let Some(m) = at.get("von_mises").and_then(Value::as_f64) {
lines.push(format!("Von Mises {}", results::number(m)));
}
if let Some(v) = vector("velocity") {
lines.push(format!("V {} |V| {}", triple(v), results::number(fieldcraft_engine::results::magnitude(v))));
}
if let Some(shape) = vector("shape") {
lines.push(format!("Mode {} shape {}", pick.mode, triple(shape)));
}
let contour = self.plot().and_then(|p| p.contour.as_ref().map(|c| (c.field, c.at(p.result.nodes(), node))));
if let Some((field, v)) = contour.filter(|(f, _)| *f != fieldcraft_engine::results::Field::VonMises) {
lines.push(format!("{} {}", field.label(), results::number(v)));
// The probe shows in the results panel and beside the node, not as a reply.
if self.reply.as_ref().is_some_and(|r| r.command == "solve.probe") {
self.reply = None;
}
let plot = self.plot();
let rows = probe_rows(&reply, plot.as_deref(), self.session.model.units(), node, pick.mode);
let lines: Vec<String> = std::iter::once(format!("Node {node}")).chain(rows.iter().map(|(what, value)| format!("{what} {value}"))).collect();
self.set_status(lines.join(" "), false);
self.probe = Some(Probe { node, step: view.step, mode: pick.mode, frame: pick.frame, lines });
self.probe = Some(Probe { node, step: view.step, mode: pick.mode, frame: pick.frame, rows, lines });
Ok(())
}
@@ -941,7 +944,7 @@ impl ModelApp {
/// to appear is framed.
pub fn display(&mut self) -> &DisplayMesh {
if self.display.0 != Some(self.revision) {
let mut d = DisplayMesh::from_model(&self.session.model);
let mut d = DisplayMesh::from_model_hiding(&self.session.model, &self.session.hidden_elements());
let design = self.session.design.clone();
if !match (&design, &self.bodies.0) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
@@ -1120,15 +1123,29 @@ impl ModelApp {
let unit = self.cam.length_per_pixel(height).and_then(|l| glyphs::symbol_unit(l, self.ui.symbol_size)).and_then(glyphs::quantise_unit);
let mut h = std::hash::DefaultHasher::new();
let plot = self.plot.1.as_ref().map(|p| Arc::as_ptr(p) as usize);
(self.revision, self.ui.show_loads, self.ui.show_interactions, self.ui.dark, plot, unit.map(|u| u.0), self.highlighted()).hash(&mut h);
let show = self.ui.show();
(self.revision, show.loads, show.interactions, self.ui.result_symbols, self.ui.dark, plot, unit.map(|u| u.0), self.highlighted())
.hash(&mut h);
let key = h.finish();
if self.symbols.0 != Some(key) {
// Over a results plot: only when asked for, and only the shown step's.
let step = self.plot.1.as_ref().map(|p| p.view.step.clone());
if let Some(step) = step.as_ref().filter(|_| self.ui.result_symbols) {
let for_plot = Some((self.revision, step.clone()));
if self.step_glyphs.0 != for_plot {
self.step_glyphs = (for_plot, self.session.step_glyphs(step));
}
}
let glyphs = if step.is_some() { &self.step_glyphs.1 } else { &self.glyphs };
let t = theme::Tokens::get(self.ui.dark);
let drawn = self.plot.1.as_ref().map_or(&self.display.1, |p| &p.display);
let mut s = Scene::default();
let unit = unit.map_or(f64::NAN, |u| u.1);
let d =
scene::symbols(&mut s, &self.glyphs, |n| drawn.node_position(n), self.ui.show(), self.plot.1.is_some(), unit, self.highlighted(), &t);
let d = if step.is_some() && !self.ui.result_symbols {
Drawn::default()
} else {
scene::symbols(&mut s, glyphs, |n| drawn.node_position(n), show, step.is_some(), unit, self.highlighted(), &t)
};
self.symbols = (Some(key), s, d);
}
(key, &self.symbols.1)
@@ -1516,6 +1533,9 @@ impl ModelApp {
}
}
self.framed_document = Some(self.session.opened());
// The probe and the reaction sums were of the old document's results.
self.probe = None;
self.reactions = None;
self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::Home);
}
}
@@ -1861,6 +1881,64 @@ pub fn command_label(id: &str) -> &str {
fieldcraft_engine::find_command(id).map_or(id, |c| c.label)
}
/// What a probe of `node` read (`solve.probe`'s reply), as (what, value) rows with units from
/// `units`: displacement, reaction, stresses, temperature, velocity or a mode's shape, and the
/// contour shown when it is none of those. Round-off beside the result's largest value is 0.
fn probe_rows(reply: &Value, plot: Option<&results::Plot>, units: UnitSystem, node: u32, mode: usize) -> Vec<(String, String)> {
use fieldcraft_engine::results::{Field, Pick, Quantity, displacements, magnitude, with_unit as titled};
let at = reply.pointer("/nodes/0").cloned().unwrap_or(Value::Null);
let vector = |key: &str| at.get(key).and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok());
let unit = |q: Quantity| q.unit(units);
// The largest displacement of the result shown: the scale round-off is read against.
let largest = plot
.and_then(|p| displacements(&p.result, Pick { mode, frame: p.pick.frame }).ok())
.map_or(0.0, |u| u.iter().map(|v| magnitude(*v)).filter(|m| m.is_finite()).fold(0.0, f64::max));
let mut rows = Vec::new();
let vector_row = |rows: &mut Vec<(String, String)>, name: &str, v: [f64; 3], q: Quantity, scale: f64| {
let u = unit(q);
rows.push((titled(name, u.as_deref()), results::components(&v, scale)));
rows.push((format!("|{name}|"), results::with_unit(results::number_in(magnitude(v), scale), u.as_deref())));
};
if let Some(u) = vector("displacement") {
vector_row(&mut rows, "U", u, Quantity::Length, largest);
}
if let Some(rf) = vector("reaction").filter(|r| r.iter().any(|c| *c != 0.0)) {
rows.push((titled("RF", unit(Quantity::Force).as_deref()), results::components(&rf, 0.0)));
}
let stress = at.get("stress").and_then(|v| serde_json::from_value::<[f64; 6]>(v.clone()).ok());
let mises = at.get("von_mises").and_then(Value::as_f64);
let stress_unit = unit(Quantity::Stress);
if let Some(m) = mises {
rows.push(("Mises".into(), results::with_unit(results::number(m), stress_unit.as_deref())));
}
if let Some(t) = stress {
let scale = mises.unwrap_or(0.0);
rows.push((titled("S xx, yy, zz", stress_unit.as_deref()), results::components(&t[..3], scale)));
rows.push((titled("S xy, xz, yz", stress_unit.as_deref()), results::components(&t[3..], scale)));
let p = fieldcraft_engine::results::principal(&t);
rows.push((titled("Principal", stress_unit.as_deref()), results::components(&p, scale)));
}
if let Some(t) = at.get("temperature").and_then(Value::as_f64) {
rows.push(("T".into(), results::with_unit(results::number(t), unit(Quantity::Temperature).as_deref())));
}
if let Some(q) = vector("heat_flux") {
rows.push((titled("q", unit(Quantity::HeatFlux).as_deref()), results::components(&q, 0.0)));
}
if let Some(v) = vector("velocity") {
vector_row(&mut rows, "V", v, Quantity::Velocity, 0.0);
}
if let Some(shape) = vector("shape") {
rows.push((format!("Mode {mode} shape (normalised)"), results::components(&shape, largest)));
}
let contour = plot.and_then(|p| p.contour.as_ref().map(|c| (c.field, c.at(p.result.nodes(), node), &p.result)));
if let Some((field, v, result)) =
contour.filter(|(f, ..)| !matches!(f, Field::VonMises | Field::Temperature) && !f.label().starts_with("Displacement"))
{
rows.push((field.label().to_string(), results::with_unit(results::number(v), field.unit(result, units).as_deref())));
}
rows
}
#[cfg(test)]
mod tests {
use fieldcraft_render::Vec3;
@@ -2196,9 +2274,11 @@ mod tests {
assert!(count(&mut app, t.pick_selected) > 0);
assert!(count(&mut app, t.glyph_load) > 0);
app.selected = None;
// Over a results plot they are muted.
// Over a results plot they are off until asked for (#327), then muted.
app.run("solve.static", json!({})).unwrap();
app.run("results.deformed", json!({"step": "Load"})).unwrap();
assert!(app.symbols().1.lines.is_empty());
app.ui.result_symbols = true;
assert_eq!(count(&mut app, t.glyph_constraint), 0);
let muted = t.glyph_colors().over_results(theme::rgb(t.viewport_bottom));
assert!(app.symbols().1.lines.iter().any(|x| x.color == muted.constraint));
@@ -2486,7 +2566,9 @@ mod tests {
app.probe_node(tip).unwrap();
let probe = app.probe.clone().unwrap();
assert_eq!((probe.node, probe.step.as_str()), (tip, "Load"));
assert!(probe.lines.iter().any(|l| l.starts_with("U ")) && probe.lines.iter().any(|l| l.starts_with("Von Mises")), "{probe:?}");
assert!(probe.lines.iter().any(|l| l.starts_with("U ")) && probe.lines.iter().any(|l| l.starts_with("Mises")), "{probe:?}");
// Units, and round-off shown as 0 (#329).
assert!(probe.lines.iter().any(|l| l.starts_with("U [mm] ")) && probe.lines.iter().any(|l| l.ends_with(" MPa")), "{probe:?}");
// A root node carries a reaction.
app.probe_node(node_at(&app, [0.0, 0.0, 0.0])).unwrap();
assert!(app.probe.as_ref().unwrap().lines.iter().any(|l| l.starts_with("RF ")));
+159 -8
View File
@@ -9,9 +9,11 @@ use serde_json::{Map, Value};
use crate::browser::{self, Def};
use crate::frame::{Level, heading};
use crate::results::number;
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)]
@@ -22,6 +24,9 @@ pub struct Reply {
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"];
@@ -37,9 +42,17 @@ 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" => {
@@ -69,10 +82,10 @@ pub fn summary(command: &str, v: &Value) -> String {
"interaction.contact.find" => {
format!("{} among {}", plural(count("touching") as u64, "touching pair"), plural(v["parts"].as_u64().unwrap_or(0), "part"))
}
"results.reactions" => {
let f = |k: usize| v.pointer(&format!("/total/force/{k}")).and_then(Value::as_f64).map_or("—".into(), number);
format!("total reaction force ({}, {}, {})", f(0), f(1), f(2))
}
"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) }
@@ -217,10 +230,12 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
});
});
ui.add_space(4.0);
ui.add(egui::Label::new(RichText::new(summary(&reply.command, &reply.value)).color(t.text_dim)).wrap());
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),
_ => value(ui, t, egui::Id::new("fc_reply"), &reply.value, 0),
@@ -432,6 +447,138 @@ fn activity(ui: &mut egui::Ui, t: &Tokens, v: &Value) {
});
}
/// 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 {
@@ -496,7 +643,9 @@ fn table(ui: &mut egui::Ui, t: &Tokens, id: egui::Id, rows: &[Value]) {
ui.end_row();
for r in rows.iter().take(MAX_ROWS) {
for c in &columns {
ui.label(r.get(*c).map_or_else(String::new, |x| inline(x).unwrap_or_else(|| crate::context::value_text(x))));
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();
}
@@ -526,7 +675,9 @@ mod tests {
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("solve.static", &json!({"dofs": 945, "modes": [1, 2], "x": {"y": 1}})), "dofs 945, modes 2");
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);
+109 -28
View File
@@ -8,7 +8,7 @@
use std::sync::Arc;
use fieldcraft_engine::results::{Field, Kind, Pick, ResultsView, displacements, field_values, magnitude, range};
use fieldcraft_engine::results::{Field, Kind, Pick, ResultsView, auto_scale, displacements, field_values, range};
use fieldcraft_engine::{Session, StepResult};
use fieldcraft_render::contour::{Bands, band_color, split_triangle};
use fieldcraft_render::{DisplayMesh, Scene, SceneTri, Vec3};
@@ -19,8 +19,10 @@ use crate::theme::{Tokens, rgb};
/// Animation frames per cycle, and the cycle's length in seconds.
pub const FRAMES: u64 = 24;
pub const PERIOD: f64 = 2.0;
/// The automatic scale draws the largest displacement this fraction of the model's size.
const AUTO_FRACTION: f64 = 0.1;
/// Significant digits of the numbers the results show.
const DIGITS: i32 = 4;
/// A value below this fraction of the largest it is read with is round-off, shown as 0.
const ROUND_OFF: f64 = 1e-9;
/// A contour plot: its field, bands, extremes and the value at every node.
#[derive(Clone, Debug, PartialEq)]
@@ -48,13 +50,16 @@ pub struct Plot {
pub result: Arc<StepResult>,
/// Whether the model changed since the step was solved.
pub stale: bool,
/// The scale factor the deformation is drawn with (0: undeformed), before animation.
/// The deformation scale factor, before animation: the view's or the automatic one, also
/// while the view is undeformed (what ticking Deformed would draw it with).
pub scale: f64,
/// The elements as drawn (deformed at the current animation frame).
pub display: DisplayMesh,
pub contour: Option<Arc<Contour>>,
/// The mode and field frame drawn.
pub pick: Pick,
/// The model's unit system, which the numbers shown with the plot are in.
pub units: fieldcraft_model::UnitSystem,
}
/// The part of a plot that does not change with the animation frame.
@@ -143,18 +148,10 @@ pub fn pick(view: &ResultsView, result: &StepResult, frame: Option<u64>) -> Pick
p
}
/// The deformation scale factor: the view's, or automatic (the largest displacement a tenth of
/// the model's size); 0 when the view is undeformed or nothing moves.
/// The deformation scale factor: the view's, or automatic ([`auto_scale`]), whether or not the
/// view is deformed.
pub fn scale(view: &ResultsView, result: &StepResult, pick: Pick, base: &DisplayMesh) -> f64 {
if !view.deformed {
return 0.0;
}
if let Some(s) = view.scale {
return s;
}
let largest = displacements(result, pick).map_or(0.0, |u| u.iter().map(|v| magnitude(*v)).filter(|m| m.is_finite()).fold(0.0, f64::max));
let size = base.bounds.diagonal();
if largest > 0.0 && size.is_finite() && size > 0.0 { AUTO_FRACTION * size / largest } else { 0.0 }
view.scale.unwrap_or_else(|| auto_scale(result, pick, base.bounds.diagonal()))
}
/// The part of the session's results plot that depends on what is drawn (`pick`), not on the
@@ -162,7 +159,13 @@ pub fn scale(view: &ResultsView, result: &StepResult, pick: Pick, base: &Display
pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick) -> Option<Prepared> {
let (view, result, _) = shown(session)?;
let contour = view.contour.and_then(|field| {
let values = field_values(&session.model, result, field, pick).ok()?;
let mut values = field_values(&session.model, result, field, pick).ok()?;
// The range (and the bands) follow what is drawn: a hidden body's nodes have no value.
for (v, label) in values.iter_mut().zip(result.nodes()) {
if base.node_position(*label).is_none() {
*v = f64::NAN;
}
}
let (min, max) = range(result.nodes(), &values)?;
Some(Arc::new(Contour { field, bands: Bands::new(min.1, max.1, view.bands), min, max, values }))
});
@@ -175,7 +178,7 @@ pub fn plot(session: &Session, base: &DisplayMesh, frame: Option<u64>, prepared:
let (view, result, current) = shown(session)?;
let nodes = result.nodes();
let pick = pick(view, result, frame);
let factor = prepared.scale * swing(result, frame);
let factor = if view.deformed { prepared.scale * swing(result, frame) } else { 0.0 };
let u = displacements(result, pick).unwrap_or(&[]);
let display = base.displaced(|label| {
let i = nodes.binary_search(&label).ok()?;
@@ -189,12 +192,15 @@ pub fn plot(session: &Session, base: &DisplayMesh, frame: Option<u64>, prepared:
display,
contour: prepared.contour.clone(),
pick,
units: session.model.units(),
})
}
/// The scene of a plot: the deformed elements drawn as the model is ([`scene::build`]), with
/// their faces split into contour bands when a field is shown.
/// their faces split into contour bands when a field is shown. Over a plot, hiding the mesh hides
/// the element edges: the result itself never vanishes with them.
pub fn scene(plot: &Plot, style: Style, show: crate::scene::Show, t: &Tokens) -> Scene {
let (style, show) = if show.mesh { (style, show) } else { (Style::Shaded, crate::scene::Show { mesh: true, ..show }) };
let mut s = crate::scene::build(&plot.display, style, show, t);
let Some(c) = plot.contour.as_ref().filter(|_| style != Style::Wireframe && show.mesh) else { return s };
let nodes = plot.result.nodes();
@@ -213,27 +219,83 @@ pub fn scene(plot: &Plot, style: Style, show: crate::scene::Show, t: &Tokens) ->
s
}
/// A number for the legend and probe: four significant digits, scientific when very large or
/// small.
/// A number as the results show it (legend, probe, reaction sums, solve summary): four
/// significant digits, thousands separators, scientific notation when very large or small.
pub fn number(v: f64) -> String {
if !v.is_finite() {
return "—".into();
}
let a = v.abs();
if a == 0.0 {
"0".into()
} else if !(1e-3..1e5).contains(&a) {
format!("{v:.3e}")
} else {
let decimals = (3 - a.log10().floor() as i32).clamp(0, 6) as usize;
format!("{v:.decimals$}")
return "0".into();
}
if !(1e-3..1e6).contains(&a) {
return format!("{:.*e}", (DIGITS - 1) as usize, v);
}
let decimals = (DIGITS - 1 - a.log10().floor() as i32).clamp(0, 6) as usize;
let text = format!("{v:.decimals$}");
// Rounding can make it -0.000.
if text.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') {
return "0".into();
}
let (sign, digits) = text.strip_prefix('-').map_or(("", text.as_str()), |d| ("-", d));
let (whole, fraction) = digits.split_once('.').map_or((digits, None), |(w, f)| (w, Some(f)));
let grouped = grouped(whole);
match fraction {
Some(f) => format!("{sign}{grouped}.{f}"),
None => format!("{sign}{grouped}"),
}
}
/// `v` read among values as large as `scale` (the largest magnitude beside it): round-off below
/// a billionth of it is 0.
pub fn clean(v: f64, scale: f64) -> f64 {
if scale.is_finite() && v.abs() <= ROUND_OFF * scale.abs() { 0.0 } else { v }
}
/// [`number`] of `v` [`clean`]ed against `scale`.
pub fn number_in(v: f64, scale: f64) -> String {
number(clean(v, scale))
}
/// A count with thousands separators: `221,850,309`.
pub fn count(n: u64) -> String {
grouped(&n.to_string())
}
/// Digits with a comma between each group of three from the right.
fn grouped(digits: &str) -> String {
let mut out = String::new();
for (i, c) in digits.chars().enumerate() {
if i > 0 && (digits.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(c);
}
out
}
/// A number with its unit: `0.1048 mm` (the number alone without one).
pub fn with_unit(text: String, unit: Option<&str>) -> String {
match unit {
Some(u) => format!("{text} {u}"),
None => text,
}
}
/// A vector's components, round-off among them (against the largest, or `scale` when larger)
/// shown as 0: `0, 0, -0.1048`.
pub fn components(v: &[f64], scale: f64) -> String {
let largest = v.iter().map(|c| c.abs()).filter(|c| c.is_finite()).fold(scale.abs(), f64::max);
v.iter().map(|c| number_in(*c, largest)).collect::<Vec<_>>().join(", ")
}
#[cfg(test)]
pub(crate) mod tests {
use serde_json::json;
use fieldcraft_engine::results::{AUTO_FRACTION, magnitude};
use super::*;
fn plot_at(s: &Session, base: &DisplayMesh, frame: Option<u64>) -> Option<Plot> {
@@ -274,9 +336,13 @@ pub(crate) mod tests {
s.execute("results.deformed", &json!({"scale": "auto"})).unwrap();
let p = plot_at(&s, &base, None).unwrap();
let largest = r.displacement.iter().map(|v| magnitude(*v)).fold(0.0, f64::max);
assert!((p.scale * largest / base.bounds.diagonal() - AUTO_FRACTION).abs() < 1e-12, "{}", p.scale);
let auto = AUTO_FRACTION * base.bounds.diagonal() / largest;
assert!((p.scale / auto - 1.0).abs() < 1e-12, "{}", p.scale);
// Undeformed, the plot still knows the scale ticking Deformed would draw (#325).
s.execute("results.deformed", &json!({"show": false})).unwrap();
assert_eq!(plot_at(&s, &base, None).unwrap().display.tris, base.tris);
let p = plot_at(&s, &base, None).unwrap();
assert_eq!(p.display.tris, base.tris);
assert!((p.scale / auto - 1.0).abs() < 1e-12, "{}", p.scale);
}
#[test]
@@ -303,6 +369,10 @@ pub(crate) mod tests {
assert!(scene.tris.iter().any(|tri| tri.color == band_color(&stops, k, 4)), "band {k}");
}
assert!(scene.tris.len() > base.tris.len());
// Hiding the mesh hides the element edges, not the contours (#317).
let hidden = super::scene(&p, Style::ShadedEdges, crate::scene::Show { mesh: false, ..crate::scene::Show::DEFAULT }, &t);
assert_eq!(hidden.tris, scene.tris);
assert!(hidden.lines.iter().all(|l| l.color != rgb(t.mesh_edge)), "no element edges");
}
#[test]
@@ -340,5 +410,16 @@ pub(crate) mod tests {
assert_eq!(number(1.5e-6), "1.500e-6");
assert_eq!(number(2.5e7), "2.500e7");
assert_eq!(number(f64::NAN), "—");
assert_eq!(number(123_456.7), "123,457");
assert_eq!(number(-4_321.06), "-4,321");
assert_eq!(number(-0.000_000_4), "-4.000e-7");
assert_eq!(count(221_850_309), "221,850,309");
assert_eq!(count(945), "945");
// Round-off beside larger values is 0.
assert_eq!(number_in(-3.16e-11, 1000.0), "0");
assert_eq!(number_in(4.34e-9, 1e5), "0");
assert_eq!(number_in(0.5, 1000.0), "0.5000");
assert_eq!(components(&[9.497e-17, -3.463e-14, -0.2415], 0.0), "0, 0, -0.2415");
assert_eq!(with_unit(number(0.1048), Some("mm")), "0.1048 mm");
}
}
+293 -78
View File
@@ -1,29 +1,63 @@
//! What the viewport shows over a results plot: the legend (field, bands, range, deformation
//! scale, a stale-result warning) and the probed node; and the results controls of the context
//! panel (right), which run the `results.*` commands.
//! What the viewport shows over a results plot: the legend (field and unit, bands, range,
//! deformation scale, a stale-result warning), markers at the contour's largest and smallest
//! value, and the probed node; and the results controls of the context panel (right), which run
//! the `results.*` commands. Numbers go through one formatter ([`crate::results::number`]) with
//! the units of the model's unit system ([`Quantity`]).
use egui::{Align2, Color32, FontId, Rect, RichText, Stroke, pos2, vec2};
use fieldcraft_engine::results::{Field, Kind};
use egui::{Align2, Color32, FontId, Pos2, Rect, RichText, Stroke, pos2, vec2};
use fieldcraft_engine::results::{Field, Kind, Quantity, true_scale, with_unit as titled};
use fieldcraft_explicit::{Energies, ExplicitResult};
use serde_json::{Value, json};
use crate::ModelApp;
use crate::results::{Plot, number};
use crate::results::{Plot, components, count, number, number_in, with_unit};
use crate::theme::Tokens;
/// Width of the panel's drop-down lists.
const COMBO_WIDTH: f32 = 160.0;
const SWATCH: egui::Vec2 = vec2(18.0, 15.0);
/// Height of the energy plot (points), and the most points one of its lines draws.
const PLOT_HEIGHT: f32 = 90.0;
/// Height of the energy plot's drawing area (points), the room its axis labels take, and the
/// most points one of its lines draws.
const PLOT_HEIGHT: f32 = 110.0;
const AXIS_LEFT: f32 = 48.0;
const AXIS_BOTTOM: f32 = 16.0;
const MAX_PLOT_POINTS: usize = 400;
/// Gap between a marker and its label, and the label's padding (points).
const LABEL_GAP: f32 = 12.0;
const LABEL_PAD: egui::Vec2 = vec2(8.0, 4.0);
/// Draw the legend and the probe when a results plot is shown.
/// Draw the legend, the extremes' markers and the probe when a results plot is shown. A click
/// on the legend's Max or Min line turns the view to that node and probes it.
pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
let Some(plot) = app.plot() else { return };
let painter = ui.painter_at(rect);
legend(&plot, &painter, rect, t);
probe(app, &plot, &painter, t);
let extremes = legend(&plot, &painter, rect, t);
if app.ui.result_extremes {
markers(app, &plot, &painter, rect, t);
}
probe(app, &plot, &painter, rect, t);
let mut focus = None;
for (i, (line, node)) in extremes.into_iter().enumerate() {
let r = ui.interact(line, ui.id().with(("fc_legend_extreme", i)), egui::Sense::click()).on_hover_text(format!("Show and probe node {node}"));
if r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.clicked() {
focus = Some(node);
}
}
if let Some(node) = focus {
focus_node(app, &plot, node);
}
}
/// Turn the view about node `node` of the plot (keeping the zoom) and probe it.
fn focus_node(app: &mut ModelApp, plot: &Plot, node: u32) {
if let Some(at) = plot.node_position(node).filter(|p| p.is_finite()) {
app.cam.target = at;
}
// A failure (a stale result) shows in the status bar.
let _ = app.probe_node(node);
}
/// What the legend shows and where: its frame, title and text lines.
@@ -40,28 +74,56 @@ pub fn legend_rect(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens)
legend_layout(plot, painter, rect, t).frame
}
/// The legend's title: the field with its unit (a mode shape normalised), or the shape drawn.
fn title(plot: &Plot) -> String {
match (&plot.contour, plot.mode()) {
(Some(l), _) => l.field.title(&plot.result, plot.units),
(None, Some(_)) => "Mode shape (normalised)".into(),
(None, None) => "Deformed shape".into(),
}
}
/// The deformation line: its scale, and whether it is automatic or true.
fn deformation(plot: &Plot) -> String {
if !plot.view.deformed {
return "Undeformed".into();
}
let how = match plot.view.scale {
_ if plot.scale == 1.0 && true_scale(&plot.result) => " (true scale)",
None => " (auto)",
Some(_) => "",
};
format!("Deformation × {}{how}", number(plot.scale))
}
/// The contour's Max and Min lines.
fn extreme_lines(plot: &Plot) -> Option<[(String, u32); 2]> {
let l = plot.contour.as_ref()?;
let unit = l.field.unit(&plot.result, plot.units);
let scale = l.max.1.abs().max(l.min.1.abs());
let line = |what: &str, (node, v): (u32, f64)| (format!("{what} {} at node {node}", with_unit(number_in(v, scale), unit.as_deref())), node);
Some([line("Max", l.max), line("Min", l.min)])
}
/// The legend's text and frame: top left, as wide as its longest line and as tall as its lines
/// and bands.
fn legend_layout(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -> Legend {
let mut lines: Vec<(String, Color32)> = Vec::new();
let title = match (&plot.contour, plot.mode()) {
(Some(l), _) => l.field.label().to_string(),
(None, Some(_)) => "Mode shape".into(),
(None, None) => "Deformed shape".into(),
};
let title = title(plot);
let mut step = format!("Step {}", plot.view.step);
if let Some((mode, value)) = plot.mode() {
step.push_str(&format!(", mode {mode}: {value}"));
}
if let Some((k, time, n)) = plot.frame() {
step.push_str(&format!(", frame {k} of {}: t = {}", n.saturating_sub(1), number(time)));
if let Some((k, at, n)) = plot.frame() {
let last = n.saturating_sub(1);
match plot.result.kind() {
// A nonlinear static step's frames are its increments, at their load factors.
Kind::Static(_) => step.push_str(&format!(", increment {k} of {last}: load factor {}", number(at))),
_ => step.push_str(&format!(", frame {k} of {last}: t = {}", with_unit(number(at), Quantity::Time.unit(plot.units).as_deref()))),
}
}
lines.push((step, t.overlay_text));
if plot.scale > 0.0 {
lines.push((format!("Deformation × {}", number(plot.scale)), t.text_dim));
} else {
lines.push(("Undeformed".into(), t.text_dim));
}
lines.push((deformation(plot), t.text_dim));
if plot.stale {
lines.push(("Stale: the model changed since this step was solved".into(), t.warm));
}
@@ -69,8 +131,14 @@ fn legend_layout(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -
let font = FontId::proportional(12.0);
let line_h = LEGEND_LINE;
let bands = plot.contour.as_ref().map_or(0, |l| l.bands.count) as f32;
// As wide as its longest line (and the range labels beside the swatches).
let widest = lines.iter().map(|(text, _)| painter.layout_no_wrap(text.clone(), font.clone(), t.overlay_text).size().x).fold(0.0_f32, f32::max);
// As wide as its longest line (and the extremes' lines under the swatches).
let extremes = extreme_lines(plot).into_iter().flatten().map(|(text, _)| text);
let widest = lines
.iter()
.map(|(text, _)| text.clone())
.chain(extremes)
.map(|text| painter.layout_no_wrap(text, font.clone(), t.overlay_text).size().x)
.fold(0.0_f32, f32::max);
let title_w = painter.layout_no_wrap(title.clone(), FontId::proportional(14.0), t.overlay_text).size().x;
let width = (widest.max(title_w) + 20.0).max(170.0);
// Padding and title, the lines, then the swatches with the range lines under them.
@@ -78,8 +146,9 @@ fn legend_layout(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -
Legend { frame: Rect::from_min_size(origin, vec2(width, height)), title, lines }
}
/// The legend, top left under the view buttons.
fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) {
/// The legend, top left under the view buttons. Returns the Max and Min lines' rects with their
/// nodes.
fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -> Vec<(Rect, u32)> {
let Legend { frame, title, lines } = legend_layout(plot, painter, rect, t);
let origin = frame.min;
let (font, line_h) = (FontId::proportional(12.0), LEGEND_LINE);
@@ -91,9 +160,11 @@ fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) {
painter.text(pos2(origin.x + 10.0, y), Align2::LEFT_TOP, text, font.clone(), *color);
y += line_h;
}
let Some(l) = &plot.contour else { return };
let (Some(l), Some(extremes)) = (&plot.contour, extreme_lines(plot)) else { return Vec::new() };
y += 6.0;
let stops = t.contour_stops();
// Round-off among the levels (a range from -1e-15 to 3) reads as 0.
let scale = l.max.1.abs().max(l.min.1.abs());
// High values on top, each band's lower boundary written beside its swatch's bottom edge.
for k in (0..l.bands.count).rev() {
let row = (l.bands.count - 1 - k) as f32;
@@ -101,9 +172,9 @@ fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) {
let c = fieldcraft_render::contour::band_color(&stops, k, l.bands.count);
painter.rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2));
if k + 1 == l.bands.count {
painter.text(pos2(r.right() + 6.0, r.top()), Align2::LEFT_CENTER, number(l.bands.level(k + 1)), font.clone(), t.overlay_text);
painter.text(pos2(r.right() + 6.0, r.top()), Align2::LEFT_CENTER, number_in(l.bands.level(k + 1), scale), font.clone(), t.overlay_text);
}
painter.text(pos2(r.right() + 6.0, r.bottom()), Align2::LEFT_CENTER, number(l.bands.level(k)), font.clone(), t.overlay_text);
painter.text(pos2(r.right() + 6.0, r.bottom()), Align2::LEFT_CENTER, number_in(l.bands.level(k), scale), font.clone(), t.overlay_text);
}
painter.rect_stroke(
Rect::from_min_size(pos2(origin.x + 10.0, y), vec2(SWATCH.x, SWATCH.y * l.bands.count as f32)),
@@ -112,24 +183,54 @@ fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) {
egui::StrokeKind::Outside,
);
y += SWATCH.y * l.bands.count as f32 + 10.0;
painter.text(pos2(origin.x + 10.0, y), Align2::LEFT_TOP, format!("Max {} at node {}", number(l.max.1), l.max.0), font.clone(), t.text_dim);
painter.text(pos2(origin.x + 10.0, y + line_h), Align2::LEFT_TOP, format!("Min {} at node {}", number(l.min.1), l.min.0), font, t.text_dim);
let mut out = Vec::new();
for (i, (text, node)) in extremes.into_iter().enumerate() {
let r = painter.text(pos2(origin.x + 10.0, y + line_h * i as f32), Align2::LEFT_TOP, text, font.clone(), t.text_dim);
out.push((r.expand(2.0), node));
}
out
}
/// The probed node: a marker on it and its values beside it.
fn probe(app: &ModelApp, plot: &Plot, painter: &egui::Painter, t: &Tokens) {
/// A box of `text` beside the screen point `at`, kept inside `bounds`: right of the point, or left
/// of it when it would run off the right edge; held inside top and bottom.
fn label_beside(painter: &egui::Painter, at: Pos2, text: String, bounds: Rect, t: &Tokens) {
let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.overlay_text);
let size = galley.size() + 2.0 * LABEL_PAD;
let right = at + vec2(LABEL_GAP, -size.y / 2.0);
let x = if right.x + size.x > bounds.right() { at.x - LABEL_GAP - size.x } else { right.x };
let y = right.y.clamp(bounds.top(), (bounds.bottom() - size.y).max(bounds.top()));
let box_rect = Rect::from_min_size(pos2(x.max(bounds.left()), y), size);
painter.rect_filled(box_rect, 4.0, t.overlay);
painter.galley(box_rect.left_top() + LABEL_PAD, galley, t.overlay_text);
}
/// Markers with their values at the contour's largest and smallest value.
fn markers(app: &ModelApp, plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) {
let Some(l) = &plot.contour else { return };
let unit = l.field.unit(&plot.result, plot.units);
let scale = l.max.1.abs().max(l.min.1.abs());
let (high, low) = (t.contour[4], t.contour[0]);
for (what, (node, v), color) in [("Max", l.max, high), ("Min", l.min, low)] {
let Some(at) = plot.node_position(node).and_then(|w| crate::viewport::to_screen(app, w)) else { continue };
if !rect.contains(at) {
continue;
}
painter.circle(at, 5.0, color, Stroke::new(2.0, t.overlay_text));
label_beside(painter, at, format!("{what} {}", with_unit(number_in(v, scale), unit.as_deref())), rect, t);
}
}
/// The probed node: a marker on it and its values beside it, inside the viewport.
fn probe(app: &ModelApp, plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) {
let Some(p) = app.probe.as_ref().filter(|p| p.step == plot.view.step && p.mode == plot.pick.mode && p.frame == plot.pick.frame) else { return };
let Some(at) = plot.node_position(p.node).and_then(|w| crate::viewport::to_screen(app, w)) else { return };
painter.circle(at, 5.0, t.accent, Stroke::new(2.0, t.overlay_text));
let font = FontId::proportional(12.0);
let galley = painter.layout_no_wrap(p.lines.join("\n"), font, t.overlay_text);
let box_rect = Rect::from_min_size(at + vec2(12.0, -10.0), galley.size() + vec2(16.0, 8.0));
painter.rect_filled(box_rect, 4.0, t.overlay);
painter.galley(box_rect.left_top() + vec2(8.0, 4.0), galley, t.overlay_text);
label_beside(painter, at, p.lines.join("\n"), rect, t);
}
/// The results controls (the context panel's Results section): step, contour field,
/// deformation, modes, animation, reaction sums. Every control runs a command.
/// deformation, modes, animation, what the viewport marks, reaction sums and the probed node.
/// Every control runs a command or a view setting.
pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) {
let mut run: Vec<(&'static str, Value)> = Vec::new();
let steps: Vec<(String, bool)> = app.session.result_steps().map(|(n, c)| (n.to_string(), c)).collect();
@@ -164,11 +265,22 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
run.push(("results.deformed", json!({"show": deformed})));
}
let mut auto = plot.view.scale.is_none();
if ui.checkbox(&mut auto, "Auto").on_hover_text("Scale the largest displacement to a tenth of the model").changed() {
let tip = if true_scale(&plot.result) {
"Draw the displacements at true scale (they are large already)"
} else {
"Scale the largest displacement to a tenth of the model"
};
if ui.checkbox(&mut auto, "Auto").on_hover_text(tip).changed() {
run.push(("results.deformed", json!({"scale": if auto { json!("auto") } else { json!(plot.scale) }})));
}
// The scale ticking Deformed draws with, also while it is off.
let mut scale = plot.scale;
let drag = egui::DragValue::new(&mut scale).speed(plot.scale.abs().max(1e-6) * 0.01).range(0.0..=1e12).prefix("× ");
let drag = egui::DragValue::new(&mut scale)
.speed(plot.scale.abs().max(1e-6) * 0.01)
.range(0.0..=1e12)
.prefix("× ")
.custom_formatter(|v, _| number(v))
.custom_parser(|s| s.replace(',', "").trim().parse::<f64>().ok());
if ui.add_enabled(plot.view.deformed, drag).changed() {
run.push(("results.deformed", json!({"scale": scale})));
}
@@ -180,6 +292,14 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
run.push(("results.animate", json!({"on": animate})));
}
ui.end_row();
ui.label("Show");
ui.horizontal(|ui| {
if plot.contour.is_some() {
ui.checkbox(&mut app.ui.result_extremes, "Max/Min").on_hover_text("Mark the nodes of the largest and smallest value");
}
ui.checkbox(&mut app.ui.result_symbols, "Loads & BCs").on_hover_text("The symbols of the step's boundary conditions and loads");
});
ui.end_row();
});
if let Kind::Explicit(e) = plot.result.kind() {
ui.separator();
@@ -215,27 +335,45 @@ pub fn controls(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens)
} else if let Kind::Heat(h) = plot.result.kind() {
ui.separator();
heat_flows(ui, &fieldcraft_engine::results::heat_flows(h), t);
} else {
}
if matches!(plot.result.kind(), Kind::Static(_)) {
ui.separator();
if ui.button("Reaction sums").on_hover_text("Reaction force and moment per boundary-condition set (results.reactions)").clicked() {
run.push(("results.reactions", json!({"step": plot.view.step})));
}
if let Some(r) = app.reactions.as_ref().filter(|r| r["step"] == plot.view.step.as_str()) {
reactions_table(ui, r, t);
reactions_table(ui, r, plot, t);
}
}
ui.label(RichText::new("Click the model to probe a node").small().color(t.text_dim));
ui.separator();
probed(app, plot, ui, t);
for (id, params) in run {
let reply = app.run(id, params);
if id == "results.reactions" {
app.reactions = reply.ok();
}
// The reaction sums land in `app.reactions` (ModelApp::run_form).
let _ = app.run(id, params);
}
}
/// The probed node's values, one row each, or how to probe one.
fn probed(app: &ModelApp, plot: &Plot, ui: &mut egui::Ui, t: &Tokens) {
let Some(p) = app.probe.as_ref().filter(|p| p.step == plot.view.step && p.mode == plot.pick.mode && p.frame == plot.pick.frame) else {
ui.label(RichText::new("Click the model to probe a node").small().color(t.text_dim));
return;
};
ui.label(RichText::new(format!("Node {}", p.node)).strong());
egui::Grid::new("fc_results_probe").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
for (what, value) in &p.rows {
ui.label(RichText::new(what).color(t.text_dim));
ui.add(egui::Label::new(value).extend());
ui.end_row();
}
});
}
/// An explicit step: a frame slider (the deformed shape and contours follow it) and the energy
/// histories, plotted and read at the frame's time.
/// histories, plotted against time with a cursor at the frame shown (a click or drag moves it)
/// and read there.
fn explicit(ui: &mut egui::Ui, e: &ExplicitResult, plot: &Plot, t: &Tokens, run: &mut Vec<(&'static str, Value)>) {
let time_unit = Quantity::Time.unit(plot.units);
if let Some((k, time, n)) = plot.frame() {
ui.horizontal(|ui| {
ui.label("Frame");
@@ -244,7 +382,7 @@ fn explicit(ui: &mut egui::Ui, e: &ExplicitResult, plot: &Plot, t: &Tokens, run:
run.push(("results.frame", json!({"frame": frame})));
}
});
ui.label(RichText::new(format!("t = {}", number(time))).color(t.text_dim));
ui.label(RichText::new(format!("t = {}", with_unit(number(time), time_unit.as_deref()))).color(t.text_dim));
}
let h = &e.history;
if h.time.is_empty() {
@@ -254,17 +392,35 @@ fn explicit(ui: &mut egui::Ui, e: &ExplicitResult, plot: &Plot, t: &Tokens, run:
("Kinetic", t.accent, |e| e.kinetic),
("Internal", t.ok, |e| e.internal),
("Hourglass", t.warm, |e| e.hourglass),
("External", t.error, |e| e.external),
("External work", t.error, |e| e.external),
];
ui.label(RichText::new("Energies").color(t.text_dim));
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), PLOT_HEIGHT), egui::Sense::hover());
let painter = ui.painter_at(rect);
let energy_unit = Quantity::Energy.unit(plot.units);
ui.label(RichText::new(titled("Energies", energy_unit.as_deref())).color(t.text_dim));
let (outer, response) = ui.allocate_exact_size(vec2(ui.available_width(), PLOT_HEIGHT + AXIS_BOTTOM), egui::Sense::click_and_drag());
let rect = Rect::from_min_max(outer.min + vec2(AXIS_LEFT, 0.0), outer.max - vec2(0.0, AXIS_BOTTOM));
let painter = ui.painter_at(outer);
painter.rect_filled(rect, 2.0, t.field);
let t_end = h.time.last().copied().unwrap_or(0.0).max(f64::MIN_POSITIVE);
let top = h.energies.iter().flat_map(|e| series.iter().map(move |(_, _, f)| f(e).abs())).filter(|v| v.is_finite()).fold(0.0, f64::max);
let top = if top > 0.0 { top } else { 1.0 };
let at =
|time: f64, v: f64| pos2(rect.left() + (time / t_end) as f32 * rect.width(), rect.bottom() - 2.0 - (v / top) as f32 * (rect.height() - 4.0));
// The value axis spans every curve and 0 (a curve below 0 stays in the frame).
let values = || h.energies.iter().flat_map(|e| series.iter().map(move |(_, _, f)| f(e))).filter(|v| v.is_finite());
let hi = values().fold(0.0, f64::max);
let lo = values().fold(0.0, f64::min);
let span = if hi > lo { hi - lo } else { 1.0 };
let at = |time: f64, v: f64| {
pos2(rect.left() + (time / t_end) as f32 * rect.width(), rect.bottom() - 2.0 - ((v - lo) / span) as f32 * (rect.height() - 4.0))
};
// Axes: the value range left, the time range under it.
let small = FontId::proportional(10.0);
let scale = hi.abs().max(lo.abs());
painter.text(pos2(rect.left() - 4.0, rect.top()), Align2::RIGHT_TOP, number_in(hi, scale), small.clone(), t.text_dim);
painter.text(pos2(rect.left() - 4.0, rect.bottom()), Align2::RIGHT_BOTTOM, number_in(lo, scale), small.clone(), t.text_dim);
if lo < 0.0 && hi > 0.0 {
let zero = at(0.0, 0.0).y;
painter.line_segment([pos2(rect.left(), zero), pos2(rect.right(), zero)], Stroke::new(1.0, t.border));
}
painter.text(pos2(rect.left(), rect.bottom() + 2.0), Align2::LEFT_TOP, "0", small.clone(), t.text_dim);
let end = format!("t {}", with_unit(number(t_end), time_unit.as_deref()));
painter.text(pos2(rect.right(), rect.bottom() + 2.0), Align2::RIGHT_TOP, end, small, t.text_dim);
// At most a few hundred points per line, however long the history.
let stride = (h.time.len() / MAX_PLOT_POINTS).max(1);
for (_, color, f) in &series {
@@ -272,19 +428,38 @@ fn explicit(ui: &mut egui::Ui, e: &ExplicitResult, plot: &Plot, t: &Tokens, run:
h.time.iter().zip(&h.energies).step_by(stride).filter(|(_, e)| f(e).is_finite()).map(|(time, e)| at(*time, f(e))).collect();
painter.add(egui::Shape::line(points, Stroke::new(1.5, *color)));
}
// The frame's time, and the sample nearest it.
let nearest = |time: f64| h.time.iter().enumerate().min_by(|a, b| (a.1 - time).abs().total_cmp(&(b.1 - time).abs())).map_or(0, |(i, _)| i);
let time_at = |x: f32| f64::from(((x - rect.left()) / rect.width()).clamp(0.0, 1.0)) * t_end;
// A click or drag on the plot shows the frame nearest that time.
if (response.clicked() || response.dragged())
&& let Some(p) = response.interact_pointer_pos()
{
run.push(("results.frame", json!({"time": time_at(p.x)})));
}
// Hover reads the curves at the pointer's time.
if let Some(p) = response.hover_pos().filter(|p| rect.contains(*p)) {
let x = p.x;
painter.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], Stroke::new(1.0, t.border));
if let Some(e) = h.energies.get(nearest(time_at(x))) {
let mut lines = vec![format!("t = {}", with_unit(number(time_at(x)), time_unit.as_deref()))];
lines.extend(series.iter().map(|(name, _, f)| format!("{name} {}", number_in(f(e), scale))));
response.clone().on_hover_text(lines.join("\n"));
}
}
// The frame's time: the cursor, and the values there.
let now = plot.frame().map_or(t_end, |(_, time, _)| time);
let x = at(now, 0.0).x;
painter.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], Stroke::new(1.0, t.text_dim));
let i = h.time.iter().enumerate().min_by(|a, b| (a.1 - now).abs().total_cmp(&(b.1 - now).abs())).map_or(0, |(i, _)| i);
let Some(at_now) = h.energies.get(i) else { return };
painter.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], Stroke::new(1.5, t.text));
let Some(at_now) = h.energies.get(nearest(now)) else { return };
let frame = plot.frame().map_or_else(String::new, |(k, ..)| format!(", frame {k}"));
ui.label(RichText::new(format!("At t = {}{frame}", with_unit(number(now), time_unit.as_deref()))).small().color(t.text_dim));
egui::Grid::new("fc_results_energies").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
for (name, color, f) in &series {
ui.label(RichText::new(*name).color(*color));
ui.label(number(f(at_now)));
ui.label(with_unit(number_in(f(at_now), scale), energy_unit.as_deref()));
ui.end_row();
}
ui.label(RichText::new("Balance").color(t.text_dim));
ui.label(RichText::new("Balance").color(t.text_dim)).on_hover_text("The energy balance's relative error");
ui.label(number(at_now.balance));
ui.end_row();
});
@@ -308,23 +483,63 @@ fn heat_flows(ui: &mut egui::Ui, flows: &Value, t: &Tokens) {
});
}
fn reactions_table(ui: &mut egui::Ui, r: &Value, t: &Tokens) {
let row = |ui: &mut egui::Ui, name: &str, v: &Value| {
let f = |k: usize| v.pointer(&format!("/force/{k}")).and_then(Value::as_f64).map_or("—".into(), number);
ui.label(name);
ui.label(f(0));
ui.label(f(1));
ui.label(f(2));
ui.end_row();
/// The reaction sums: per set and over the model, the force and the moment about the point, and
/// the applied total beside the reacted one (they balance in equilibrium). Round-off below a
/// billionth of the largest force (moment) is 0.
fn reactions_table(ui: &mut egui::Ui, r: &Value, plot: &Plot, t: &Tokens) {
let vector = |v: &Value| -> Option<[f64; 3]> { serde_json::from_value(v.clone()).ok() };
let rows: Vec<(String, Option<[f64; 3]>, Option<[f64; 3]>)> = r["sets"]
.as_array()
.into_iter()
.flatten()
.map(|s| {
let nodes = s["nodes"].as_u64().unwrap_or(0);
(format!("{} ({} nodes)", s["set"].as_str().unwrap_or("?"), count(nodes)), vector(&s["force"]), vector(&s["moment"]))
})
.chain(std::iter::once(("All nodes".to_string(), vector(&r["total"]["force"]), vector(&r["total"]["moment"]))))
.collect();
let applied = vector(&r["applied"]);
let largest = |vs: &mut dyn Iterator<Item = [f64; 3]>| vs.flatten().map(f64::abs).filter(|x| x.is_finite()).fold(0.0, f64::max);
let force_scale = largest(&mut rows.iter().filter_map(|r| r.1).chain(applied));
let moment_scale = largest(&mut rows.iter().filter_map(|r| r.2));
let (force, moment) = (Quantity::Force.unit(plot.units), Quantity::Moment.unit(plot.units));
let about = vector(&r["about"]).unwrap_or_default();
ui.label(RichText::new(format!("Moments about ({})", components(&about, 0.0))).small().color(t.text_dim));
let cells = |ui: &mut egui::Ui, v: Option<[f64; 3]>, scale: f64| {
for k in 0..3 {
ui.add(egui::Label::new(v.and_then(|v| v.get(k).copied()).map_or("—".into(), |x| number_in(x, scale))).extend());
}
};
egui::Grid::new("fc_results_reactions").num_columns(4).spacing(vec2(6.0, 2.0)).show(ui, |ui| {
for h in ["Set", "Fx", "Fy", "Fz"] {
egui::Grid::new("fc_results_reactions").num_columns(4).spacing(vec2(8.0, 2.0)).striped(true).show(ui, |ui| {
for h in ["", "X", "Y", "Z"] {
ui.label(RichText::new(h).small().color(t.text_dim));
}
ui.end_row();
for s in r["sets"].as_array().into_iter().flatten() {
row(ui, s["set"].as_str().unwrap_or("?"), s);
for (name, f, m) in &rows {
ui.label(RichText::new(name).strong());
ui.end_row();
ui.label(RichText::new(titled("F", force.as_deref())).color(t.text_dim));
cells(ui, *f, force_scale);
ui.end_row();
ui.label(RichText::new(titled("M", moment.as_deref())).color(t.text_dim));
cells(ui, *m, moment_scale);
ui.end_row();
}
if applied.is_some() {
ui.label(RichText::new(titled("Applied F", force.as_deref())).color(t.text_dim));
cells(ui, applied, force_scale);
ui.end_row();
}
row(ui, "All", &r["total"]);
});
// Equilibrium: the reactions over the whole model balance the applied loads.
if let (Some(total), Some(applied)) = (rows.last().and_then(|r| r.1), applied) {
let residual = (0..3).map(|k| (total[k] + applied[k]).abs()).fold(0.0, f64::max);
let balanced = residual <= 1e-6 * force_scale.max(f64::MIN_POSITIVE);
let (text, color) = if balanced {
("✔ Reactions balance the applied loads".to_string(), t.ok)
} else {
(format!("Reactions and applied loads differ by {}", with_unit(number(residual), force.as_deref())), t.warm)
};
ui.label(RichText::new(text).small().color(color));
}
}