Files
OneCAD-Simulation/crates/ui-egui/src/results_panel.rs
T
jyvikoandClaude Opus 5.5 ad84a52ed2 Viewport: one framing rule for open, results, fit, home and standard views
Every fit frames what is drawn (the deformed shape and the setup's symbols
included) with Camera::fit_in in the part of the viewport the overlays
leave free: the results legend, the selection bar, the gizmo and the
navigation bar (framing::free_part). Standard views fit too, so a long
beam's front view keeps both ends (#147); fit and home leave the legend
clear (#76). The view frames itself in the home view when another model,
sample or design is opened (Session::opened; the same design opened again
after an edit keeps the camera) (#233), and when results come up with part
of the model out of sight (#223). ui.inspect's layout reports the legend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-14 22:29:44 -04:00

310 lines
15 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, 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.
use egui::{Align2, Color32, FontId, Rect, RichText, Stroke, pos2, vec2};
use fieldcraft_engine::results::{Field, Kind};
use fieldcraft_explicit::{Energies, ExplicitResult};
use serde_json::{Value, json};
use crate::ModelApp;
use crate::results::{Plot, number};
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;
const MAX_PLOT_POINTS: usize = 400;
/// Draw the legend and the probe when a results plot is shown.
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);
}
/// 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 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 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)));
}
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));
}
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| 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);
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, top left under the view buttons.
fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) {
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) = &plot.contour else { return };
y += 6.0;
let stops = t.contour_stops();
// 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;
let r = Rect::from_min_size(pos2(origin.x + 10.0, y + row * SWATCH.y), SWATCH);
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.bottom()), Align2::LEFT_CENTER, number(l.bands.level(k)), 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)),
0.0,
Stroke::new(1.0, t.border),
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);
}
/// The probed node: a marker on it and its values beside it.
fn probe(app: &ModelApp, plot: &Plot, painter: &egui::Painter, 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);
}
/// The results controls (the context panel's Results section): step, contour field,
/// deformation, modes, animation, reaction sums. Every control runs a command.
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();
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();
if ui.checkbox(&mut auto, "Auto").on_hover_text("Scale the largest displacement to a tenth of the model").changed() {
run.push(("results.deformed", json!({"scale": if auto { json!("auto") } else { json!(plot.scale) }})));
}
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("× ");
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();
});
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 {
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);
}
}
ui.label(RichText::new("Click the model to probe a node").small().color(t.text_dim));
for (id, params) in run {
let reply = app.run(id, params);
if id == "results.reactions" {
app.reactions = reply.ok();
}
}
}
/// 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.
fn explicit(ui: &mut egui::Ui, e: &ExplicitResult, plot: &Plot, t: &Tokens, run: &mut Vec<(&'static str, Value)>) {
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 = {}", number(time))).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", 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);
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));
// 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)));
}
// The frame's time, and the sample nearest it.
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 };
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.end_row();
}
ui.label(RichText::new("Balance").color(t.text_dim));
ui.label(number(at_now.balance));
ui.end_row();
});
}
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();
};
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"] {
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);
}
row(ui, "All", &r["total"]);
});
}