Results display (M4) and offscreen full-UI capture (#28)

* ui.capture: the whole window drawn offscreen and rasterised on the CPU

ui.screenshot falls back to it when no frame is presented (hidden window,
locked screen), so agents can look at panels and the toolbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Draw the open design and imported surfaces in the viewport

DisplayMesh keeps node labels on every element primitive (for results),
gains add_surface (geometry faces and their boundaries, coincident points
welded) and displaced (elements moved by nodal offsets). Bodies are
tessellated at a thousandth of their size; meshed models show the geometry
as boundaries only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* results.* commands: deformed shape, contours, mode shapes, animation, reaction sums

The results display is session state (ResultsView), set by commands that are
not edits. Nodal fields (displacement, von Mises, principal and component
stresses; principal stresses in closed form after Smith 1961) and reaction
force and moment sums per set. Results kept from an earlier revision stay
viewable, marked stale; solve.probe says when a result is stale. The Results
tab's display and reaction entries are ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Results display: deformed shape, contours with legend, mode shapes and animation, probe, reaction sums

The viewport draws the session's results view: elements moved by their nodes'
displacements or a mode shape (automatic or given scale), faces split exactly
along contour band boundaries into flat-coloured pieces (same on GPU and CPU),
a legend with levels and extremes, a results panel whose controls run the
results.* commands, a click probing the nearest node (solve.probe), and stale
results marked. Solving from the form shows the result on the Results tab.
sample::cantilever is a solvable model for tests and demos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Simplify the results display (review)

One Contour in the plot; the results scene reuses scene::build; a solve shows
its result through the engine (ResultsView::for_result) and the UI raises the
Results tab when a view appears; the plot's scale and contour values are kept
across animation frames; design bodies are tessellated once per design; the
first content is framed where the display is rebuilt; field_values in one
match; probe reuses results::displacements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* Results display for explicit steps: field frames, energy histories

The results view works on the engine's step results (static, frequency,
explicit): ResultsView.frame and results.frame {frame | time} pick an explicit
field frame; the deformed shape (true scale) and contours (element stresses
averaged to the nodes, velocity) follow it; animation runs through the frames.
The Results panel has a frame slider and plots the kinetic, internal, hourglass
and external energies with their values at the frame. sample::impact is a
steel bar flying onto a rigid wall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs/features.md after the rebase

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 19:23:05 -04:00
1 parent 1899ecbd2f
commit 0e97f8dd1d
30 files changed
+2989 -86

No files matched your search

+13 -6
View File
@@ -309,19 +309,26 @@ pub static CATALOGUE: &[Capability] = &[
soon("solve.flow", "Lattice Boltzmann", Solve, "Fluids", "M12", "Lattice Boltzmann flow on the CPU or the GPU"),
soon("solve.fsi", "Fluid–Structure", Solve, "Fluids", "M13", "Coupled fluid and structure solve"),
// ---- Results ----
soon("results.deformed", "Deformed", Results, "Display", "M4", "The deformed shape"),
soon("results.contour", "Contours", Results, "Display", "M4", "Contour plots of stress, strain and displacement"),
soon("results.modes", "Mode Shapes", Results, "Display", "M4", "Mode shapes of a frequency step"),
soon("results.animate", "Animate", Results, "Display", "M9", "Animate modes and time histories"),
ready_more("results.deformed", "Deformed", Results, "Display", "The deformed shape of a solved step, to scale", &["results.clear"]),
ready("results.contour", "Contours", Results, "Display", "Contours of displacement and stress, with a legend"),
ready("results.modes", "Mode Shapes", Results, "Display", "Mode shapes and frequencies of a frequency step"),
ready_more(
"results.animate",
"Animate",
Results,
"Display",
"Animate the deformed shape, a mode or an explicit step's frames",
&["results.frame"],
),
ready_more(
"solve.probe",
"Probe",
Results,
"Query",
"Displacement, reaction and stress at nodes, a mode shape, or an explicit field frame",
"Displacement, reaction and stress at nodes, a mode shape, or an explicit field frame (or click the model)",
&["solve.history"],
),
soon("results.reactions", "Reactions", Results, "Query", "M4", "Reaction force and moment sums"),
ready("results.reactions", "Reactions", Results, "Query", "Reaction force and moment sums per set"),
soon("results.free_body", "Free Body", Results, "Query", "M9", "Free-body forces across a cut"),
soon("results.xy", "XY Plot", Results, "Plots", "M9", "Plot a quantity against time or another"),
soon("results.path", "Path Plot", Results, "Plots", "M9", "Plot a quantity along a path"),
+9 -1
View File
@@ -7,7 +7,8 @@ pub(crate) mod file;
mod geometry;
mod mesh;
mod model;
mod solve;
mod results;
pub(crate) mod solve;
mod tags;
use modelcraft_model::MAX_LABEL;
@@ -75,6 +76,13 @@ pub static ALL: &[CommandSpec] = &[
solve::EXPLICIT,
solve::HISTORY,
solve::PROBE,
results::DEFORMED,
results::CONTOUR,
results::MODES,
results::ANIMATE,
results::FRAME,
results::REACTIONS,
results::CLEAR,
];
/// A required label parameter (1..=u32::MAX; the model checks its own range).
+455
View File
@@ -0,0 +1,455 @@
//! `results.*`: what the results display shows (deformed shape, contours, mode shapes,
//! animation) and reaction sums. The display is session state ([`ResultsView`]): these
//! commands are not edits, so they are not journalled or undone.
use serde_json::{Value, json};
use super::{opt, xyz};
use crate::results::{Field, Kind, MAX_BANDS, MIN_BANDS, ResultsView};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
pub const DEFORMED: CommandSpec = CommandSpec {
id: "results.deformed",
label: "Deformed Shape",
params: r#"{"step": "Load", "show": true, "scale": "auto"} — shows a solved step's deformed shape (`show: false` the undeformed mesh); `scale` is a factor or "auto" (the largest displacement drawn a tenth of the model's size); `step` defaults to the step shown or the first solved one"#,
kind: CommandKind::Session,
run: deformed,
};
pub const CONTOUR: CommandSpec = CommandSpec {
id: "results.contour",
label: "Contours",
params: r#"{"step": "Load", "field": "von_mises", "bands": 12} — contours of a nodal field: displacement, displacement_x/y/z, von_mises, max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz (stresses for static steps); "none" turns them off; 2 to 24 bands"#,
kind: CommandKind::Session,
run: contour,
};
pub const MODES: CommandSpec = CommandSpec {
id: "results.modes",
label: "Mode Shapes",
params: r#"{"step": "Modes", "mode": 1} — shows a mode shape of a solved frequency step (from 1) and lists every mode's frequency"#,
kind: CommandKind::Session,
run: modes,
};
pub const ANIMATE: CommandSpec = CommandSpec {
id: "results.animate",
label: "Animate",
params: r#"{"on": true} — animates the deformed shape: a mode swings through its positive and negative shape, a static step grows from undeformed to deformed"#,
kind: CommandKind::Session,
run: animate,
};
pub const FRAME: CommandSpec = CommandSpec {
id: "results.frame",
label: "Field Frame",
params: r#"{"step": "Impact", "frame": 3, "time": 0.002} — shows a field frame of a solved explicit step, by index from 0 or the frame nearest a time (neither: the last); the deformed shape and contours follow it"#,
kind: CommandKind::Session,
run: frame,
};
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"#,
kind: CommandKind::Session,
run: reactions,
};
pub const CLEAR: CommandSpec = CommandSpec {
id: "results.clear",
label: "Hide Results",
params: "{} — back to the model: hides the deformed shape, contours and modes",
kind: CommandKind::Session,
run: clear,
};
/// Most sets one reaction sum lists.
const MAX_SETS: usize = 1000;
/// Largest deformation scale factor.
const MAX_SCALE: f64 = 1e12;
fn solved_list(s: &Session) -> String {
let names: Vec<&str> = s.result_steps().map(|(n, _)| n).collect();
if names.is_empty() { "no step is solved yet (solve.static, solve.frequency)".into() } else { format!("solved steps: {}", names.join(", ")) }
}
/// The steps a command works on.
#[derive(Clone, Copy, PartialEq)]
enum Want {
Any,
Frequency,
Explicit,
}
impl Want {
fn fits(self, s: &Session, step: &str) -> bool {
s.latest_result(step).is_some_and(|(r, _)| match (self, r.kind()) {
(Want::Any, _) | (Want::Frequency, Kind::Frequency(_)) | (Want::Explicit, Kind::Explicit(_)) => true,
(Want::Frequency | Want::Explicit, _) => false,
})
}
fn name(self) -> &'static str {
match self {
Want::Any => "solved",
Want::Frequency => "frequency",
Want::Explicit => "explicit",
}
}
}
/// The step a command works on: `step`, else the step shown, else the first solved one
/// (current results first) of the kind it wants.
fn step_for(s: &Session, p: &Value, want: Want) -> Result<String> {
if let Some(name) = opt::<String>(p, "step")? {
if s.latest_result(&name).is_none() {
return Err(EngineError::Params(format!("step `{name}` has no result; {}", solved_list(s))));
}
if !want.fits(s, &name) {
return Err(EngineError::Params(format!("step `{name}` is not a {} step", want.name())));
}
return Ok(name);
}
if let Some(v) = &s.results_view
&& want.fits(s, &v.step)
{
return Ok(v.step.clone());
}
let mut steps: Vec<(&str, bool)> = s.result_steps().filter(|(n, _)| want.fits(s, n)).collect();
// Current results first, then by name (stable).
steps.sort_by_key(|(_, current)| !*current);
steps.first().map(|(n, _)| n.to_string()).ok_or_else(|| {
EngineError::Params(if want == Want::Any { solved_list(s) } else { format!("no {} step has a result; {}", want.name(), solved_list(s)) })
})
}
/// The view of `step`: the one shown, or the step's first look ([`ResultsView::for_result`])
/// when another step (or none) is shown.
fn view_of<'a>(s: &'a mut Session, step: &str) -> Result<&'a mut ResultsView> {
if s.results_view.as_ref().is_none_or(|v| v.step != step) {
let fresh = s.latest_result(step).map(|(r, _)| ResultsView::for_result(r)).ok_or_else(|| EngineError::Params(solved_list(s)))?;
s.results_view = Some(fresh);
}
s.results_view.as_mut().ok_or_else(|| EngineError::Params("no results are shown".into()))
}
fn deformed(s: &mut Session, p: &Value) -> Result<Value> {
let step = step_for(s, p, Want::Any)?;
let show = opt::<bool>(p, "show")?.unwrap_or(true);
let scale = match p.get("scale") {
None => None,
Some(Value::String(a)) if a == "auto" => Some(None),
Some(v) => Some(Some(
v.as_f64()
.filter(|x| x.is_finite() && *x > 0.0 && *x <= MAX_SCALE)
.ok_or_else(|| EngineError::Params(format!("`scale` must be \"auto\" or a factor above 0 and at most {MAX_SCALE}")))?,
)),
};
let v = view_of(s, &step)?;
v.deformed = show;
if let Some(scale) = scale {
v.scale = scale;
}
Ok(s.results_report())
}
fn contour(s: &mut Session, p: &Value) -> Result<Value> {
let step = step_for(s, p, Want::Any)?;
let name = opt::<String>(p, "field")?.ok_or_else(|| EngineError::Params("`field` is required (a field id, or \"none\")".into()))?;
let field = match name.as_str() {
"none" => None,
id => Some(Field::parse(id).ok_or_else(|| {
let ids: Vec<&str> = Field::ALL.iter().map(|f| f.id()).collect();
EngineError::Params(format!("unknown field `{id}` (one of {}, or none)", ids.join(", ")))
})?),
};
let bands = match p.get("bands") {
None => None,
Some(b) => Some(
b.as_u64()
.and_then(|n| u32::try_from(n).ok())
.filter(|n| (MIN_BANDS..=MAX_BANDS).contains(n))
.ok_or_else(|| EngineError::Params(format!("`bands` must be a whole number from {MIN_BANDS} to {MAX_BANDS}")))?,
),
};
if let (Some(f), Some((r, _))) = (field, s.latest_result(&step))
&& !Field::available(r).any(|a| a == f)
{
return Err(EngineError::Params(format!("{} is not available for step `{step}` (mode shapes have no stress scale)", f.label())));
}
let v = view_of(s, &step)?;
v.contour = field;
if let Some(b) = bands {
v.bands = b;
}
Ok(s.results_report())
}
fn modes(s: &mut Session, p: &Value) -> Result<Value> {
let step = step_for(s, p, Want::Frequency)?;
let count = match s.latest_result(&step).map(|(r, _)| r.kind()) {
Some(Kind::Frequency(f)) => f.modes.len(),
_ => 0,
};
let mode = match p.get("mode") {
None => s.results_view.as_ref().filter(|v| v.step == step).map_or(1, |v| v.mode),
Some(m) => m
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| (1..=count).contains(n))
.ok_or_else(|| EngineError::Params(format!("`mode` must be from 1 to {count}")))?,
};
let v = view_of(s, &step)?;
v.mode = mode;
v.deformed = true;
Ok(s.results_report())
}
fn animate(s: &mut Session, p: &Value) -> Result<Value> {
let on = opt::<bool>(p, "on")?.unwrap_or(true);
if !on {
if let Some(v) = &mut s.results_view {
v.animate = false;
}
return Ok(s.results_report());
}
let step = step_for(s, &json!({}), Want::Any)?;
let v = view_of(s, &step)?;
v.animate = true;
v.deformed = true;
Ok(s.results_report())
}
fn frame(s: &mut Session, p: &Value) -> Result<Value> {
let step = step_for(s, p, Want::Explicit)?;
let times: Vec<f64> = match s.latest_result(&step).map(|(r, _)| r.kind()) {
Some(Kind::Explicit(e)) => e.frames.iter().map(|f| f.time).collect(),
_ => Vec::new(),
};
let last = times.len().checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{step}` has no field frames")))?;
let k = match (p.get("frame"), opt::<f64>(p, "time")?) {
(Some(f), _) => f
.as_u64()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n <= last)
.ok_or_else(|| EngineError::Params(format!("`frame` must be from 0 to {last}")))?,
(None, Some(t)) if t.is_finite() => {
times.iter().enumerate().min_by(|a, b| (a.1 - t).abs().total_cmp(&(b.1 - t).abs())).map_or(last, |(i, _)| i)
}
(None, Some(_)) => return Err(EngineError::Params("`time` must be a number".into())),
(None, None) => last,
};
let v = view_of(s, &step)?;
v.frame = Some(k);
Ok(s.results_report())
}
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
s.results_view = None;
Ok(json!({"view": null}))
}
fn reactions(s: &mut Session, p: &Value) -> Result<Value> {
let step = step_for(s, p, Want::Any)?;
let about = if p.get("about").is_some() { xyz(p, "about")? } else { [0.0; 3] };
if !about.iter().all(|c| c.is_finite()) {
return Err(EngineError::Params("`about` must be a finite [x, y, z]".into()));
}
let Some((result, current)) = s.latest_result(&step) else { return Err(EngineError::Params(solved_list(s))) };
let Kind::Static(r) = result.kind() else {
return Err(EngineError::Params(format!(
"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") {
Some(v) => {
let list = v
.as_array()
.filter(|a| a.len() <= MAX_SETS)
.ok_or_else(|| EngineError::Params(format!("`sets` must be a list of at most {MAX_SETS} set names")))?;
list.iter()
.map(|n| n.as_str().map(str::to_string).ok_or_else(|| EngineError::Params("`sets` must be set names".into())))
.collect::<Result<_>>()?
}
None => {
let mut names: Vec<String> = s
.model
.step(&step)
.map(|st| st.boundary_conditions.iter().filter_map(|bc| s.model.boundary_condition(bc)?.target().set().map(str::to_string)).collect())
.unwrap_or_default();
names.sort();
names.dedup();
names
}
};
// Σ R and Σ (x − about) × R over nodes (undeformed positions).
let sum = |nodes: &mut dyn Iterator<Item = u32>| {
let (mut force, mut moment, mut count) = ([0.0; 3], [0.0; 3], 0usize);
for label in nodes {
let (Ok(i), Some(node)) = (r.nodes.binary_search(&label), s.model.node(label)) else { continue };
let Some(f) = r.reaction.get(i) else { continue };
let d = [node.xyz[0] - about[0], node.xyz[1] - about[1], node.xyz[2] - about[2]];
for k in 0..3 {
force[k] += f[k];
}
moment[0] += d[1] * f[2] - d[2] * f[1];
moment[1] += d[2] * f[0] - d[0] * f[2];
moment[2] += d[0] * f[1] - d[1] * f[0];
count += 1;
}
json!({"nodes": count, "force": force, "moment": moment})
};
let mut per_set = Vec::with_capacity(sets.len());
for name in &sets {
let resolved = s.model.resolve_set(name)?;
let mut row = sum(&mut resolved.nodes.iter().copied());
row["set"] = json!(name);
per_set.push(row);
}
let total = sum(&mut r.nodes.iter().copied());
Ok(json!({"step": step, "stale": !current, "about": about, "sets": per_set, "total": total, "applied": r.applied_total}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cmd::solve::tests::cantilever;
fn solved() -> Session {
let mut s = Session::default();
cantilever(&mut s);
s.execute("solve.static", &json!({})).unwrap();
s.execute("solve.frequency", &json!({})).unwrap();
s
}
#[test]
fn the_display_follows_the_commands_and_is_not_an_edit() {
let mut s = solved();
let (journal, revision) = (s.journal.len(), s.revision());
// Solving showed the first solved step.
assert_eq!(s.results_view.as_ref().map(|v| (v.step.as_str(), v.contour)), Some(("Bend", Some(Field::VonMises))));
s.execute("results.clear", &json!({})).unwrap();
let r = s.execute("results.contour", &json!({"field": "von_mises"})).unwrap();
assert_eq!(r["view"]["step"], "Bend", "{r}");
assert_eq!(r["contour"]["field"], "von_mises");
assert!(r["contour"]["range"]["max"]["value"].as_f64().unwrap() > 0.0, "{r}");
let r = s.execute("results.deformed", &json!({"scale": 2.5})).unwrap();
assert_eq!((r["view"]["deformed"].as_bool(), r["view"]["scale"].as_f64()), (Some(true), Some(2.5)));
s.execute("results.deformed", &json!({"scale": "auto", "show": false})).unwrap();
assert_eq!(s.results_view.as_ref().map(|v| (v.deformed, v.scale)), Some((false, None)));
// Modes switch to the frequency step (its own first look) and list the frequencies.
let r = s.execute("results.modes", &json!({"mode": 3})).unwrap();
assert_eq!((r["view"]["step"].as_str(), r["view"]["mode"].as_u64()), (Some("Modes"), Some(3)));
assert_eq!(r["modes"].as_array().map(Vec::len), Some(4));
assert_eq!(s.results_view.as_ref().and_then(|v| v.contour), Some(Field::Displacement));
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();
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));
assert_eq!((s.journal.len(), s.revision()), (journal, revision), "display commands are not edits");
// An edit makes the results stale; the display keeps showing them, marked.
s.execute("model.material.edit", &json!({"name": "Mat", "elastic": {"type": "isotropic", "young": 2000, "poisson": 0}, "density": 1}))
.unwrap();
assert_eq!(s.results_report()["stale"], true);
s.execute("results.clear", &json!({})).unwrap();
assert!(s.results_view.is_none());
assert!(s.execute("edit.undo", &json!({})).is_ok());
}
#[test]
fn reactions_balance_the_load() {
let mut s = solved();
let r = s.execute("results.reactions", &json!({})).unwrap();
// The clamp holds the whole downward tip force of 1 and its moment: about the origin the
// tip force −1 z at x = 10 turns +10 about y, so the reactions turn −10.
let root = &r["sets"][0];
assert_eq!((root["set"].as_str(), root["nodes"].as_u64()), (Some("root"), Some(4)), "{r}");
assert!((root["force"][2].as_f64().unwrap() - 1.0).abs() < 1e-9, "{r}");
assert!((root["moment"][1].as_f64().unwrap() + 10.0).abs() < 1e-6, "{r}");
assert!((r["total"]["force"][2].as_f64().unwrap() - 1.0).abs() < 1e-9);
// About a point on the tip's line of action the moment about y vanishes; the tip set
// has no reactions.
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));
assert!(s.execute("results.reactions", &json!({"step": "Modes"})).is_err());
assert!(s.execute("results.reactions", &json!({"sets": ["nope"]})).is_err());
}
#[test]
fn explicit_frames_drive_the_display() {
let mut s = Session::default();
s.run_script(&crate::sample::impact()).unwrap();
s.execute("solve.explicit", &json!({})).unwrap();
// The first look: the last frame, true scale, von Mises averaged from the elements.
let r = s.results_report();
assert_eq!((r["view"]["step"].as_str(), r["view"]["scale"].as_f64(), r["view"]["frame"].is_null()), (Some("Impact"), Some(1.0), true), "{r}");
assert_eq!(r["frames"].as_array().map(Vec::len), Some(11));
assert!(r["contour"]["range"]["max"]["value"].as_f64().unwrap() > 0.0, "{r}");
// A frame by time: the nearest one.
let r = s.execute("results.frame", &json!({"time": 1.3e-5})).unwrap();
assert_eq!(r["view"]["frame"], 3, "{r}");
assert!((r["time"].as_f64().unwrap() - 1.3e-5).abs() < 1e-6, "frames land on increments: {r}");
// At frame 0 the bar has not hit yet: no stress.
let r = s.execute("results.frame", &json!({"frame": 0})).unwrap();
assert!(r["contour"]["range"]["max"]["value"].as_f64().unwrap().abs() < 1e-6, "{r}");
s.execute("results.contour", &json!({"field": "displacement_x"})).unwrap();
// Frames keep velocities: every node starts at 10 m/s.
let r = s.execute("results.contour", &json!({"field": "velocity"})).unwrap();
assert!((r["contour"]["range"]["min"]["value"].as_f64().unwrap() - 10000.0).abs() < 1e-6, "{r}");
assert!(s.execute("results.frame", &json!({"frame": 11})).is_err());
assert!(s.execute("results.modes", &json!({})).is_err());
assert!(s.execute("results.reactions", &json!({})).is_err());
}
#[test]
fn results_hostile_params_never_panic() {
let s = solved();
let hostile = [
json!(null),
json!([]),
json!({"step": 7}),
json!({"step": "Nope"}),
json!({"step": "Bend", "field": "nope"}),
json!({"field": 3, "bands": 1e300}),
json!({"field": "von_mises", "bands": 0}),
json!({"field": "von_mises", "bands": 25}),
json!({"scale": -1}),
json!({"scale": 1e300}),
json!({"scale": "big"}),
json!({"scale": f64::NAN}),
json!({"show": "yes"}),
json!({"mode": 0}),
json!({"mode": 99}),
json!({"mode": -1}),
json!({"step": "Bend", "mode": 1}),
json!({"on": 5}),
json!({"sets": "root"}),
json!({"sets": [1, 2]}),
json!({"sets": vec!["root"; 2000]}),
json!({"about": [1e308, 1e308, 1e308]}),
json!({"about": "here"}),
json!({"frame": -1}),
json!({"frame": 1e300}),
json!({"time": "late"}),
json!({"time": 1e308}),
json!({"step": "Bend", "frame": 0}),
];
for id in ["results.deformed", "results.contour", "results.modes", "results.animate", "results.frame", "results.reactions", "results.clear"] {
for p in &hostile {
let mut t = s.clone();
let _ = t.execute(id, p);
assert_eq!(t.model, s.model);
}
}
// Nothing solved: every command says so.
let mut empty = Session::default();
for id in ["results.deformed", "results.modes", "results.reactions", "results.animate"] {
let e = empty.execute(id, &json!({"field": "displacement"})).unwrap_err().to_string();
assert!(e.contains("solve"), "{id}: {e}");
}
}
}
+11 -4
View File
@@ -86,13 +86,13 @@ fn run_static(s: &mut Session, p: &Value) -> Result<Value> {
let limits = limits(p)?;
let step = opt::<String>(p, "step")?;
let r = solve_static(&s.model, step.as_deref(), &limits)?;
let magnitude = |u: &[f64; 3]| (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt();
let summary = json!({
"step": r.step,
"dofs": r.stats.dofs,
"equations": r.stats.equations,
"factor_entries": r.stats.factor_entries,
"max_displacement": largest(r.nodes.iter().zip(&r.displacement).map(|(l, u)| (*l, magnitude(u)))),
"max_displacement": largest(r.nodes.iter().zip(&r.displacement).map(|(l, u)| (*l, crate::results::magnitude(*u)))),
"max_von_mises": largest(r.nodes.iter().zip(&r.von_mises).map(|(l, v)| (*l, *v))),
"applied_total": r.applied_total,
"reaction_total": r.reaction_total,
@@ -208,7 +208,13 @@ fn history(s: &mut Session, p: &Value) -> Result<Value> {
fn probe(s: &mut Session, p: &Value) -> Result<Value> {
let step = opt::<String>(p, "step")?.ok_or_else(|| EngineError::Params("`step` is required".into()))?;
let result = s.result(&step).ok_or_else(|| EngineError::Params(format!("step `{step}` has no result; solve it first")))?;
let result = s.result(&step).ok_or_else(|| {
EngineError::Params(if s.latest_result(&step).is_some() {
format!("the result of step `{step}` is stale (the model changed since it was solved); solve it again")
} else {
format!("step `{step}` has no result; solve it first")
})
})?;
let nodes = labels(p, "nodes", MAX_PROBE)?;
let result = match result.as_ref() {
StepResult::Explicit(r) => return probe_frame(r, &step, &nodes, p),
@@ -358,7 +364,8 @@ pub(crate) mod tests {
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.solved_steps().count(), 2);
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.execute("solve.probe", &json!({"step": "Bend", "nodes": [1]})).is_err());
let e = s.execute("solve.probe", &json!({"step": "Bend", "nodes": [1]})).unwrap_err().to_string();
assert!(e.contains("stale"), "{e}");
}
#[test]
+13 -1
View File
@@ -16,6 +16,7 @@ pub mod autosave;
pub mod catalogue;
pub mod cmd;
pub mod history;
pub mod results;
pub mod sample;
use std::collections::BTreeMap;
@@ -144,6 +145,8 @@ 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 the results display shows (`results.*`); `None` shows the model.
pub results_view: Option<results::ResultsView>,
}
impl Session {
@@ -208,8 +211,17 @@ impl Session {
self.results.iter().filter(|(_, (revision, _))| *revision == self.revision).map(|(name, _)| name.as_str())
}
/// Keep a step's result for the current state.
/// Keep a step's result for the current state. With no results shown, the display shows it;
/// a view of the same step stays as it is (on mode 1 and the last frame if its own are gone).
pub(crate) fn store_result(&mut self, result: StepResult) {
match &mut self.results_view {
None => self.results_view = Some(results::ResultsView::for_result(&result)),
Some(v) if v.step == result.step() && results::displacements(&result, v.pick()).is_err() => {
v.mode = 1;
v.frame = None;
}
Some(_) => {}
}
self.results.insert(result.step().to_string(), (self.revision, Arc::new(result)));
}
+406
View File
@@ -0,0 +1,406 @@
//! What the results display shows ([`ResultsView`], set by the `results.*` commands) and the
//! nodal values it draws: displacements, mode shapes, explicit field frames and stress fields,
//! read from a solved step's [`StepResult`]. An explicit frame's one-point element stresses are
//! averaged to the nodes (unweighted, like the implicit solver's nodal stresses).
//!
//! The view is session state, like the camera: changing it is not an edit, so it is neither
//! journalled nor undone, and it does not change the session revision. A result kept from an
//! earlier revision is still shown, marked stale ([`Session::latest_result`]).
use modelcraft_explicit::{ExplicitResult, Frame};
use modelcraft_model::Model;
use modelcraft_solver::{FrequencyResult, StaticResult};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{EngineError, Result, Session, StepResult};
/// A result by its procedure.
pub enum Kind<'a> {
Static(&'a StaticResult),
Frequency(&'a FrequencyResult),
Explicit(&'a ExplicitResult),
}
impl StepResult {
pub fn kind(&self) -> Kind<'_> {
match self {
StepResult::Implicit(modelcraft_solver::StepResult::Static(r)) => Kind::Static(r),
StepResult::Implicit(modelcraft_solver::StepResult::Frequency(r)) => Kind::Frequency(r),
StepResult::Explicit(r) => Kind::Explicit(r),
}
}
}
/// Which part of a result is shown: the mode (from 1, frequency steps) and the field frame
/// (explicit steps; `None` is the last).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Pick {
pub mode: usize,
pub frame: Option<usize>,
}
/// The field frame of an explicit result `pick` asks for.
pub fn frame(r: &ExplicitResult, pick: Pick) -> Result<&Frame> {
let last = r.frames.len().checked_sub(1).ok_or_else(|| EngineError::Params(format!("step `{}` has no field frames", r.step)))?;
let k = pick.frame.unwrap_or(last);
r.frames.get(k).ok_or_else(|| EngineError::Params(format!("step `{}` has frames 0 to {last}; there is no frame {k}", r.step)))
}
/// A nodal quantity drawn as contours.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Field {
/// Displacement magnitude (a mode's shape magnitude for a frequency step).
Displacement,
DisplacementX,
DisplacementY,
DisplacementZ,
/// Velocity magnitude (explicit steps).
Velocity,
VonMises,
MaxPrincipal,
MidPrincipal,
MinPrincipal,
StressXx,
StressYy,
StressZz,
StressXy,
StressXz,
StressYz,
}
impl Field {
pub const ALL: [Field; 15] = [
Field::Displacement,
Field::DisplacementX,
Field::DisplacementY,
Field::DisplacementZ,
Field::Velocity,
Field::VonMises,
Field::MaxPrincipal,
Field::MidPrincipal,
Field::MinPrincipal,
Field::StressXx,
Field::StressYy,
Field::StressZz,
Field::StressXy,
Field::StressXz,
Field::StressYz,
];
/// The id commands take (`von_mises`…).
pub fn id(self) -> &'static str {
match self {
Field::Displacement => "displacement",
Field::DisplacementX => "displacement_x",
Field::DisplacementY => "displacement_y",
Field::DisplacementZ => "displacement_z",
Field::Velocity => "velocity",
Field::VonMises => "von_mises",
Field::MaxPrincipal => "max_principal",
Field::MidPrincipal => "mid_principal",
Field::MinPrincipal => "min_principal",
Field::StressXx => "stress_xx",
Field::StressYy => "stress_yy",
Field::StressZz => "stress_zz",
Field::StressXy => "stress_xy",
Field::StressXz => "stress_xz",
Field::StressYz => "stress_yz",
}
}
pub fn label(self) -> &'static str {
match self {
Field::Displacement => "Displacement magnitude",
Field::DisplacementX => "Displacement X",
Field::DisplacementY => "Displacement Y",
Field::DisplacementZ => "Displacement Z",
Field::Velocity => "Velocity magnitude",
Field::VonMises => "Von Mises stress",
Field::MaxPrincipal => "Max. principal stress",
Field::MidPrincipal => "Mid. principal stress",
Field::MinPrincipal => "Min. principal stress",
Field::StressXx => "Stress XX",
Field::StressYy => "Stress YY",
Field::StressZz => "Stress ZZ",
Field::StressXy => "Stress XY",
Field::StressXz => "Stress XZ",
Field::StressYz => "Stress YZ",
}
}
pub fn parse(id: &str) -> Option<Field> {
Field::ALL.into_iter().find(|f| f.id() == id)
}
/// Whether it is a stress.
pub fn is_stress(self) -> bool {
!matches!(self, Field::Displacement | Field::DisplacementX | Field::DisplacementY | Field::DisplacementZ | Field::Velocity)
}
/// The fields a result has: displacements everywhere, stresses for static steps and explicit
/// frames that kept them (mode shapes have no stress scale), velocity for explicit frames.
pub fn available(r: &StepResult) -> impl Iterator<Item = Field> + '_ {
let (stress, velocity) = match r.kind() {
Kind::Static(_) => (true, false),
Kind::Frequency(_) => (false, false),
Kind::Explicit(e) => e.frames.last().map_or((false, false), |f| (!f.stress.is_empty(), !f.velocity.is_empty())),
};
Field::ALL.into_iter().filter(move |f| if *f == Field::Velocity { velocity } else { stress || !f.is_stress() })
}
}
/// Most contour bands.
pub const MAX_BANDS: u32 = 24;
/// Fewest contour bands.
pub const MIN_BANDS: u32 = 2;
/// Contour bands when none are asked for.
pub const DEFAULT_BANDS: u32 = 12;
/// What the results display shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ResultsView {
/// The solved step shown.
pub step: String,
/// Draw the deformed shape.
pub deformed: bool,
/// Deformation scale factor; `None` scales the largest displacement to a tenth of the
/// model's size.
pub scale: Option<f64>,
/// Contours of this field; `None` draws the deformed shape in element colours.
pub contour: Option<Field>,
pub bands: u32,
/// The mode shown, from 1 (frequency steps).
pub mode: usize,
/// The field frame shown (explicit steps); `None` is the last.
#[serde(default)]
pub frame: Option<usize>,
/// Animate the deformation (a mode swings through ±, a static step grows from 0).
pub animate: bool,
}
impl ResultsView {
/// How a step's result is shown first: deformed, with von Mises contours for a static step
/// (and an explicit step that kept stresses), the first mode with displacement contours for
/// a frequency step; an explicit step at its last frame, to true scale.
pub fn for_result(r: &StepResult) -> ResultsView {
let contour = if Field::available(r).any(|f| f == Field::VonMises) { Field::VonMises } else { Field::Displacement };
let scale = matches!(r.kind(), Kind::Explicit(_)).then_some(1.0);
ResultsView {
step: r.step().to_string(),
deformed: true,
scale,
contour: Some(contour),
bands: DEFAULT_BANDS,
mode: 1,
frame: None,
animate: false,
}
}
pub fn pick(&self) -> Pick {
Pick { mode: self.mode, frame: self.frame }
}
}
/// 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]]> {
match r.kind() {
Kind::Static(s) => Ok(&s.displacement),
Kind::Frequency(f) => pick
.mode
.checked_sub(1)
.and_then(|k| f.modes.get(k))
.map(|m| m.shape.as_slice())
.ok_or_else(|| EngineError::Params(format!("step `{}` has modes 1 to {}; there is no mode {}", f.step, f.modes.len(), pick.mode))),
Kind::Explicit(e) => {
let f = frame(e, pick)?;
if f.displacement.is_empty() {
return Err(EngineError::Params(format!("step `{}` kept no displacements in its field frames", e.step)));
}
Ok(&f.displacement)
}
}
}
/// A field's value at every node of the result, in [`StepResult::nodes`] order. `model` gives
/// the element connectivity an explicit frame's element stresses are averaged over.
pub fn field_values(model: &Model, r: &StepResult, field: Field, pick: Pick) -> Result<Vec<f64>> {
let unavailable = || EngineError::Params(format!("{} is not available for step `{}`", field.label(), r.step()));
let component = |f: fn(&[f64; 3]) -> f64| -> Result<Vec<f64>> { Ok(displacements(r, pick)?.iter().map(f).collect()) };
let stresses = || -> Result<std::borrow::Cow<'_, [[f64; 6]]>> {
match r.kind() {
Kind::Static(s) => Ok(std::borrow::Cow::Borrowed(s.stress.as_slice())),
Kind::Frequency(_) => Err(unavailable()),
Kind::Explicit(e) => {
let f = frame(e, pick)?;
if f.stress.is_empty() {
return Err(unavailable());
}
Ok(std::borrow::Cow::Owned(nodal_average(model, &e.nodes, &e.elements, &f.stress)))
}
}
};
let each = |f: &dyn Fn(&[f64; 6]) -> f64| -> Result<Vec<f64>> { Ok(stresses()?.iter().map(f).collect()) };
let principal = |k: usize| each(&|t| modelcraft_fem::principal(t).get(k).copied().unwrap_or(f64::NAN));
match field {
Field::Displacement => component(|v| magnitude(*v)),
Field::DisplacementX => component(|v| v[0]),
Field::DisplacementY => component(|v| v[1]),
Field::DisplacementZ => component(|v| v[2]),
Field::Velocity => match r.kind() {
Kind::Explicit(e) => {
let f = frame(e, pick)?;
if f.velocity.is_empty() { Err(unavailable()) } else { Ok(f.velocity.iter().map(|v| magnitude(*v)).collect()) }
}
Kind::Static(_) | Kind::Frequency(_) => Err(unavailable()),
},
Field::VonMises => each(&modelcraft_fem::von_mises),
Field::MaxPrincipal => principal(0),
Field::MidPrincipal => principal(1),
Field::MinPrincipal => principal(2),
Field::StressXx => each(&|t| t[0]),
Field::StressYy => each(&|t| t[1]),
Field::StressZz => each(&|t| t[2]),
Field::StressXy => each(&|t| t[3]),
Field::StressXz => each(&|t| t[4]),
Field::StressYz => each(&|t| t[5]),
}
}
/// Element values (in `elements` order) averaged at the nodes (in `nodes` order) of the
/// elements around them; a node no element reaches gets NaN.
fn nodal_average(model: &Model, nodes: &[u32], elements: &[u32], values: &[[f64; 6]]) -> Vec<[f64; 6]> {
let mut sum = vec![[0.0; 6]; nodes.len()];
let mut count = vec![0u32; nodes.len()];
for (label, v) in elements.iter().zip(values) {
let Some(e) = model.element(*label) else { continue };
for n in &e.nodes {
let Ok(i) = nodes.binary_search(n) else { continue };
if let (Some(s), Some(c)) = (sum.get_mut(i), count.get_mut(i)) {
for (a, b) in s.iter_mut().zip(v) {
*a += b;
}
*c += 1;
}
}
}
sum.into_iter().zip(count).map(|(s, c)| if c == 0 { [f64::NAN; 6] } else { s.map(|x| x / f64::from(c)) }).collect()
}
/// The smallest and largest finite value, with the nodes they are at (`labels` in the same
/// order as `values`).
pub fn range(labels: &[u32], values: &[f64]) -> Option<((u32, f64), (u32, f64))> {
let mut out: Option<((u32, f64), (u32, f64))> = None;
for (&l, &v) in labels.iter().zip(values).filter(|(_, v)| v.is_finite()) {
out = Some(match out {
None => ((l, v), (l, v)),
Some((lo, hi)) => (if v < lo.1 { (l, v) } else { lo }, if v > hi.1 { (l, v) } else { hi }),
});
}
out
}
pub fn magnitude(v: [f64; 3]) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
impl Session {
/// The latest result of a step and whether it describes the current state (`false`: the
/// model changed since it was solved; it is stale).
pub fn latest_result(&self, step: &str) -> Option<(&std::sync::Arc<StepResult>, bool)> {
self.results.get(step).map(|(revision, r)| (r, *revision == self.revision))
}
/// What the results display shows: the view, whether its result is stale, the contour
/// field's range, a frequency step's modes and an explicit step's frame times (the
/// `results.*` commands' reply).
pub fn results_report(&self) -> Value {
let Some(v) = &self.results_view else { return json!({"view": null}) };
let Some((r, current)) = self.latest_result(&v.step) else { return json!({"view": v}) };
let mut out = json!({"view": v, "stale": !current});
if let Some(f) = v.contour
&& let Ok(values) = field_values(&self.model, r, f, v.pick())
{
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});
}
match r.kind() {
Kind::Frequency(fr) => {
out["modes"] = fr.modes.iter().enumerate().map(|(i, m)| json!({"mode": i + 1, "frequency": m.frequency})).collect();
}
Kind::Explicit(e) => {
out["frames"] = e.frames.iter().map(|f| f.time).collect();
if let Ok(f) = frame(e, v.pick()) {
out["time"] = json!(f.time);
}
}
Kind::Static(_) => {}
}
out
}
/// Every step with a result, current or stale, by name.
pub fn result_steps(&self) -> impl Iterator<Item = (&str, bool)> {
self.results.iter().map(|(name, (revision, _))| (name.as_str(), *revision == self.revision))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cmd::solve::tests::cantilever;
use serde_json::json;
#[test]
fn fields_follow_the_result() {
let mut s = Session::default();
cantilever(&mut s);
s.execute("solve.static", &json!({})).unwrap();
s.execute("solve.frequency", &json!({})).unwrap();
let (bend, current) = s.latest_result("Bend").unwrap();
assert!(current);
let mag = field_values(&s.model, bend, Field::Displacement, Pick { mode: 1, frame: None }).unwrap();
let uz = field_values(&s.model, bend, Field::DisplacementZ, Pick { mode: 1, frame: None }).unwrap();
assert_eq!(mag.len(), bend.nodes().len());
for (m, z) in mag.iter().zip(&uz) {
assert!(*m >= z.abs() - 1e-12);
}
// The tip bends down the most; the clamped root does not move.
let ((lo_node, lo), (_, hi)) = range(bend.nodes(), &uz).unwrap();
assert!(lo < -3.8 && hi.abs() < 1e-12, "{lo} {hi}");
assert!([11, 22, 33, 44].contains(&lo_node), "{lo_node}");
let vm = field_values(&s.model, bend, Field::VonMises, Pick { mode: 1, frame: None }).unwrap();
let max = field_values(&s.model, bend, Field::MaxPrincipal, Pick { mode: 1, frame: None }).unwrap();
let min = field_values(&s.model, bend, Field::MinPrincipal, Pick { mode: 1, frame: None }).unwrap();
for ((v, a), b) in vm.iter().zip(&max).zip(&min) {
assert!(a >= b && *v >= 0.0, "{v} {a} {b}");
}
let (modes, _) = s.latest_result("Modes").unwrap();
assert_eq!(Field::available(modes).count(), 4);
assert_eq!(Field::available(bend).count(), Field::ALL.len() - 1, "all but velocity");
assert!(field_values(&s.model, modes, Field::VonMises, Pick { mode: 1, frame: None }).is_err());
assert!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 9, frame: None }).is_err());
assert_eq!(field_values(&s.model, modes, Field::Displacement, Pick { mode: 2, frame: None }).unwrap().len(), modes.nodes().len());
// An edit leaves the results stale, not gone.
s.execute("model.material.edit", &json!({"name": "Mat", "elastic": {"type": "isotropic", "young": 2000, "poisson": 0}, "density": 1}))
.unwrap();
assert!(s.result("Bend").is_none());
assert_eq!(s.latest_result("Bend").map(|(_, current)| current), Some(false));
assert_eq!(s.result_steps().collect::<Vec<_>>(), vec![("Bend", false), ("Modes", false)]);
}
#[test]
fn field_ids_round_trip() {
for f in Field::ALL {
assert_eq!(Field::parse(f.id()), Some(f));
assert_eq!(serde_json::to_value(f).unwrap(), json!(f.id()));
}
assert_eq!(Field::parse("nope"), None);
assert_eq!(range(&[1, 2], &[f64::NAN, f64::INFINITY]), None);
assert_eq!(range(&[1, 2, 3], &[2.0, -1.0, 5.0]), Some(((2, -1.0), (3, 5.0))));
}
}
+117 -6
View File
@@ -1,10 +1,14 @@
//! The sample model the desktop app opens with `--sample`: a script of ordinary commands, so it
//! builds the same way a user's script does.
//! Sample models as scripts of ordinary commands, so they build the same way a user's script
//! does.
//!
//! Two parts side by side: a 40 × 20 × 10 block of 5 mm hex8 cells, and a 15 mm cube split into
//! 162 tet4 elements (each 5 mm cell into six tetrahedra around its main diagonal, the Kuhn
//! subdivision, which is conforming between neighbouring cells). Every element is positively
//! oriented.
//! [`script`], the model the desktop app opens with `--sample`: two parts side by side, a
//! 40 × 20 × 10 block of 5 mm hex8 cells, and a 15 mm cube split into 162 tet4 elements (each
//! 5 mm cell into six tetrahedra around its main diagonal, the Kuhn subdivision, which is
//! conforming between neighbouring cells). Every element is positively oriented.
//!
//! [`cantilever`], a model ready to solve: a steel beam clamped at one end with a load on the
//! other, a static and a frequency step. [`impact`]: the same beam flying onto a rigid wall, an
//! explicit dynamic step.
use serde_json::{Value, json};
@@ -91,6 +95,90 @@ pub fn script() -> Value {
Value::Array(steps)
}
/// The cantilever's length (x), width (y) and height (z) in mm, and its hex8 cells along each.
pub const CANTILEVER_SIZE: [f64; 3] = [100.0, 10.0, 20.0];
const CANTILEVER_CELLS: [u32; 3] = [20, 2, 4];
/// The beam's mesh in steel (E = 210 GPa, ν = 0.3, ρ = 7.85 t/m³ in mm, t, s, N, MPa): nodes,
/// hex8 elements, sets `beam` (elements), `root` (nodes at x = 0) and `tip` (at x = L), the
/// material `Steel` and a solid section `Beam` with the given formulation.
fn beam(formulation: Value) -> Vec<Value> {
let [nx, ny, nz] = CANTILEVER_CELLS;
let [lx, ly, lz] = CANTILEVER_SIZE;
let id = |i: u32, j: u32, k: u32| 1 + i + j * (nx + 1) + k * (nx + 1) * (ny + 1);
let mut steps = vec![json!({"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}})];
for k in 0..=nz {
for j in 0..=ny {
for i in 0..=nx {
let xyz = [lx * f64::from(i) / f64::from(nx), ly * f64::from(j) / f64::from(ny), lz * f64::from(k) / f64::from(nz)];
steps.push(json!({"command": "mesh.node.add", "params": {"label": id(i, j, k), "xyz": xyz}}));
}
}
}
let mut element = 0u32;
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
element += 1;
let nodes = [
id(i, j, k),
id(i + 1, j, k),
id(i + 1, j + 1, k),
id(i, j + 1, k),
id(i, j, k + 1),
id(i + 1, j, k + 1),
id(i + 1, j + 1, k + 1),
id(i, j + 1, k + 1),
];
steps.push(json!({"command": "mesh.element.add", "params": {"label": element, "kind": "hex8", "nodes": nodes}}));
}
}
}
let end = |i: u32| -> Vec<u32> { (0..=nz).flat_map(|k| (0..=ny).map(move |j| id(i, j, k))).collect() };
steps.extend([
json!({"command": "model.set.create", "params": {"name": "beam", "elements": (1..=element).collect::<Vec<_>>()}}),
json!({"command": "model.set.create", "params": {"name": "root", "nodes": end(0)}}),
json!({"command": "model.set.create", "params": {"name": "tip", "nodes": end(nx)}}),
json!({"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9}}),
json!({"command": "model.section.create", "params": {"name": "Beam", "set": "beam", "material": "Steel", "kind": {"type": "solid"}, "formulation": formulation}}),
]);
steps
}
/// A steel cantilever of hex8 elements with incompatible modes ([`CANTILEVER_SIZE`]), clamped at
/// x = 0 (set `root`), with a total force of 1000 N down on the free end (set `tip`); step
/// `Load` (static) and step `Modes` (six modes). Euler–Bernoulli puts the tip
/// 4 P L³ / (E w h³) = 0.238 mm down.
pub fn cantilever() -> Value {
let mut steps = beam(json!({"incompatible_modes": true}));
steps.extend([
json!({"command": "model.boundary_condition.create", "params": {"name": "Clamp", "type": "fixed", "target": {"set": "root"}}}),
json!({"command": "model.load.create", "params": {"name": "Push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -1000]}}),
json!({"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["Clamp"], "loads": ["Push"]}}),
json!({"command": "model.step.create", "params": {"name": "Modes", "procedure": {"type": "frequency", "modes": 6}, "boundary_conditions": ["Clamp"]}}),
]);
Value::Array(steps)
}
/// The same steel beam in one-point hex8 elements, flying at 10 m/s onto a rigid wall at x = 0:
/// step `Impact` (explicit dynamics, 40 µs, ten field frames, energies and the tip's history).
/// The compression wave runs the 100 mm bar in about 19 µs (c = √(E/ρ) ≈ 5.2 mm/µs).
pub fn impact() -> Value {
let mut steps = beam(json!({"integration": "reduced"}));
let step = json!({
"type": "explicit_dynamic", "end_time": 4e-5, "output_interval": 4e-6, "history_interval": 2e-7, "history_nodes": "tip",
"initial_velocities": [{"set": "all_nodes", "velocity": [-10000, 0, 0]}],
"rigid_walls": [{"set": "root", "point": [0, 0, 0], "normal": [1, 0, 0]}]
});
let [nx, ny, nz] = CANTILEVER_CELLS;
let nodes = (nx + 1) * (ny + 1) * (nz + 1);
steps.extend([
json!({"command": "model.set.create", "params": {"name": "all_nodes", "nodes": (1..=nodes).collect::<Vec<_>>()}}),
json!({"command": "model.step.create", "params": {"name": "Impact", "procedure": step}}),
]);
Value::Array(steps)
}
/// Six times the signed volume of the tetrahedron `p[0..4]` (positive when p3 lies on the side
/// p0 → p1 → p2 turns counter-clockwise around).
fn signed_volume(p: &[[f64; 3]]) -> f64 {
@@ -124,4 +212,27 @@ mod tests {
}
assert!((volume - 15.0f64.powi(3)).abs() < 1e-9, "{volume}");
}
#[test]
fn the_cantilever_solves_close_to_beam_theory() {
let mut s = Session::default();
s.run_script(&cantilever()).unwrap();
let r = s.execute("solve.static", &json!({})).unwrap();
let [l, w, h] = CANTILEVER_SIZE;
let beam = 4.0 * 1000.0 * l.powi(3) / (210000.0 * w * h.powi(3));
let tip = r["max_displacement"]["value"].as_f64().unwrap();
// Shear deformation adds a little (L/h = 5).
assert!(tip > beam && tip < 1.1 * beam, "{tip} vs {beam}");
let f = s.execute("solve.frequency", &json!({})).unwrap();
assert_eq!(f["modes"].as_array().map(Vec::len), Some(6));
}
#[test]
fn the_impact_runs_and_keeps_frames_with_stresses() {
let mut s = Session::default();
s.run_script(&impact()).unwrap();
let r = s.execute("solve.explicit", &json!({})).unwrap();
assert_eq!((r["completed"].as_bool(), r["frames"].as_u64()), (Some(true), Some(11)), "{r}");
assert!(r["energy_balance"].as_f64().unwrap() < 0.05, "{r}");
}
}