ui: XY plot in the results panel, drawn by the energy plot's code (#94)

XY Plot opens a section in the results panel: a quantity picker, the
series from results.xy plotted against the step's own axis with the
frame or mode shown marked (a click shows the nearest), nodal fields
read at the probed node, and Export CSV through results.xy. The energy
plot and the XY plot share one line-plot function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 05:31:33 -04:00
1 parent 9a8469cf4b
commit 4a8d34d619
4 files changed
+330 -49

No files matched your search

+61
View File
@@ -302,6 +302,17 @@ pub struct Viewport {
pub menu_target: Option<menu::Target>,
}
/// The XY plot in the results panel (results.xy): the quantity picked, the node it is read at,
/// and the series the command replied with for `key` (step, result identity, `y`).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct XyPlot {
/// `results.xy`'s `y`; a nodal field's `node` is filled from [`XyPlot::node`].
pub y: Option<Value>,
pub node: Option<u32>,
pub key: Option<(String, usize, Value)>,
pub series: Option<Value>,
}
/// 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)]
@@ -377,6 +388,8 @@ pub struct ModelApp {
pub probe: Option<Probe>,
/// The reaction sums asked for last in the results panel.
pub reactions: Option<Value>,
/// The XY plot, while it is open in the results panel.
pub xy: Option<XyPlot>,
/// The last command reply with more to it than its status line, shown in the context panel.
pub reply: Option<reply::Reply>,
/// Whether results were shown last frame (the Results tab comes up when they appear).
@@ -508,6 +521,7 @@ impl ModelApp {
plot: (None, None),
probe: None,
reactions: None,
xy: None,
reply: None,
results_shown: false,
reframe: None,
@@ -632,6 +646,19 @@ impl ModelApp {
{
self.reactions = Some(v.clone());
}
// The XY plot's series, however it was asked for; the panel opens on it.
if id == "results.xy" && (r.is_ok() || params.get("path").is_none()) {
let xy = self.xy.get_or_insert_with(XyPlot::default);
xy.series = r.as_ref().ok().cloned();
// The picker follows a series asked for elsewhere (the control channel, a script).
if let Some(y) = params.get("y").filter(|_| r.is_ok()) {
let mut template = y.clone();
if let Some(node) = template.as_object_mut().and_then(|o| o.remove("node")) {
xy.node = node.as_u64().and_then(|n| u32::try_from(n).ok());
}
xy.y = Some(template);
}
}
// What the duplicate check found is selected, so the viewport shows it (#234).
if id == "mesh.check.duplicates"
&& let Ok(v) = &r
@@ -1851,6 +1878,7 @@ impl ModelApp {
// The probe and the reaction sums were of the old document's results.
self.probe = None;
self.reactions = None;
self.xy = None;
self.reframe = framing::Reframe::merge(self.reframe, framing::Reframe::Home);
}
}
@@ -2778,6 +2806,39 @@ mod tests {
out.textures_delta.clear();
}
/// XY Plot from the ribbon opens the plot in the results panel, which runs results.xy for
/// its quantity and keeps the series; a nodal field waits for a probed node (#94).
#[test]
fn the_xy_plot_runs_results_xy() {
let ctx = egui::Context::default();
let mut app = ModelApp::new(Session::default());
app.open_sample("impact");
app.run("solve.run", json!({})).unwrap();
app.start("results.xy").unwrap();
assert!(app.dialog.is_none(), "the plot opens in the panel, not a form");
draw(&mut app, &ctx, 1280.0, 800.0);
let s = app.xy.as_ref().and_then(|x| x.series.clone()).expect("the panel ran results.xy");
assert_eq!((s["step"].as_str(), s["x"]["axis"].as_str()), (Some("Impact"), Some("time")));
assert!(s["points"].as_u64().unwrap_or(0) > 1, "{s}");
// A nodal field needs a node: the probed one.
app.xy.as_mut().unwrap().y = Some(json!({"field": "velocity"}));
draw(&mut app, &ctx, 1280.0, 800.0);
assert!(app.xy.as_ref().unwrap().series.is_none());
app.probe_node(1).unwrap();
draw(&mut app, &ctx, 1280.0, 800.0);
let s = app.xy.as_ref().and_then(|x| x.series.clone()).expect("read at the probed node");
assert_eq!(s["frames"].as_array().map(Vec::len), Some(11), "{s}");
// A linear static step offers nothing; a frequency step its frequencies.
let mut app = ModelApp::new(Session::default());
app.open_sample("cantilever");
app.run("solve.run", json!({})).unwrap();
app.run("results.modes", json!({"step": "Modes"})).unwrap();
app.start("results.xy").unwrap();
draw(&mut app, &ctx, 1280.0, 800.0);
let s = app.xy.as_ref().and_then(|x| x.series.clone()).expect("the frequencies");
assert_eq!(s["x"]["axis"], "mode");
}
fn is_iso(cam: &Camera) -> bool {
let (yaw, pitch) = StandardView::Iso.angles();
(cam.yaw - yaw).abs() < 1e-9 && (cam.pitch - pitch).abs() < 1e-9