forked from 1b-agent/fieldcraft-upstream
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:
1 parent
91e8467e4f
commit
7a078b1c04
5 files changed
+675
-146
No files matched your search
+113
-31
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user