From 0966dfa2a69279f6fd12bcc0f1de0706bc881a37 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:39:00 -0400 Subject: [PATCH] engine: result units, per-step staleness, step symbols, true-scale nonlinear results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quantity names the unit of every number the results show from the model's unit system (mm, MPa, N, N·mm, mJ, mW, °C or K); Field::title puts it in the legend title, and calls a mode shape normalised (#321). - A solved step's result stays current through edits that leave what it reads alone (Model::step_inputs: other steps and what only they use), and comes back current on undo (#318). - Session::step_glyphs: the symbols of what a solved step used, for drawing over its results; no load arrows on a frequency step's modes (#327). - Nonlinear static steps open deformed at true scale like explicit ones, and their automatic scale is 1 (auto_scale, shared by the viewport and the headless pictures) (#326). - results.reactions with an empty sets list sums the step's boundary-condition sets, as its form says (#328). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/cmd/results.rs | 14 +- crates/engine/src/cmd/solve.rs | 4 +- crates/engine/src/cmd/view.rs | 29 +++- crates/engine/src/lib.rs | 26 +++- crates/engine/src/results.rs | 235 +++++++++++++++++++++++++++++-- crates/engine/src/view.rs | 20 +-- crates/model/src/lib.rs | 2 +- crates/model/src/sets.rs | 25 +++- crates/model/src/steps.rs | 58 +++++++- 9 files changed, 371 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/results.rs b/crates/engine/src/cmd/results.rs index d9712a3..e3770c9 100644 --- a/crates/engine/src/cmd/results.rs +++ b/crates/engine/src/cmd/results.rs @@ -63,7 +63,7 @@ pub const FRAME: CommandSpec = CommandSpec { pub const REACTIONS: CommandSpec = CommandSpec { id: "results.reactions", label: "Reaction Sums", - params: r#"{"step": "Load", "sets": ["root"], "about": [0, 0, 0]} — reaction force and moment (about the point) summed over each node set, and over the whole model; `sets` defaults to the sets the step's boundary conditions hold"#, + params: r#"{"step": "Load", "sets": ["root"], "about": [0, 0, 0]} — reaction force and moment (about the point) summed over each node set, and over the whole model; `sets` (none or empty) defaults to the sets the step's boundary conditions hold"#, form: Some(super::forms::REACTIONS), kind: CommandKind::Session, requires: &[Requirement::Result], @@ -281,7 +281,8 @@ fn reactions(s: &mut Session, p: &Value) -> Result { "step `{step}` is not a static step: reactions are kept for static steps (explicit steps keep the constraint force history, solve.history)" ))); }; - let sets: Vec = match p.get("sets") { + // No sets (or an empty list, a form with none ticked): the step's boundary-condition sets. + let sets: Vec = match p.get("sets").filter(|v| v.as_array().is_none_or(|a| !a.is_empty())) { Some(v) => { let list = v .as_array() @@ -369,6 +370,13 @@ mod tests { assert!(s.execute("results.contour", &json!({"field": "von_mises"})).is_err()); s.execute("results.contour", &json!({"field": "displacement", "bands": 6})).unwrap(); s.execute("results.animate", &json!({})).unwrap(); + // Changing the field keeps the deformation, its scale and the animation (#332). + s.execute("results.deformed", &json!({"scale": 3})).unwrap(); + let r = s.execute("results.contour", &json!({"step": "Modes", "field": "displacement_z"})).unwrap(); + assert_eq!( + (r["view"]["deformed"].as_bool(), r["view"]["scale"].as_f64(), r["view"]["animate"].as_bool()), + (Some(true), Some(3.0), Some(true)) + ); assert!(s.results_view.as_ref().is_some_and(|v| v.animate && v.bands == 6)); s.execute("results.animate", &json!({"on": false})).unwrap(); assert!(s.results_view.as_ref().is_some_and(|v| !v.animate)); @@ -398,6 +406,8 @@ mod tests { let r = s.execute("results.reactions", &json!({"sets": ["root", "tip"], "about": [10, 0, 0]})).unwrap(); assert!(r["sets"][0]["moment"][1].as_f64().unwrap().abs() < 1e-6, "{r}"); assert_eq!(r["sets"][1]["force"][2].as_f64(), Some(0.0)); + let empty = s.execute("results.reactions", &json!({"sets": []})).unwrap(); + assert_eq!(empty["sets"][0]["set"], "root", "an empty list sums the step's boundary-condition sets: {empty}"); assert!(s.execute("results.reactions", &json!({"step": "Modes"})).is_err()); assert!(s.execute("results.reactions", &json!({"sets": ["nope"]})).is_err()); } diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index b29fdbf..7118a8b 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -1397,8 +1397,8 @@ pub(crate) mod tests { s.execute("model.step.create", &json!({"name": "Long", "procedure": long})).unwrap(); assert!(s.execute("solve.explicit", &json!({"step": "Long"})).unwrap_err().to_string().contains("increments")); assert!(s.execute("model.set.delete", &json!({"name": "all_nodes"})).unwrap_err().to_string().contains("step `Impact`")); - // Defining steps edited the model, so the impact result is stale. - assert_eq!(s.solved_steps().count(), 0); + // Defining other steps left what the impact step reads alone: its result is current. + assert_eq!(s.solved_steps().collect::>(), ["Impact"]); } /// A mass on a spring and a dashpot, defined, solved and written through commands; editing diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 287e882..e8bc47d 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -113,16 +113,31 @@ pub(crate) fn forget_missing(s: &mut Session) { impl Session { /// The analysis setup's symbols, without the definitions the view hides. pub fn glyphs(&self) -> fieldcraft_render::glyphs::Glyphs { + self.glyphs_of(None) + } + + /// The symbols of what solved step `step` used, over its results: its own boundary + /// conditions and loads (none for a frequency step, which has no loads) and its own initial + /// velocities and rigid walls; the interactions as always. Without the view's hidden items. + pub fn step_glyphs(&self, step: &str) -> fieldcraft_render::glyphs::Glyphs { + self.glyphs_of(Some(step)) + } + + fn glyphs_of(&self, step: Option<&str>) -> fieldcraft_render::glyphs::Glyphs { + use fieldcraft_model::Procedure; use fieldcraft_render::glyphs::Definition; + let step = step.map(|name| self.model.step(name)); + let frequency = matches!(step, Some(Some(s)) if matches!(s.procedure, Procedure::Frequency { .. })); fieldcraft_render::glyphs::Glyphs::from_model_hiding(&self.model, &|definition, name| { - let kind = match definition { - Definition::BoundaryCondition => ItemKind::BoundaryCondition, - Definition::Load => ItemKind::Load, - Definition::ReferencePoint => ItemKind::ReferencePoint, - Definition::Coupling => ItemKind::Coupling, - Definition::Contact | Definition::Tie | Definition::Step => return false, + let (kind, used) = match definition { + Definition::BoundaryCondition => (ItemKind::BoundaryCondition, step.map(|s| s.is_some_and(|s| s.boundary_conditions.contains(name)))), + Definition::Load => (ItemKind::Load, step.map(|s| !frequency && s.is_some_and(|s| s.loads.contains(name)))), + Definition::ReferencePoint => (ItemKind::ReferencePoint, None), + Definition::Coupling => (ItemKind::Coupling, None), + Definition::Step => return step.is_some_and(|s| s.is_none_or(|s| s.name != name)), + Definition::Contact | Definition::Tie => return false, }; - self.view.is_hidden(kind, name) + used == Some(false) || self.view.is_hidden(kind, name) }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3ebcf90..cd903a6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -247,6 +247,9 @@ pub struct Session { /// The latest result of each solved step, by step name, with the revision it was solved at; /// [`Session::result`] hands out only those that describe the current state. results: BTreeMap)>, + /// What each solved step read ([`results::inputs_fingerprint`]): an edit that leaves it the + /// same keeps the step's result current. + result_inputs: BTreeMap, /// What the results display shows (`results.*`); `None` shows the model. pub results_view: Option, /// The default work caps of commands; a file replacing the session keeps them. @@ -331,6 +334,9 @@ impl Session { cmd::view::forget_missing(self); } } + if self.revision != revision { + self.refresh_results(); + } drop(scope); crash::command_done(self, journaled, self.revision != revision || self.opened != opened, id, params, &result); result @@ -396,7 +402,25 @@ impl Session { } Some(_) => {} } - self.results.insert(result.step().to_string(), (self.revision, Arc::new(result))); + let step = result.step().to_string(); + match results::inputs_fingerprint(&self.model, &step) { + Some(f) => self.result_inputs.insert(step.clone(), f), + None => self.result_inputs.remove(&step), + }; + self.results.insert(step, (self.revision, Arc::new(result))); + } + + /// After the state changed (an edit, undo or redo): a result whose step reads what it read + /// when it was solved describes the new state too, so it stays (or is again) current; the + /// others go stale. + fn refresh_results(&mut self) { + let revision = self.revision; + for (step, (solved, _)) in self.results.iter_mut().filter(|(_, (r, _))| *r != revision) { + let read = self.result_inputs.get(step).copied(); + if read.is_some() && read == results::inputs_fingerprint(&self.model, step) { + *solved = revision; + } + } } /// The path to use for `access` to the file at `path`, checked against [`Session::file_access`]. diff --git a/crates/engine/src/results.rs b/crates/engine/src/results.rs index 8274a5b..223b085 100644 --- a/crates/engine/src/results.rs +++ b/crates/engine/src/results.rs @@ -8,11 +8,12 @@ //! earlier revision is still shown, marked stale ([`Session::latest_result`]). use fieldcraft_explicit::{ExplicitResult, Frame}; -use fieldcraft_model::Model; +use fieldcraft_model::{Model, UnitSystem}; use fieldcraft_solver::{FrequencyResult, HeatResult, StaticResult}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use crate::schema::Dim; use crate::{EngineError, Result, Session, StepResult}; /// A result by its procedure. @@ -182,6 +183,49 @@ impl Field { } } + /// What it measures. + pub fn quantity(self) -> Quantity { + match self { + Field::Displacement | Field::DisplacementX | Field::DisplacementY | Field::DisplacementZ | Field::ContactSlip => Quantity::Length, + Field::Velocity => Quantity::Velocity, + Field::Temperature => Quantity::Temperature, + Field::HeatFlux => Quantity::HeatFlux, + Field::ContactGap | Field::ContactStatus => Quantity::None, + Field::ContactPressure + | Field::ContactShear + | Field::VonMises + | Field::MaxPrincipal + | Field::MidPrincipal + | Field::MinPrincipal + | Field::StressXx + | Field::StressYy + | Field::StressZz + | Field::StressXy + | Field::StressXz + | Field::StressYz => Quantity::Stress, + } + } + + /// Whether `r` holds it as a mode shape: normalised, with no unit. + pub fn is_mode_shape(self, r: &StepResult) -> bool { + matches!(r.kind(), Kind::Frequency(_)) && self.quantity() == Quantity::Length + } + + /// Its unit for result `r` in `system`: none for a mode shape (it is normalised). + pub fn unit(self, r: &StepResult, system: UnitSystem) -> Option { + if self.is_mode_shape(r) { None } else { self.quantity().unit(system) } + } + + /// Its title over result `r`: the label with its unit (`Von Mises stress [MPa]`), or, for + /// a mode shape, saying it is normalised. + pub fn title(self, r: &StepResult, system: UnitSystem) -> String { + if self.is_mode_shape(r) { + let label = self.label().trim_end_matches(" magnitude"); + return format!("{label} (mode shape, normalised)"); + } + with_unit(self.label(), self.unit(r, system).as_deref()) + } + pub fn parse(id: &str) -> Option { Field::ALL.into_iter().find(|f| f.id() == id) } @@ -238,6 +282,76 @@ impl Field { } } +/// What a number the results show measures: its unit follows the model's unit system +/// ([`Quantity::unit`]). The legend, the probe, the reaction sums and the solve summary all name +/// their units through it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Quantity { + /// No unit (a ratio, a code, a normalised value). + None, + Length, + Stress, + Force, + Moment, + Mass, + Time, + Velocity, + Temperature, + /// Energy (work): force × length. + Energy, + /// Heat flow (power): energy per time. + HeatFlow, + /// Heat flux: heat flow per area. + HeatFlux, +} + +impl Quantity { + /// Its unit in `system` (`mm`, `MPa`, `N·mm`, `mW`…), or `None` (no unit, or the system is + /// unspecified). The model never converts, so this only names the system's own unit; a + /// temperature is in whatever scale its values were entered in. + pub fn unit(self, system: UnitSystem) -> Option { + let dim = match self { + Quantity::None => return None, + Quantity::Length => Dim::Length, + Quantity::Stress => Dim::Stress, + Quantity::Force => Dim::Force, + Quantity::Moment => Dim::Moment, + Quantity::Mass => Dim::Mass, + Quantity::Time => Dim::Time, + Quantity::Velocity => Dim::Velocity, + Quantity::Temperature | Quantity::Energy | Quantity::HeatFlow | Quantity::HeatFlux => { + // Named units where the system's force × length (per time) has one. + let (energy, power, flux) = match system { + UnitSystem::Unspecified => return None, + UnitSystem::MKgS => ("J", "W", "W/m²"), + UnitSystem::MmTonneS => ("mJ", "mW", "mW/mm²"), + UnitSystem::MmKgMs => ("J", "kW", "kW/mm²"), + UnitSystem::MmGMs => ("mJ", "W", "W/mm²"), + UnitSystem::InLbfS => ("lbf·in", "lbf·in/s", "lbf/(in·s)"), + }; + return Some( + match self { + Quantity::Energy => energy, + Quantity::HeatFlow => power, + Quantity::HeatFlux => flux, + _ => "°C or K", + } + .into(), + ); + } + }; + dim.unit(system) + } +} + +/// `label` with `unit` in brackets when there is one: `Von Mises stress [MPa]`. +pub fn with_unit(label: &str, unit: Option<&str>) -> String { + match unit { + Some(u) => format!("{label} [{u}]"), + None => label.to_string(), + } +} + /// Most contour bands. pub const MAX_BANDS: u32 = 24; /// Fewest contour bands. @@ -268,11 +382,12 @@ pub struct ResultsView { } impl ResultsView { - /// How a step's result is shown first: a static step undeformed with von Mises contours, so - /// they are read on the true geometry (`results.deformed` shows the deformed shape, at the - /// automatic scale); a frequency step's first mode deformed with displacement contours (a - /// mode shape is a deformation); an explicit step at its last frame, deformed to true scale - /// (with von Mises contours when it kept stresses); a heat step undeformed. + /// How a step's result is shown first: a linear static step undeformed with von Mises + /// contours, so they are read on the true geometry (`results.deformed` shows the deformed + /// shape, at the automatic scale); a frequency step's first mode deformed with displacement + /// contours (a mode shape is a deformation); an explicit step at its last frame and a + /// nonlinear static step at its last increment, deformed to true scale ([`true_scale`]; + /// with von Mises contours when they kept stresses); a heat step undeformed. pub fn for_result(r: &StepResult) -> ResultsView { let heat = matches!(r.kind(), Kind::Heat(_)); let contour = if heat { @@ -282,11 +397,11 @@ impl ResultsView { } else { Field::Displacement }; - let scale = matches!(r.kind(), Kind::Explicit(_)).then_some(1.0); + let true_scale = true_scale(r); ResultsView { step: r.step().to_string(), - deformed: matches!(r.kind(), Kind::Frequency(_) | Kind::Explicit(_)), - scale, + deformed: true_scale || matches!(r.kind(), Kind::Frequency(_)), + scale: true_scale.then_some(1.0), contour: Some(contour), bands: DEFAULT_BANDS, mode: 1, @@ -300,6 +415,33 @@ impl ResultsView { } } +/// The automatic deformation scale draws the largest displacement this fraction of the model's +/// size. +pub const AUTO_FRACTION: f64 = 0.1; + +/// Whether a result's deformation is drawn at true scale even when the scale is automatic: an +/// explicit step's and a nonlinear static step's displacements are already large (finite strain, +/// contact), and magnifying them draws the part crushed through its neighbours. Linear static, +/// frequency and buckling results are magnified. +pub fn true_scale(r: &StepResult) -> bool { + match r.kind() { + Kind::Explicit(_) => true, + Kind::Static(s) => s.nonlinear.is_some(), + Kind::Frequency(_) | Kind::Fluid(_) | Kind::Heat(_) => false, + } +} + +/// The automatic deformation scale of `r` at `pick` on a model `size` across (its bounding box +/// diagonal): 1 for a [`true_scale`] result, else the factor that draws the largest +/// displacement [`AUTO_FRACTION`] of the size; 0 when nothing moves. +pub fn auto_scale(r: &StepResult, pick: Pick, size: f64) -> f64 { + if true_scale(r) { + return 1.0; + } + let largest = displacements(r, pick).map_or(0.0, |u| u.iter().map(|v| magnitude(*v)).filter(|m| m.is_finite()).fold(0.0, f64::max)); + if largest > 0.0 && size.is_finite() && size > 0.0 { AUTO_FRACTION * size / largest } else { 0.0 } +} + /// The nodal displacements a result's deformed shape uses: the static displacement, the shape /// of the picked mode or the picked frame's displacement, in [`StepResult::nodes`] order. pub fn displacements(r: &StepResult, pick: Pick) -> Result<&[[f64; 3]]> { @@ -458,6 +600,27 @@ pub fn range(labels: &[u32], values: &[f64]) -> Option<((u32, f64), (u32, f64))> out } +/// A fingerprint of what solving step `step` of `model` reads ([`Model::step_inputs`]), or +/// `None` when there is no such step. +pub(crate) fn inputs_fingerprint(model: &Model, step: &str) -> Option { + use std::hash::Hasher; + /// Hashes what is written to it. + struct HashWriter(std::hash::DefaultHasher); + impl std::io::Write for HashWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.write(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let inputs = model.step_inputs(step)?; + let mut h = HashWriter(std::hash::DefaultHasher::new()); + serde_json::to_writer(&mut h, &inputs).ok()?; + Some(h.0.finish()) +} + pub fn magnitude(v: [f64; 3]) -> f64 { (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt() } @@ -481,7 +644,7 @@ impl Session { { let ends = range(r.nodes(), &values).map(|((ln, lv), (hn, hv))| json!({"min": {"node": ln, "value": lv}, "max": {"node": hn, "value": hv}})); - out["contour"] = json!({"field": f, "label": f.label(), "range": ends}); + out["contour"] = json!({"field": f, "label": f.label(), "unit": f.unit(r, self.model.units()), "range": ends}); } match r.kind() { Kind::Frequency(fr) => { @@ -574,6 +737,58 @@ mod tests { assert_eq!(s.result_steps().collect::>(), vec![("Bend", false), ("Modes", false)]); } + /// A step's result goes stale only when something it reads changes (#318); undoing the + /// change makes it current again. + #[test] + fn unrelated_edits_keep_results_current() { + let mut s = Session::default(); + cantilever(&mut s); + s.execute("solve.static", &json!({})).unwrap(); + s.execute("solve.frequency", &json!({})).unwrap(); + // A set, a boundary condition and a step that neither solved step uses. + s.execute("model.set.create", &json!({"name": "extra", "nodes": [1]})).unwrap(); + s.execute("model.boundary_condition.create", &json!({"name": "X", "type": "fixed", "target": {"set": "extra"}})).unwrap(); + s.execute("model.step.create", &json!({"name": "Other", "procedure": {"type": "static"}, "boundary_conditions": ["X"]})).unwrap(); + assert_eq!(s.result_steps().collect::>(), vec![("Bend", true), ("Modes", true)]); + // The load only Bend lists: Bend goes stale, Modes (no loads) stays current. + s.execute("model.load.edit", &json!({"name": "push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -2]})).unwrap(); + assert_eq!(s.result_steps().collect::>(), vec![("Bend", false), ("Modes", true)]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.result_steps().collect::>(), vec![("Bend", true), ("Modes", true)]); + // The clamp's set, which both use. + s.execute("model.set.edit", &json!({"name": "root", "nodes": [1, 12]})).unwrap(); + assert_eq!(s.result_steps().collect::>(), vec![("Bend", false), ("Modes", false)]); + s.execute("edit.undo", &json!({})).unwrap(); + // A step's own definition. + s.execute("model.step.edit", &json!({"name": "Modes", "procedure": {"type": "frequency", "modes": 3}, "boundary_conditions": ["clamp"]})) + .unwrap(); + assert_eq!(s.result_steps().collect::>(), vec![("Bend", true), ("Modes", false)]); + } + + #[test] + fn units_follow_the_unit_system() { + let mut s = Session::default(); + cantilever(&mut s); + s.execute("solve.static", &json!({})).unwrap(); + s.execute("solve.frequency", &json!({})).unwrap(); + let mm = UnitSystem::MmTonneS; + let (bend, _) = s.latest_result("Bend").unwrap(); + let (modes, _) = s.latest_result("Modes").unwrap(); + assert_eq!(Field::VonMises.title(bend, mm), "Von Mises stress [MPa]"); + assert_eq!(Field::Displacement.title(bend, mm), "Displacement magnitude [mm]"); + assert_eq!(Field::Displacement.title(modes, mm), "Displacement (mode shape, normalised)"); + assert_eq!(Field::DisplacementZ.title(modes, mm), "Displacement Z (mode shape, normalised)"); + assert_eq!(Field::VonMises.title(bend, UnitSystem::Unspecified), "Von Mises stress"); + assert_eq!(Field::ContactStatus.unit(bend, mm), None); + let unit = |q: Quantity, u: UnitSystem| q.unit(u); + assert_eq!(unit(Quantity::HeatFlow, mm).as_deref(), Some("mW")); + assert_eq!(unit(Quantity::Energy, mm).as_deref(), Some("mJ")); + assert_eq!(unit(Quantity::Moment, mm).as_deref(), Some("N·mm")); + assert_eq!(unit(Quantity::Energy, UnitSystem::MKgS).as_deref(), Some("J")); + assert_eq!(unit(Quantity::Temperature, UnitSystem::Unspecified), None); + assert_eq!(unit(Quantity::None, mm), None); + } + #[test] fn field_ids_round_trip() { for f in Field::ALL { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 306843b..9eecb8b 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -8,7 +8,8 @@ //! deformed shape (automatic or chosen scale) with its contour bands. Animation is not drawn: a //! picture shows the full deformation (a mode at its positive shape). The analysis setup's //! symbols ([`fieldcraft_render::glyphs`]) are drawn over the model as the viewport draws them, -//! muted over a results plot, at their default size on screen once the camera is known. +//! muted over a results plot (and only those its step used), at their default size on screen +//! once the camera is known. use fieldcraft_model::ElementKind; use fieldcraft_render::contour::{Bands, band_color, split_triangle}; @@ -16,7 +17,7 @@ use fieldcraft_render::glyphs::{self, GlyphColors, GlyphShow, GlyphView, Glyphs} use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, SceneTri, Vec3}; use serde_json::Value; -use crate::results::{displacements, field_values, magnitude, range}; +use crate::results::{auto_scale, displacements, field_values, range}; use crate::{EngineError, Result, Session}; /// The colours of a picture. @@ -93,8 +94,6 @@ impl Palette { const EDGE_WIDTH: f64 = 1.0; const GEOMETRY_EDGE_WIDTH: f64 = 1.5; const LINE_ELEMENT_WIDTH: f64 = 3.0; -/// The automatic scale draws the largest displacement this fraction of the model's size. -const AUTO_FRACTION: f64 = 0.1; /// The chordal tolerance geometry is tessellated with for display: a thousandth of its size. fn display_tolerance(b: &Aabb3) -> f64 { @@ -182,9 +181,10 @@ impl Picture { fn picture(s: &Session, p: &Palette) -> Picture { let (base, failed) = display(s); - let glyphs = s.glyphs(); let mut shown = Shown { failed, ..Shown::default() }; let plot = s.results_view.as_ref().and_then(|v| s.latest_result(&v.step).map(|(r, _)| (v, r))); + // Over a results plot, the symbols of what its step used. + let glyphs = plot.map_or_else(|| s.glyphs(), |(v, _)| s.step_glyphs(&v.step)); let Some((view, result)) = plot else { let mut scene = model_scene(&base, p); let bounds = base.bounds; @@ -196,15 +196,7 @@ fn picture(s: &Session, p: &Palette) -> Picture { let pick = view.pick(); let nodes = result.nodes(); let u = displacements(result, pick).unwrap_or(&[]); - let scale = if !view.deformed { - 0.0 - } else if let Some(k) = view.scale { - k - } else { - let largest = 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 } - }; + let scale = if !view.deformed { 0.0 } else { view.scale.unwrap_or_else(|| auto_scale(result, pick, base.bounds.diagonal())) }; shown.scale = scale; let drawn = base.displaced(|label| { let i = nodes.binary_search(&label).ok()?; diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 9e2ea88..d7b1758 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -75,7 +75,7 @@ pub use sets::Set; pub use steps::{ ArcConstraint, ArcLength, BulkViscosity, DisplacementLimit, ExplicitDynamic, Hourglass, HourglassKind, Imperfection, InitialVelocity, MAX_LOAD_PATH_POINTS, MAX_NONLINEAR_INCREMENTS, MAX_NONLINEAR_ITERATIONS, MassScaling, NonlinearStatic, OutputVariable, Procedure, RigidWall, - Step, StressRate, Tolerances, Transient, + Step, StepInputs, StressRate, Tolerances, Transient, }; pub use tie::Tie; pub use units::UnitSystem; diff --git a/crates/model/src/sets.rs b/crates/model/src/sets.rs index b0d4bbc..2b38cd2 100644 --- a/crates/model/src/sets.rs +++ b/crates/model/src/sets.rs @@ -135,6 +135,18 @@ impl Model { /// The definitions that refer to a set, as `kind \`name\`` strings. pub fn set_users(&self, set: &str) -> Vec { + let mut users = self.set_model_users(set); + users + .extend(self.boundary_conditions.iter().filter(|(_, b)| b.target().set() == Some(set)).map(|(n, _)| format!("boundary condition `{n}`"))); + users.extend(self.loads.iter().filter(|(_, l)| l.set() == Some(set)).map(|(n, _)| format!("load `{n}`"))); + users.extend(self.steps.iter().filter(|s| s.procedure.sets().any(|x| x == set)).map(|s| format!("step `{}`", s.name))); + users + } + + /// The users of a set that act in every step (sections, couplings, contacts, ties, point + /// masses and springs): all but boundary conditions, loads and steps, which act only in the + /// steps that list them ([`Model::step_inputs`]). + fn set_model_users(&self, set: &str) -> Vec { let mut users = Vec::new(); users.extend(self.sections.iter().filter(|(_, s)| s.set == set).map(|(n, _)| format!("section `{n}`"))); users.extend(self.couplings.iter().filter(|(_, c)| c.set == set).map(|(n, _)| format!("coupling `{n}`"))); @@ -142,10 +154,15 @@ impl Model { users.extend(self.tie_users(set)); users.extend(self.discrete_users(|t| t.set() == Some(set))); users - .extend(self.boundary_conditions.iter().filter(|(_, b)| b.target().set() == Some(set)).map(|(n, _)| format!("boundary condition `{n}`"))); - users.extend(self.loads.iter().filter(|(_, l)| l.set() == Some(set)).map(|(n, _)| format!("load `{n}`"))); - users.extend(self.steps.iter().filter(|s| s.procedure.sets().any(|x| x == set)).map(|s| format!("step `{}`", s.name))); - users + } + + /// Whether solving `step` reads set `set`: a definition acting in every step refers to it, + /// or one of the step's own boundary conditions and loads, or its procedure. + pub(crate) fn step_uses_set(&self, step: &crate::Step, set: &str) -> bool { + !self.set_model_users(set).is_empty() + || step.boundary_conditions.iter().any(|n| self.boundary_conditions.get(n).is_some_and(|b| b.target().set() == Some(set))) + || step.loads.iter().any(|n| self.loads.get(n).is_some_and(|l| l.set() == Some(set))) + || step.procedure.sets().any(|x| x == set) } /// Resolve one set against the current mesh. The error names every member that is not on diff --git a/crates/model/src/steps.rs b/crates/model/src/steps.rs index ae456d3..a56830d 100644 --- a/crates/model/src/steps.rs +++ b/crates/model/src/steps.rs @@ -6,7 +6,7 @@ //! before the first step is the unloaded, unconstrained model; a boundary condition active in //! every step can be written at model level by formats that have one. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; @@ -834,7 +834,63 @@ impl Step { } } +/// What solving one step reads from a [`Model`] ([`Model::step_inputs`]), borrowed: two models +/// whose step inputs serialize the same give the step the same result. +#[derive(Debug, Serialize)] +pub struct StepInputs<'a> { + nodes: &'a BTreeMap, + elements: &'a BTreeMap, + classification: &'a crate::Classification, + units: crate::UnitSystem, + sets: BTreeMap<&'a str, &'a crate::Set>, + materials: &'a BTreeMap, + sections: &'a BTreeMap, + reference_points: &'a BTreeMap, + couplings: &'a BTreeMap, + contacts: &'a BTreeMap, + ties: &'a BTreeMap, + masses: &'a BTreeMap, + springs: &'a BTreeMap, + boundary_conditions: BTreeMap<&'a str, &'a crate::BoundaryCondition>, + loads: BTreeMap<&'a str, &'a crate::Load>, + fluid_domains: &'a BTreeMap, + step: &'a Step, +} + impl Model { + /// What solving step `name` reads: the mesh, the materials, sections, interactions and every + /// definition that acts in all steps, the step itself and the boundary conditions, loads and + /// sets it uses. Other steps and what only they use are left out (steps are solved each on + /// its own), and so are the meshing inputs (datums, partitions, seeds: the mesh they made is + /// in). `None` when there is no such step. + pub fn step_inputs(&self, name: &str) -> Option> { + let step = self.step(name)?; + Some(StepInputs { + nodes: &self.nodes, + elements: &self.elements, + classification: &self.classification, + units: self.units, + sets: self.sets.iter().filter(|(n, _)| self.step_uses_set(step, n)).map(|(n, s)| (n.as_str(), s)).collect(), + materials: &self.materials, + sections: &self.sections, + reference_points: &self.reference_points, + couplings: &self.couplings, + contacts: &self.contacts, + ties: &self.ties, + masses: &self.masses, + springs: &self.springs, + boundary_conditions: self + .boundary_conditions + .iter() + .filter(|(n, _)| step.boundary_conditions.contains(*n)) + .map(|(n, b)| (n.as_str(), b)) + .collect(), + loads: self.loads.iter().filter(|(n, _)| step.loads.contains(*n)).map(|(n, l)| (n.as_str(), l)).collect(), + fluid_domains: &self.fluid_domains, + step, + }) + } + /// The steps in analysis order. pub fn steps(&self) -> &[Step] { &self.steps