engine: result units, per-step staleness, step symbols, true-scale nonlinear results

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 09:36:54 -04:00
1 parent a09e3c856b
commit 0966dfa2a6
9 files changed
+371 -42

No files matched your search

+12 -2
View File
@@ -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<Value> {
"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<String> = match p.get("sets") {
// No sets (or an empty list, a form with none ticked): the step's boundary-condition sets.
let sets: Vec<String> = 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());
}
+2 -2
View File
@@ -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::<Vec<_>>(), ["Impact"]);
}
/// A mass on a spring and a dashpot, defined, solved and written through commands; editing
+22 -7
View File
@@ -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)
})
}
+25 -1
View File
@@ -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<String, (u64, Arc<StepResult>)>,
/// What each solved step read ([`results::inputs_fingerprint`]): an edit that leaves it the
/// same keeps the step's result current.
result_inputs: BTreeMap<String, u64>,
/// What the results display shows (`results.*`); `None` shows the model.
pub results_view: Option<results::ResultsView>,
/// 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`].
+225 -10
View File
@@ -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<String> {
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> {
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<String> {
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<u64> {
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<usize> {
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<_>>(), 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<_>>(), 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<_>>(), vec![("Bend", false), ("Modes", true)]);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.result_steps().collect::<Vec<_>>(), 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<_>>(), 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<_>>(), 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 {
+6 -14
View File
@@ -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()?;
+1 -1
View File
@@ -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;
+21 -4
View File
@@ -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<String> {
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<String> {
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
+57 -1
View File
@@ -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<u32, crate::Node>,
elements: &'a BTreeMap<u32, crate::Element>,
classification: &'a crate::Classification,
units: crate::UnitSystem,
sets: BTreeMap<&'a str, &'a crate::Set>,
materials: &'a BTreeMap<String, crate::Material>,
sections: &'a BTreeMap<String, crate::Section>,
reference_points: &'a BTreeMap<String, crate::ReferencePoint>,
couplings: &'a BTreeMap<String, crate::Coupling>,
contacts: &'a BTreeMap<String, crate::Contact>,
ties: &'a BTreeMap<String, crate::Tie>,
masses: &'a BTreeMap<String, crate::PointMass>,
springs: &'a BTreeMap<String, crate::Spring>,
boundary_conditions: BTreeMap<&'a str, &'a crate::BoundaryCondition>,
loads: BTreeMap<&'a str, &'a crate::Load>,
fluid_domains: &'a BTreeMap<String, crate::FluidDomain>,
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<StepInputs<'_>> {
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