//! The results display in the viewport: what the engine's [`ResultsView`] asks for, drawn on the //! model's display data. The deformed shape moves every element by its nodes' displacements //! (or a mode shape) times the scale; contours split the faces into flat-coloured bands //! ([`fieldcraft_render::contour`]); animation steps the deformation through a fixed number of //! frames per cycle (an explicit step's animation runs through its field frames, contours //! following). A plot of a stale result (the model changed since the solve) is drawn the //! same way and marked. A fluid step draws the part as it is, with the engine's flow display //! ([`fieldcraft_engine::flow`]: a slice, the obstacles coloured and streamlines) through it. use std::sync::Arc; use fieldcraft_engine::flow::FlowPlot; use fieldcraft_engine::results::{Field, Kind, Pick, ResultsView, auto_scale, displacements, field_values, range}; use fieldcraft_engine::{Session, StepResult}; use fieldcraft_render::contour::{Bands, banded, bands_color}; use fieldcraft_render::{DisplayMesh, Scene, Vec3}; use crate::Style; use crate::theme::{Tokens, rgb}; /// Animation frames per cycle, and the cycle's length in seconds. pub const FRAMES: u64 = 24; pub const PERIOD: f64 = 2.0; /// Significant digits of the numbers the results show. const DIGITS: i32 = 4; /// A value below this fraction of the largest it is read with is round-off, shown as 0. const ROUND_OFF: f64 = 1e-9; /// A contour plot: its field, bands, extremes and the value at every node. #[derive(Clone, Debug, PartialEq)] pub struct Contour { pub field: Field, pub bands: Bands, /// The smallest and largest value and their nodes (a fluid step's: their cells). pub min: (u32, f64), pub max: (u32, f64), /// The value at every node, in the result's node order (empty for a fluid step's cells). pub values: Vec, } impl Contour { /// The value at a node (NaN when it has none). pub fn at(&self, nodes: &[u32], label: u32) -> f64 { nodes.binary_search(&label).ok().and_then(|i| self.values.get(i).copied()).unwrap_or(f64::NAN) } } /// A results plot ready to draw. #[derive(Clone, Debug)] pub struct Plot { pub view: ResultsView, pub result: Arc, /// Whether the model changed since the step was solved. pub stale: bool, /// The deformation scale factor, before animation: the view's or the automatic one, also /// while the view is undeformed (what ticking Deformed would draw it with). pub scale: f64, /// The elements as drawn (deformed at the current animation frame). pub display: DisplayMesh, pub contour: Option>, /// A fluid step's slice, coloured obstacles and streamlines. pub flow: Option>, /// The mode and field frame drawn. pub pick: Pick, /// The model's unit system, which the numbers shown with the plot are in. pub units: fieldcraft_model::UnitSystem, } /// The part of a plot that does not change with the animation frame. #[derive(Clone, Debug, Default)] pub struct Prepared { pub scale: f64, pub contour: Option>, pub flow: Option>, } /// A mode's value as the results show it: its frequency, or a buckling mode's load factor. pub fn mode_value(buckling: bool, eigenvalue: f64, frequency: f64) -> String { if buckling { format!("λ = {}", number(eigenvalue)) } else { format!("{} Hz", number(frequency)) } } impl Plot { /// A frequency or buckling step's mode shown, and its frequency (Hz) or load factor. pub fn mode(&self) -> Option<(usize, String)> { match self.result.kind() { Kind::Frequency(f) => { f.modes.get(self.pick.mode.checked_sub(1)?).map(|m| (self.pick.mode, mode_value(f.buckling.is_some(), m.eigenvalue, m.frequency))) } Kind::Static(_) | Kind::Explicit(_) | Kind::Fluid(_) | Kind::Heat(_) => None, } } /// An explicit or transient heat step's frame shown, its time (a nonlinear static step's: /// its load factor) and the number of frames. pub fn frame(&self) -> Option<(usize, f64, usize)> { let (n, time): (usize, &dyn Fn(usize) -> Option) = match self.result.kind() { Kind::Explicit(e) => (e.frames.len(), &|k| e.frames.get(k).map(|f| f.time)), Kind::Static(r) if r.nonlinear.is_some() => { let frames = r.nonlinear.as_ref().map(|h| h.frames.as_slice()).unwrap_or_default(); (frames.len(), &|k| frames.get(k).map(|f| f.load_factor)) } Kind::Heat(h) if h.times.len() > 1 => (h.times.len(), &|k| h.times.get(k).copied()), Kind::Fluid(f) => (f.frames.len(), &|k| f.frames.get(k).map(|f| f.time)), _ => return None, }; let k = self.pick.frame.unwrap_or(n.checked_sub(1)?); Some((k, time(k)?, n)) } /// The node's position in the plot, if it is drawn. pub fn node_position(&self, label: u32) -> Option { self.display.node_position(label) } /// Where a contour extreme is: its node's position, or a fluid step's cell centre. pub fn extreme_position(&self, label: u32) -> Option { match self.result.kind() { Kind::Fluid(f) => Some(Vec3::from(fieldcraft_engine::flow::cell_centre(f, usize::try_from(label).ok()?))), _ => self.node_position(label), } } /// Whether it shows a fluid step (fields per cell, no deformation). pub fn is_fluid(&self) -> bool { matches!(self.result.kind(), Kind::Fluid(_)) } } /// The result the session's view shows, and whether it is current. pub fn shown(session: &Session) -> Option<(&ResultsView, &Arc, bool)> { let view = session.results_view.as_ref()?; let (result, current) = session.latest_result(&view.step)?; Some((view, result, current)) } /// The animation frame shown at time `now` (seconds), when animating. pub fn frame(view: &ResultsView, now: f64) -> Option { (view.animate && view.deformed && now.is_finite()).then(|| ((now.max(0.0) / PERIOD * FRAMES as f64).floor() as u64) % FRAMES) } /// The deformation multiplier of an animation frame: a mode swings through ±1, a static step /// grows from 0 to 1 and back; an explicit step's frames are drawn as they are. pub fn swing(result: &StepResult, frame: Option) -> f64 { let Some(k) = frame else { return 1.0 }; let a = std::f64::consts::TAU * k as f64 / FRAMES as f64; match result.kind() { Kind::Frequency(_) => a.sin(), // A nonlinear step plays its increments' frames. Kind::Static(r) if r.nonlinear.is_some() => 1.0, Kind::Static(_) => 0.5 * (1.0 - a.cos()), Kind::Explicit(_) | Kind::Fluid(_) | Kind::Heat(_) => 1.0, } } /// What is drawn at animation `frame`: the view's mode and field frame, an animated explicit /// step running through its field frames once per cycle. pub fn pick(view: &ResultsView, result: &StepResult, frame: Option) -> Pick { let mut p = view.pick(); let frames = match result.kind() { Kind::Explicit(e) => e.frames.len(), Kind::Static(r) => r.nonlinear.as_ref().map_or(0, |h| h.frames.len()), _ => 0, }; if let (Some(k), Some(last)) = (frame, frames.checked_sub(1)) { p.frame = Some((k as usize * frames / FRAMES as usize).min(last)); } p } /// The deformation scale factor: the view's, or automatic ([`auto_scale`]), whether or not the /// view is deformed. pub fn scale(view: &ResultsView, result: &StepResult, pick: Pick, base: &DisplayMesh) -> f64 { view.scale.unwrap_or_else(|| auto_scale(result, pick, base.bounds.diagonal())) } /// The part of the session's results plot that depends on what is drawn (`pick`), not on the /// animation's deformation multiplier: the deformation scale and the contour values. pub fn prepare(session: &Session, base: &DisplayMesh, pick: Pick) -> Option { let (view, result, _) = shown(session)?; if matches!(result.kind(), Kind::Fluid(_)) { let flow = fieldcraft_engine::flow::plot(view, result, &session.traced).ok(); let cell = |(k, v): (usize, f64)| (u32::try_from(k).unwrap_or(u32::MAX), v); let contour = flow .as_ref() .and_then(|p| p.contour.as_ref()) .map(|c| Arc::new(Contour { field: c.field, bands: c.bands, min: cell(c.min), max: cell(c.max), values: Vec::new() })); return Some(Prepared { scale: 0.0, contour, flow: flow.map(Arc::new) }); } let contour = view.contour.and_then(|field| { let mut values = field_values(&session.model, result, field, pick).ok()?; // The range (and the bands) follow what is drawn: a hidden body's nodes have no value. for (v, label) in values.iter_mut().zip(result.nodes()) { if base.node_position(*label).is_none() { *v = f64::NAN; } } let (min, max) = range(result.nodes(), &values)?; Some(Arc::new(Contour { field, bands: view.bands(min.1, max.1), min, max, values })) }); Some(Prepared { scale: scale(view, result, pick, base), contour, flow: None }) } /// The plot of the session's results view on the model's display data at animation `frame`, /// with its [`prepare`]d part. pub fn plot(session: &Session, base: &DisplayMesh, frame: Option, prepared: &Prepared) -> Option { let (view, result, current) = shown(session)?; let nodes = result.nodes(); let pick = pick(view, result, frame); let factor = if view.deformed { prepared.scale * swing(result, frame) } else { 0.0 }; let u = displacements(result, pick).unwrap_or(&[]); // A fluid step does not move the part: it stays drawn as it is, geometry included. let display = if matches!(result.kind(), Kind::Fluid(_)) { base.clone() } else { base.displaced(|label| { let i = nodes.binary_search(&label).ok()?; u.get(i).map(|d| Vec3::from(*d) * factor) }) }; Some(Plot { view: view.clone(), result: result.clone(), stale: !current, scale: prepared.scale, display, contour: prepared.contour.clone(), flow: prepared.flow.clone(), pick, units: session.model.units(), }) } /// The scene of a plot: the deformed elements drawn as the model is ([`scene::build`]), with /// their faces split into contour bands when a field is shown; a fluid step's part as it is with /// the flow drawn through it ([`fieldcraft_engine::flow::draw`]). Over a plot, hiding the mesh /// hides the element edges: the result itself never vanishes with them. pub fn scene(plot: &Plot, style: Style, show: crate::scene::Show, t: &Tokens) -> Scene { let (style, show) = if show.mesh { (style, show) } else { (Style::Shaded, crate::scene::Show { mesh: true, ..show }) }; let mut s = crate::scene::build(&plot.display, style, show, t); if let Some(flow) = &plot.flow { let stops = t.contour_stops(); let colors = fieldcraft_engine::flow::FlowColors { stops: &stops, over: rgb(t.contour_over), plain: rgb(t.streamline) }; fieldcraft_engine::flow::draw(flow, &mut s, &colors); return s; } let Some(c) = plot.contour.as_ref().filter(|_| style != Style::Wireframe && show.mesh) else { return s }; let nodes = plot.result.nodes(); let stops = t.contour_stops(); let color = |band: Option| band.map_or(rgb(t.no_result), |k| bands_color(&stops, rgb(t.contour_over), &c.bands, k)); s.tris.clear(); for tri in &plot.display.tris { banded(tri.p, tri.nodes.map(|l| c.at(nodes, l)), tri.normal, &c.bands, &color, &mut s.tris); } s } /// A number as the results show it (legend, probe, reaction sums, solve summary): four /// significant digits, thousands separators, scientific notation when very large or small. pub fn number(v: f64) -> String { if !v.is_finite() { return "—".into(); } let a = v.abs(); if a == 0.0 { return "0".into(); } if !(1e-3..1e6).contains(&a) { return format!("{:.*e}", (DIGITS - 1) as usize, v); } let decimals = (DIGITS - 1 - a.log10().floor() as i32).clamp(0, 6) as usize; let text = format!("{v:.decimals$}"); // Rounding can make it -0.000. if text.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { return "0".into(); } let (sign, digits) = text.strip_prefix('-').map_or(("", text.as_str()), |d| ("-", d)); let (whole, fraction) = digits.split_once('.').map_or((digits, None), |(w, f)| (w, Some(f))); let grouped = grouped(whole); match fraction { Some(f) => format!("{sign}{grouped}.{f}"), None => format!("{sign}{grouped}"), } } /// `v` read among values as large as `scale` (the largest magnitude beside it): round-off below /// a billionth of it is 0. pub fn clean(v: f64, scale: f64) -> f64 { if scale.is_finite() && v.abs() <= ROUND_OFF * scale.abs() { 0.0 } else { v } } /// [`number`] of `v` [`clean`]ed against `scale`. pub fn number_in(v: f64, scale: f64) -> String { number(clean(v, scale)) } /// A count with thousands separators: `221,850,309`. pub fn count(n: u64) -> String { grouped(&n.to_string()) } /// Digits with a comma between each group of three from the right. fn grouped(digits: &str) -> String { let mut out = String::new(); for (i, c) in digits.chars().enumerate() { if i > 0 && (digits.len() - i).is_multiple_of(3) { out.push(','); } out.push(c); } out } /// A number with its unit: `0.1048 mm` (the number alone without one). pub fn with_unit(text: String, unit: Option<&str>) -> String { match unit { Some(u) => format!("{text} {u}"), None => text, } } /// A vector's components, round-off among them (against the largest, or `scale` when larger) /// shown as 0: `0, 0, -0.1048`. pub fn components(v: &[f64], scale: f64) -> String { let largest = v.iter().map(|c| c.abs()).filter(|c| c.is_finite()).fold(scale.abs(), f64::max); v.iter().map(|c| number_in(*c, largest)).collect::>().join(", ") } #[cfg(test)] pub(crate) mod tests { use serde_json::json; use fieldcraft_engine::results::{AUTO_FRACTION, magnitude}; use fieldcraft_render::contour::band_color; use super::*; fn plot_at(s: &Session, base: &DisplayMesh, frame: Option) -> Option { let (view, result, _) = shown(s)?; plot(s, base, frame, &prepare(s, base, pick(view, result, frame))?) } /// The sample cantilever, solved (static step `Load`, frequency step `Modes`). pub(crate) fn solved_cantilever() -> Session { let mut s = Session::default(); s.run_script(&fieldcraft_engine::sample::cantilever()).unwrap(); s.execute("solve.static", &json!({})).unwrap(); s.execute("solve.frequency", &json!({})).unwrap(); s } /// The label of the node at `xyz`. pub(crate) fn node_at(s: &Session, xyz: [f64; 3]) -> u32 { *s.model.nodes().iter().find(|(_, n)| n.xyz == xyz).unwrap().0 } #[test] fn the_deformed_shape_moves_by_the_scaled_displacement() { let mut s = solved_cantilever(); let base = DisplayMesh::from_model(&s.model); s.execute("results.clear", &json!({})).unwrap(); assert!(plot_at(&s, &base, None).is_none(), "nothing shown after results.clear"); s.execute("results.deformed", &json!({"scale": 2.0})).unwrap(); let p = plot_at(&s, &base, None).unwrap(); assert_eq!(p.scale, 2.0); // A tip corner moves twice its displacement. let tip = node_at(&s, [100.0, 0.0, 0.0]); let Kind::Static(r) = p.result.kind() else { panic!("a static result") }; let u = r.displacement[r.nodes.binary_search(&tip).unwrap()]; let at = p.node_position(tip).unwrap(); assert!((at.z - 2.0 * u[2]).abs() < 1e-12 && u[2] < -0.2, "{at:?} {u:?}"); // Automatic: the largest displacement is a tenth of the model's size. s.execute("results.deformed", &json!({"scale": "auto"})).unwrap(); let p = plot_at(&s, &base, None).unwrap(); let largest = r.displacement.iter().map(|v| magnitude(*v)).fold(0.0, f64::max); let auto = AUTO_FRACTION * base.bounds.diagonal() / largest; assert!((p.scale / auto - 1.0).abs() < 1e-12, "{}", p.scale); // Undeformed, the plot still knows the scale ticking Deformed would draw (#325). s.execute("results.deformed", &json!({"show": false})).unwrap(); let p = plot_at(&s, &base, None).unwrap(); assert_eq!(p.display.tris, base.tris); assert!((p.scale / auto - 1.0).abs() < 1e-12, "{}", p.scale); } #[test] fn contours_colour_known_nodes_with_their_band() { let mut s = solved_cantilever(); s.execute("results.contour", &json!({"field": "displacement_z", "bands": 4})).unwrap(); s.execute("results.deformed", &json!({"show": false})).unwrap(); let base = DisplayMesh::from_model(&s.model); let p = plot_at(&s, &base, None).unwrap(); let legend = p.contour.clone().unwrap(); // The tip moves most (lowest band), the clamped root not at all (highest band). assert!(legend.min.1 < -0.2 && legend.max.1.abs() < 1e-12, "{legend:?}"); let t = Tokens::dark(); let scene = scene(&p, Style::Shaded, crate::scene::Show::DEFAULT, &t); let stops = t.contour_stops(); let (low, high) = (band_color(&stops, 0, 4), band_color(&stops, 3, 4)); // Faces whose corners are all near the root are in the top band; near the tip, the bottom. let root_end = scene.tris.iter().filter(|tri| tri.p.iter().all(|q| q.x < 0.5)).map(|tri| tri.color).collect::>(); assert!(!root_end.is_empty() && root_end.iter().all(|c| *c == high), "{root_end:?}"); let tip_end = scene.tris.iter().filter(|tri| tri.p.iter().all(|q| q.x > 99.5)).map(|tri| tri.color).collect::>(); assert!(!tip_end.is_empty() && tip_end.iter().all(|c| *c == low), "{tip_end:?}"); // Every band appears, and the pieces still cover the faces (more triangles than before). for k in 0..4 { assert!(scene.tris.iter().any(|tri| tri.color == band_color(&stops, k, 4)), "band {k}"); } assert!(scene.tris.len() > base.tris.len()); // Hiding the mesh hides the element edges, not the contours (#317). let hidden = super::scene(&p, Style::ShadedEdges, crate::scene::Show { mesh: false, ..crate::scene::Show::DEFAULT }, &t); assert_eq!(hidden.tris, scene.tris); assert!(hidden.lines.iter().all(|l| l.color != rgb(t.mesh_edge)), "no element edges"); } /// A 40 × 30 × 10 mm plate in a tank of 5 mm cells, a liquid flowing past it along x, solved. fn plate_in_a_tank() -> Session { use fieldcraft_geometry::sample::{Plate, plate_design}; let dir = std::env::temp_dir().join(format!("fieldcraft-ui-flow-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("plate.solidcraft"); std::fs::write(&path, plate_design(&Plate::default()).unwrap()).unwrap(); let mut s = Session::default(); s.execute("geometry.open", &json!({"path": path.to_str().unwrap()})).unwrap(); let _ = std::fs::remove_dir_all(&dir); s.run_script(&json!([ {"command": "model.material.create", "params": {"name": "Water", "density": 1e-9, "fluid": {"type": "newtonian", "viscosity": 1e-7}}}, {"command": "model.fluid_domain.create", "params": {"name": "Tank", "material": "Water", "min": [-20, -20, -20], "max": [60, 50, 30], "spacing": 5, "obstacles": ["Body1"], "faces": {"x_min": {"type": "velocity", "velocity": [10, 0, 0]}, "x_max": {"type": "pressure", "pressure": 0}}}}, {"command": "model.step.create", "params": {"name": "Flow", "procedure": {"type": "fluid", "domain": "Tank", "steps": 60, "frames": 2}}}, ])) .unwrap(); s.execute("solve.fluid", &json!({})).unwrap(); s } /// After a fluid solve the part stays drawn and the flow is drawn through it: velocity on a /// slice and on the part, with its legend, and streamlines (#193). #[test] fn a_fluid_step_keeps_the_part_and_draws_the_flow() { let mut s = plate_in_a_tank(); let (base, _) = fieldcraft_engine::view::display(&s); assert!(!base.surface_tris.is_empty()); let p = plot_at(&s, &base, None).unwrap(); assert!(p.is_fluid() && p.flow.is_some()); assert_eq!(p.display.surface_tris, base.surface_tris, "the part stays"); let legend = p.contour.clone().unwrap(); assert!(legend.field == Field::Velocity && legend.max.1 > legend.min.1, "{legend:?}"); let at = p.extreme_position(legend.max.0).unwrap(); assert!(at.x > -20.0 && at.x < 60.0, "{at:?}"); assert_eq!(p.frame().map(|(k, _, n)| (k, n)), Some((2, 3))); let t = Tokens::light(); let scene = scene(&p, Style::ShadedEdges, crate::scene::Show::DEFAULT, &t); assert!(scene.tris.len() > base.surface_tris.len(), "the slice is drawn"); assert!(!scene.lines.is_empty(), "streamlines and the part's edges are drawn"); // Pressure on the slice; the first frame (at rest at the inlet speed everywhere). s.execute("results.slice", &json!({"field": "pressure", "frame": 0})).unwrap(); let p = plot_at(&s, &base, None).unwrap(); assert_eq!((p.contour.as_ref().map(|c| c.field), p.pick.frame), (Some(Field::Pressure), Some(0))); } #[test] fn modes_swing_and_static_steps_grow_when_animated() { let mut s = solved_cantilever(); s.execute("results.modes", &json!({"mode": 1})).unwrap(); s.execute("results.animate", &json!({})).unwrap(); let base = DisplayMesh::from_model(&s.model); let view = s.results_view.clone().unwrap(); assert_eq!(frame(&view, 0.0), Some(0)); assert_eq!(frame(&view, PERIOD * 0.25), Some(FRAMES / 4)); assert_eq!(frame(&view, PERIOD * 1.25), Some(FRAMES / 4)); let (_, result, _) = shown(&s).unwrap(); assert!((swing(result, Some(FRAMES / 4)) - 1.0).abs() < 1e-12); assert!((swing(result, Some(3 * FRAMES / 4)) + 1.0).abs() < 1e-12); // At frame 0 a mode is undeformed; a quarter cycle later fully deformed. let p0 = plot_at(&s, &base, Some(0)).unwrap(); let p1 = plot_at(&s, &base, Some(FRAMES / 4)).unwrap(); assert_eq!(p0.display.tris, base.tris); assert_ne!(p1.display.tris, base.tris); assert_eq!(p1.mode().map(|(m, _)| m), Some(1)); s.execute("results.deformed", &json!({"step": "Load"})).unwrap(); let (_, bend, _) = shown(&s).unwrap(); assert_eq!(swing(bend, Some(0)), 0.0); assert!((swing(bend, Some(FRAMES / 2)) - 1.0).abs() < 1e-12); assert_eq!(swing(bend, None), 1.0); } #[test] fn numbers_read_well() { assert_eq!(number(0.0), "0"); assert_eq!(number(1.23456), "1.235"); assert_eq!(number(-4.024), "-4.024"); assert_eq!(number(123.456), "123.5"); assert_eq!(number(1.5e-6), "1.500e-6"); assert_eq!(number(2.5e7), "2.500e7"); assert_eq!(number(f64::NAN), "—"); assert_eq!(number(123_456.7), "123,457"); assert_eq!(number(-4_321.06), "-4,321"); assert_eq!(number(-0.000_000_4), "-4.000e-7"); assert_eq!(count(221_850_309), "221,850,309"); assert_eq!(count(945), "945"); // Round-off beside larger values is 0. assert_eq!(number_in(-3.16e-11, 1000.0), "0"); assert_eq!(number_in(4.34e-9, 1e5), "0"); assert_eq!(number_in(0.5, 1000.0), "0.5000"); assert_eq!(components(&[9.497e-17, -3.463e-14, -0.2415], 0.0), "0, 0, -0.2415"); assert_eq!(with_unit(number(0.1048), Some("mm")), "0.1048 mm"); } }