Files
OneCAD-Simulation/crates/ui-egui/src/results_panel.rs
T
jyvikoandClaude Opus 5.5 5112855b83 results: clamp the contour legend's range; values above it in an over colour (#191)
results.contour takes range {min?, max?} (or "auto"), kept until the field changes: a
singular peak no longer squeezes the field into the bottom bands. Values above a clamped
maximum go to an extra band drawn in the over colour, split exactly at the clamp; the legend
shows that band up to the true maximum and says it is clamped. The results panel has Legend
max/min (Auto or a value), the MCP show_results tool a range argument, and headless pictures
follow the clamp too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 11:07:46 -04:00

593 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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, 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'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, 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);
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.
struct Legend {
frame: Rect,
title: String,
lines: Vec<(String, Color32)>,
}
const LEGEND_LINE: f32 = 16.0;
/// Where the legend of `plot` goes in the viewport `rect` (a fit leaves it free).
pub fn legend_rect(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) -> Rect {
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 = 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, 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));
lines.push((deformation(plot), t.text_dim));
if let Some(l) = plot.contour.as_ref().filter(|_| plot.view.range_min.is_some() || plot.view.range_max.is_some()) {
let scale = l.max.1.abs().max(l.min.1.abs());
lines.push((format!("Legend clamped to {} … {}", number_in(l.bands.min, scale), number_in(l.bands.max, scale)), t.warm));
}
if plot.stale {
lines.push(("Stale: the model changed since this step was solved".into(), t.warm));
}
let origin = rect.left_top() + vec2(12.0, 12.0);
let font = FontId::proportional(12.0);
let line_h = LEGEND_LINE;
let bands = plot.contour.as_ref().map_or(0, |l| swatches(&l.bands)) as f32;
// 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.
let height = 36.0 + line_h * lines.len() as f32 + if bands > 0.0 { 16.0 + bands * SWATCH.y + 2.0 * line_h } else { 0.0 };
Legend { frame: Rect::from_min_size(origin, vec2(width, height)), title, lines }
}
/// The legend's swatches: the bands, and the "over" band above a clamped maximum.
fn swatches(b: &fieldcraft_render::contour::Bands) -> u32 {
b.count + u32::from(b.over)
}
/// 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);
painter.rect_filled(frame, 4.0, t.overlay);
let mut y = origin.y + 8.0;
painter.text(pos2(origin.x + 10.0, y), Align2::LEFT_TOP, title, FontId::proportional(14.0), t.overlay_text);
y += 20.0;
for (text, color) in &lines {
painter.text(pos2(origin.x + 10.0, y), Align2::LEFT_TOP, text, font.clone(), *color);
y += line_h;
}
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;
// over a clamped maximum, the "over" band runs up to the field's true maximum.
let rows = swatches(&l.bands);
let over = crate::theme::rgb(t.contour_over);
for k in (0..rows).rev() {
let row = (rows - 1 - k) as f32;
let r = Rect::from_min_size(pos2(origin.x + 10.0, y + row * SWATCH.y), SWATCH);
let c = fieldcraft_render::contour::bands_color(&stops, over, &l.bands, k);
painter.rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2));
if k + 1 == rows {
let top = if l.bands.is_over(k) { l.max.1 } else { l.bands.level(k + 1) };
painter.text(pos2(r.right() + 6.0, r.top()), Align2::LEFT_CENTER, number_in(top, scale), 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 * rows as f32)),
0.0,
Stroke::new(1.0, t.border),
egui::StrokeKind::Outside,
);
y += SWATCH.y * rows as f32 + 10.0;
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
}
/// 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);
}
}
/// `results.contour`'s parameters that change one end of the legend's range (`key` `min` or
/// `max`), keeping the field and the other end.
fn legend_params(plot: &Plot, key: &str, value: Value) -> Value {
let field = plot.view.contour.map_or("none", Field::id);
let mut range = json!({"min": plot.view.range_min, "max": plot.view.range_max});
range[key] = value;
json!({"step": plot.view.step, "field": field, "range": range})
}
/// 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));
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, 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();
egui::Grid::new("fc_results_grid").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| {
ui.label("Step");
egui::ComboBox::from_id_salt("fc_results_step").selected_text(&plot.view.step).width(COMBO_WIDTH).truncate().show_ui(ui, |ui| {
for (name, current) in &steps {
let label = if *current { name.clone() } else { format!("{name} (stale)") };
if ui.selectable_label(*name == plot.view.step, label).clicked() && *name != plot.view.step {
run.push(("results.deformed", json!({"step": name})));
}
}
});
ui.end_row();
ui.label("Contours");
let shown = plot.view.contour.map_or("None", Field::label);
egui::ComboBox::from_id_salt("fc_results_field").selected_text(shown).width(COMBO_WIDTH).truncate().show_ui(ui, |ui| {
if ui.selectable_label(plot.view.contour.is_none(), "None").clicked() {
run.push(("results.contour", json!({"field": "none"})));
}
for f in Field::available(&plot.result) {
if ui.selectable_label(plot.view.contour == Some(f), f.label()).clicked() {
run.push(("results.contour", json!({"field": f.id()})));
}
}
});
ui.end_row();
// The legend's range: each end follows the field (Auto) or is clamped, e.g. below a
// singular peak (#191).
if let Some(l) = &plot.contour {
for (label, key, set, value) in [("Legend max", "max", plot.view.range_max, l.max.1), ("Legend min", "min", plot.view.range_min, l.min.1)]
{
ui.label(label);
ui.horizontal(|ui| {
let mut auto = set.is_none();
let tip = format!("Follow the field's {}; untick to clamp the legend", if key == "max" { "maximum" } else { "minimum" });
if ui.checkbox(&mut auto, "Auto").on_hover_text(tip).changed() {
run.push(("results.contour", legend_params(plot, key, if auto { json!("auto") } else { json!(value) })));
}
let mut v = set.unwrap_or(value);
let speed = (l.max.1 - l.min.1).abs().max(1e-9) * 0.005;
let drag = egui::DragValue::new(&mut v)
.speed(speed)
.custom_formatter(|v, _| number(v))
.custom_parser(|s| s.replace(',', "").trim().parse::<f64>().ok());
if ui.add_enabled(set.is_some(), drag).changed() {
run.push(("results.contour", legend_params(plot, key, json!(v))));
}
});
ui.end_row();
}
}
ui.label("Deformed");
ui.horizontal(|ui| {
let mut deformed = plot.view.deformed;
if ui.checkbox(&mut deformed, "").changed() {
run.push(("results.deformed", json!({"show": deformed})));
}
let mut auto = plot.view.scale.is_none();
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("× ")
.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})));
}
});
ui.end_row();
ui.label("Animate");
let mut animate = plot.view.animate;
if ui.checkbox(&mut animate, "").changed() {
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();
explicit(ui, e, plot, t, &mut run);
} else if let Some((k, factor, n)) = plot.frame().filter(|_| matches!(plot.result.kind(), Kind::Static(_))) {
// A nonlinear static step: its increments, the deformed shape following the slider.
ui.separator();
ui.horizontal(|ui| {
ui.label("Increment");
let mut frame = k;
if ui.add(egui::Slider::new(&mut frame, 0..=n.saturating_sub(1))).changed() {
run.push(("results.frame", json!({"frame": frame})));
}
});
ui.label(RichText::new(format!("load factor {}", number(factor))).color(t.text_dim));
} else if let Kind::Frequency(f) = plot.result.kind() {
ui.separator();
ui.label(RichText::new("Modes").color(t.text_dim));
egui::ScrollArea::vertical().id_salt("fc_results_modes").max_height(150.0).show(ui, |ui| {
for (i, m) in f.modes.iter().enumerate() {
let mode = i + 1;
if ui
.selectable_label(
plot.view.mode == mode,
format!("{mode} {}", crate::results::mode_value(f.buckling.is_some(), m.eigenvalue, m.frequency)),
)
.clicked()
{
run.push(("results.modes", json!({"mode": mode})));
}
}
});
} else if let Kind::Heat(h) = plot.result.kind() {
ui.separator();
heat_flows(ui, &fieldcraft_engine::results::heat_flows(h), t);
}
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, plot, t);
}
}
ui.separator();
probed(app, plot, ui, t);
for (id, params) in run {
// 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 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");
let mut frame = k;
if ui.add(egui::Slider::new(&mut frame, 0..=n.saturating_sub(1))).changed() {
run.push(("results.frame", json!({"frame": frame})));
}
});
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() {
return;
}
let series: [(&str, Color32, fn(&Energies) -> f64); 4] = [
("Kinetic", t.accent, |e| e.kinetic),
("Internal", t.ok, |e| e.internal),
("Hourglass", t.warm, |e| e.hourglass),
("External work", t.error, |e| e.external),
];
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);
// 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 {
let points: Vec<egui::Pos2> =
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)));
}
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.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(with_unit(number_in(f(at_now), scale), energy_unit.as_deref()));
ui.end_row();
}
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();
});
}
/// A heat step's balance: the heat each heat load carries in (negative: out), the prescribed
/// temperatures' total, and what is left over (about 0 when steady).
fn heat_flows(ui: &mut egui::Ui, flows: &Value, t: &Tokens) {
ui.label(RichText::new("Heat flow into the body").color(t.text_dim));
egui::Grid::new("fc_results_heat").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
let row = |ui: &mut egui::Ui, name: &str, v: Option<f64>| {
ui.label(name);
ui.label(v.map_or("—".into(), number));
ui.end_row();
};
for l in flows["loads"].as_array().into_iter().flatten() {
row(ui, &format!("{} ({})", l["name"].as_str().unwrap_or("?"), l["type"].as_str().unwrap_or("").replace('_', " ")), l["heat"].as_f64());
}
row(ui, "Prescribed temperatures", flows["reactions"].as_f64());
row(ui, "Imbalance", flows["imbalance"].as_f64());
});
}
/// 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(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 (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();
}
});
// 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));
}
}