From 7a078b1c041ac06744a820c3cca903ef18057bf0 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:56:15 -0400 Subject: [PATCH] ui: results show units, clean numbers, extremes, reaction sums and a solve summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/ui-egui/src/lib.rs | 144 ++++++++--- crates/ui-egui/src/reply.rs | 167 ++++++++++++- crates/ui-egui/src/results.rs | 137 +++++++--- crates/ui-egui/src/results_panel.rs | 371 ++++++++++++++++++++++------ docs/control-protocol.md | 2 +- 5 files changed, 675 insertions(+), 146 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dc06d89..1719438 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, } -/// 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, + pub rows: Vec<(String, String)>, pub lines: Vec, } @@ -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, 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, /// 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 = 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 "))); diff --git a/crates/ui-egui/src/reply.rs b/crates/ui-egui/src/reply.rs index 7fe704b..ead1b28 100644 --- a/crates/ui-egui/src/reply.rs +++ b/crates/ui-egui/src/reply.rs @@ -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::>(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::>(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 = 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); diff --git a/crates/ui-egui/src/results.rs b/crates/ui-egui/src/results.rs index 30bbc41..5033888 100644 --- a/crates/ui-egui/src/results.rs +++ b/crates/ui-egui/src/results.rs @@ -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, /// 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>, /// 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) -> 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 { 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, 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, 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::>().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) -> Option { @@ -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"); } } diff --git a/crates/ui-egui/src/results_panel.rs b/crates/ui-egui/src/results_panel.rs index 4fa574b..61032fd 100644 --- a/crates/ui-egui/src/results_panel.rs +++ b/crates/ui-egui/src/results_panel.rs @@ -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::().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| 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)); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 6e00670..3ca738f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -46,7 +46,7 @@ commands (files, undo, `model.info`) leave the ribbon where it is. | Method | Params | Notes | |---|---|---| | `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `solve` (the title bar's Solve: `enabled`, and `unavailable` as `ui.toolbar`'s entries give it, listing everything `solve.run` misses), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `job` (that job as `job.status` gives it), `prompt` (`{"unsaved": path}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"recover"`, `"samples"`), `recent` (recent files), `updates` (docs/updates.md: `automatic`, `checked_at`, `skip`, `available` (a newer version the status bar offers), `busy`, `staged`, `unsupported`; null when the run does not check), `selected` (the browser's selected definition `{kind, name}`, or seed `{kind: "seed", edge}`), `browser` (each top-level browser node's setup state: `[{node, state, tip, add}]`, `state` `done`, `next`, `blocked` (⚠: its command cannot run yet; `tip` says what it needs and where), `todo`, `optional` or null for nodes that are no setup stage, `add` its ghost rows `{stage, label, command, status}`; a ghost row's region is `layout`'s `browser/add/`), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `legend` (while results are shown), `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version | -| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, contact and tie surfaces, rigid walls), `symbol_size` (the setup symbols' size on screen in points, 6–48, at any zoom), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `nav_preset` (the mouse navigation: `fieldcraft`, `fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`; an unknown id acts as `fieldcraft`) and `reverse_zoom` (both remembered per user), `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `/` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings | +| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, contact and tie surfaces, rigid walls), `symbol_size` (the setup symbols' size on screen in points, 6–48, at any zoom), `result_symbols` (the shown step's setup symbols over a results plot; off by default), `result_extremes` (markers at a contour's largest and smallest value), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `nav_preset` (the mouse navigation: `fieldcraft`, `fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`; an unknown id acts as `fieldcraft`) and `reverse_zoom` (both remembered per user), `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `/` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings | | `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | every view frames the model as drawn (the deformed shape included; the setup's symbols keep their size on screen and are not framed) in the part of the viewport the overlays (`layout`'s `legend`, `selection_bar`, `axis_view`, `nav_bar`) leave free; `fit` keeps the direction of view. Opening another model, sample or design frames it in the home view (the same design opened again after an edit keeps the camera), and results that come up with part of the model out of sight are framed. Returns the camera | | `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) | | `ui.toolbar` | — | the ribbon: `workspace`, `workspaces`, `tab`, `tabs` (the workspace's), `menu` (the open menu), `panels` (each panel label's `menu` key and `rect`), and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its related commands), `coming_soon` (the milestone, or null), `enabled` (false while coming soon or while its command misses what it needs), `unavailable` (null, or what its command misses: `reason`, the tooltip's lines such as "Needs a material — Properties › Material", and `missing`, each with `requirement`, `what`, `provider` (the command that provides it) and `place` (its toolbar entry)), `fit` — `full` (a button), `collapsed` (its panel is one button), `more` (its panel is in the » menu) or `menu` (in its panel's drop-down) — and `rect` (screen points, last frame, on the shown tab; for `menu` entries the row while the drop-down is open) |