forked from 1b-agent/fieldcraft-upstream
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:
1 parent
a09e3c856b
commit
0966dfa2a6
9 files changed
+371
-42
No files matched your search
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user