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)
})
}