diff --git a/AGENTS.md b/AGENTS.md index edfffd8..2b5fb7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,8 +111,9 @@ People trust ModelCraft with their models; a crash loses their work. **This outr `{"command", "params"}`) and prints the model summary. - `cargo run --release -p modelcraft -- --sample --control PORT` opens the desktop app with the sample model and a control channel on `127.0.0.1:PORT` (docs/control-protocol.md). For UI work, - look at the result: `ui.screenshot {path}` (needs a presented frame) or `ui.render {path}` - (CPU render, no window needed), then read the PNG. + look at the result: `ui.capture {path}` (the whole window drawn offscreen; works with the + screen locked), `ui.screenshot {path}` (a presented frame) or `ui.render {path}` (the model + only), then read the PNG. - Build with `CARGO_TARGET_DIR=/target`. Parallel agents: separate target dirs, one worktree each. diff --git a/Cargo.lock b/Cargo.lock index 3d77841..f564384 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1881,6 +1881,7 @@ dependencies = [ "log", "modelcraft-decks", "modelcraft-explicit", + "modelcraft-fem", "modelcraft-geometry", "modelcraft-io", "modelcraft-mesh", @@ -1998,6 +1999,8 @@ dependencies = [ "egui-wgpu", "log", "modelcraft-engine", + "modelcraft-explicit", + "modelcraft-geometry", "modelcraft-model", "modelcraft-render", "resvg", diff --git a/ROADMAP.md b/ROADMAP.md index fc57cd5..632869a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,7 +22,7 @@ hex meshes today and it gives the best elements where stresses are read. | M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`modelcraft-mesh::surface`, `mesh.surface.check`, done) | in progress | | M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `modelcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `modelcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023: implicit LNC Steiner points on segments, gift-wrapping facet recovery, inside/outside labelling, B-rep face tags on boundary tet faces, few-ulp rounding repair checked by the validity gate; `modelcraft-mesh::cdt` and `mesh.tet.generate`, done: all 20 valid corpus parts); Delaunay refinement, ODT smoothing, sliver removal; tet4 and tet10 with mid-side nodes on the B-rep; mesh verify per set | in progress | | M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.modelcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress | -| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `modelcraft-fem` kernels, `modelcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion); deformed shape, contours, mode shapes, probes, reaction sums | started | +| M4 Own solver and first results | Element library per docs/elements.md (1D and 2D first); Linear static and modal solver (tet4/10, wedge6, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions (solver: done — `modelcraft-fem` kernels, `modelcraft-solver` with nested dissection, sparse Cholesky and block Lanczos, `solve.static`/`solve.frequency`/`solve.probe`, patch, cantilever, Lamé, Kirsch and beam-frequency tests; reference points with kinematic and distributing couplings and moments by master–slave constraint elimination, verified by rigid motion, clamping, Saint-Venant tension, bending and tube torsion); results display (done: `results.*` commands, deformed shape to scale, banded contours of displacement, von Mises, principal and component stresses with a legend, mode shapes with their frequencies and animation, probing a node by clicking it, reaction force and moment sums per set, stale results marked after an edit; explicit steps: field frames by slider or animation with contours per frame, energy histories plotted) | started | | M5 Structured hex | 2D quad meshing of faces (`modelcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions; sweep meshing; conforming interfaces and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started | | M6 Automatic hex | Octree/grid-dual all-hex for any part; hex-dominant by tet recombination; polycube for blocky parts; smoothing, untangling, boundary padding, layers through thin walls; mesh editing and orphan-mesh import | planned | | M7 Assemblies and contact | Part instances from SolidCraft occurrences; tie between non-matching meshes; general and pairwise contact with automatic pair detection, friction; bolt pretension, springs, point masses; amplitudes, multiple steps; nonlinear static steps in decks | planned | @@ -34,4 +34,4 @@ hex meshes today and it gives the best elements where stresses are read. | M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned | | M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned | | Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md) | started | -| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done), MCP server, browser build, command schema as the public API | started | +| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done), MCP server, browser build, command schema as the public API | started | diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index f3d0478..378f51e 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -16,6 +16,7 @@ modelcraft-io = { workspace = true } modelcraft-decks = { workspace = true } modelcraft-solver = { workspace = true } modelcraft-explicit = { workspace = true } +modelcraft-fem = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index c9d087b..5362fa5 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -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"), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 58fc1f7..aa4bdfb 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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). diff --git a/crates/engine/src/cmd/results.rs b/crates/engine/src/cmd/results.rs new file mode 100644 index 0000000..433da16 --- /dev/null +++ b/crates/engine/src/cmd/results.rs @@ -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 { + if let Some(name) = opt::(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 { + let step = step_for(s, p, Want::Any)?; + let show = opt::(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 { + let step = step_for(s, p, Want::Any)?; + let name = opt::(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 { + 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 { + let on = opt::(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 { + let step = step_for(s, p, Want::Explicit)?; + let times: Vec = 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::(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 { + s.results_view = None; + Ok(json!({"view": null})) +} + +fn reactions(s: &mut Session, p: &Value) -> Result { + 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 = 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::>()? + } + None => { + let mut names: Vec = 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| { + 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}"); + } + } +} diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index 2f3c33a..e63146d 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -86,13 +86,13 @@ fn run_static(s: &mut Session, p: &Value) -> Result { let limits = limits(p)?; let step = opt::(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 { fn probe(s: &mut Session, p: &Value) -> Result { let step = opt::(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] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2fc9325..ebe507e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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)>, + /// What the results display shows (`results.*`); `None` shows the model. + pub results_view: Option, } 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))); } diff --git a/crates/engine/src/results.rs b/crates/engine/src/results.rs new file mode 100644 index 0000000..19fd378 --- /dev/null +++ b/crates/engine/src/results.rs @@ -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, +} + +/// 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::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 + '_ { + 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, + /// Contours of this field; `None` draws the deformed shape in element colours. + pub contour: Option, + 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, + /// 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> { + let unavailable = || EngineError::Params(format!("{} is not available for step `{}`", field.label(), r.step())); + let component = |f: fn(&[f64; 3]) -> f64| -> Result> { Ok(displacements(r, pick)?.iter().map(f).collect()) }; + let stresses = || -> Result> { + 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> { 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, 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 { + 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![("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)))); + } +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 5da1158..b9d825a 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -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 { + 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 { (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::>()}}), + 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::>()}}), + 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}"); + } } diff --git a/crates/fem/src/lib.rs b/crates/fem/src/lib.rs index 750b57d..99fbece 100644 --- a/crates/fem/src/lib.rs +++ b/crates/fem/src/lib.rs @@ -29,7 +29,7 @@ pub mod shape; pub mod uniform; pub use element::{ELEMENT_TYPES, ElementType, Family, Formulation, PointStress, Solid, element_type}; -pub use material::{Elasticity, elasticity, von_mises}; +pub use material::{Elasticity, elasticity, principal, von_mises}; #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum Error { diff --git a/crates/fem/src/material.rs b/crates/fem/src/material.rs index 9540c43..97ff75d 100644 --- a/crates/fem/src/material.rs +++ b/crates/fem/src/material.rs @@ -71,6 +71,29 @@ pub fn von_mises(s: &[f64; 6]) -> f64 { (0.5 * normal + 3.0 * (xy * xy + xz * xz + yz * yz)).sqrt() } +/// The principal stresses of a stress 6-vector, largest first: the eigenvalues of the symmetric +/// stress tensor in closed form (O. K. Smith, "Eigenvalues of a symmetric 3 × 3 matrix", +/// Commun. ACM 4(4):168, 1961). +pub fn principal(s: &[f64; 6]) -> [f64; 3] { + let [xx, yy, zz, xy, xz, yz] = *s; + let off = xy * xy + xz * xz + yz * yz; + let q = (xx + yy + zz) / 3.0; + let (a, b, c) = (xx - q, yy - q, zz - q); + let p = ((a * a + b * b + c * c + 2.0 * off) / 6.0).sqrt(); + if !(p > 0.0) || off == 0.0 { + let mut d = [xx, yy, zz]; + d.sort_by(|u, v| v.total_cmp(u)); + return d; + } + // det(A − qI) / (2 p³), the cosine of three times the angle, within [−1, 1]. + let det = a * (b * c - yz * yz) - xy * (xy * c - yz * xz) + xz * (xy * yz - b * xz); + let r = (det / (2.0 * p * p * p)).clamp(-1.0, 1.0); + let phi = r.acos() / 3.0; + let first = q + 2.0 * p * phi.cos(); + let third = q + 2.0 * p * (phi + 2.0 * std::f64::consts::FRAC_PI_3).cos(); + [first, 3.0 * q - first - third, third] +} + /// σ = D ε. pub fn stress(d: &Elasticity, strain: &[f64; 6]) -> [f64; 6] { let mut s = [0.0; 6]; @@ -99,6 +122,31 @@ mod tests { assert!(orthotropic([1.0; 3], [0.9; 3], [1.0; 3]).is_err()); } + #[test] + fn principal_stresses_are_the_tensor_eigenvalues() { + let close = |a: [f64; 3], b: [f64; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9 * (1.0 + y.abs())); + // Diagonal: sorted, largest first. + assert_eq!(principal(&[1.0, 3.0, -2.0, 0.0, 0.0, 0.0]), [3.0, 1.0, -2.0]); + // Pure shear τ in the xy plane: ±τ and 0. + assert!(close(principal(&[0.0, 0.0, 0.0, 5.0, 0.0, 0.0]), [5.0, 0.0, -5.0])); + // Hydrostatic: three equal. + assert!(close(principal(&[-7.0, -7.0, -7.0, 0.0, 0.0, 0.0]), [-7.0; 3])); + // A general tensor: the sum is the trace, and each is a root of det(σ − λI) = 0. + let s = [10.0, -4.0, 3.0, 2.0, -1.5, 6.0]; + let l = principal(&s); + assert!(l[0] >= l[1] && l[1] >= l[2]); + assert!((l.iter().sum::() - (s[0] + s[1] + s[2])).abs() < 1e-9); + for x in l { + let (a, b, c) = (s[0] - x, s[1] - x, s[2] - x); + let det = a * (b * c - s[5] * s[5]) - s[3] * (s[3] * c - s[5] * s[4]) + s[4] * (s[3] * s[5] - b * s[4]); + assert!(det.abs() < 1e-7, "{x}: {det}"); + } + // Von Mises from the principal values agrees. + let vm = (0.5 * ((l[0] - l[1]).powi(2) + (l[1] - l[2]).powi(2) + (l[2] - l[0]).powi(2))).sqrt(); + assert!((vm - von_mises(&s)).abs() < 1e-9); + assert!(principal(&[f64::NAN; 6]).iter().all(|v| v.is_nan())); + } + #[test] fn orthotropic_uniaxial_response() { // Stress along 1 alone gives strains σ/E1, −ν12 σ/E1, −ν13 σ/E1. diff --git a/crates/render/src/contour.rs b/crates/render/src/contour.rs new file mode 100644 index 0000000..3095a25 --- /dev/null +++ b/crates/render/src/contour.rs @@ -0,0 +1,176 @@ +//! Banded contour plots: a range of nodal values cut into equal bands, each drawn in one colour. +//! +//! A triangle with values at its corners is split exactly along the band boundaries (the +//! values vary linearly across it), so every piece lies in one band and the band edges are +//! sharp iso-lines on any renderer, with no shader support: the GPU viewport and the CPU +//! rasterizer draw the same flat-coloured pieces. + +use crate::{Rgb, Vec3}; + +/// Equal bands from `min` to `max`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bands { + pub min: f64, + pub max: f64, + pub count: u32, +} + +impl Bands { + /// `count` bands over `[min, max]`; a range of one value (or none) is one band around it. + pub fn new(min: f64, max: f64, count: u32) -> Bands { + let count = count.max(1); + if !(min.is_finite() && max.is_finite()) { + return Bands { min: 0.0, max: 0.0, count }; + } + Bands { min: min.min(max), max: max.max(min), count } + } + + /// The band a value lies in (values outside the range go to the end bands); `None` for a + /// value that is not a number. + pub fn band(&self, v: f64) -> Option { + if v.is_nan() { + return None; + } + let span = self.max - self.min; + if !(span > 0.0) { + return Some(self.count / 2); + } + let k = ((v - self.min) / span * f64::from(self.count)).floor(); + Some(k.clamp(0.0, f64::from(self.count - 1)) as u32) + } + + /// The value between band `k − 1` and band `k` (`level(0)` is `min`, `level(count)` `max`). + pub fn level(&self, k: u32) -> f64 { + self.min + (self.max - self.min) * f64::from(k) / f64::from(self.count) + } +} + +/// Split a triangle (corners `p`, values `v`) into pieces that each lie in one band, and hand +/// each piece (a convex polygon of 3 to 5 corners) to `out` with its band. A triangle with a +/// value that is not a number is handed whole, with no band. +pub fn split_triangle(p: [Vec3; 3], v: [f64; 3], bands: &Bands, out: &mut impl FnMut(&[Vec3], Option)) { + let [Some(a), Some(b), Some(c)] = v.map(|x| bands.band(x)) else { + out(&p, None); + return; + }; + let (lo, hi) = (a.min(b).min(c), a.max(b).max(c)); + if lo == hi { + out(&p, Some(lo)); + return; + } + let poly: Vec<(Vec3, f64)> = p.into_iter().zip(v).collect(); + for k in lo..=hi { + // The end bands also hold the values beyond the range. + let below = if k == 0 { f64::NEG_INFINITY } else { bands.level(k) }; + let above = if k + 1 == bands.count { f64::INFINITY } else { bands.level(k + 1) }; + let piece = clip(&clip(&poly, below, true), above, false); + if piece.len() >= 3 { + let pts: Vec = piece.iter().map(|(q, _)| *q).collect(); + out(&pts, Some(k)); + } + } +} + +/// The part of a convex polygon where the value is at least (`keep_above`) or at most `level` +/// (Sutherland–Hodgman against one level). +fn clip(poly: &[(Vec3, f64)], level: f64, keep_above: bool) -> Vec<(Vec3, f64)> { + if level.is_infinite() { + return poly.to_vec(); + } + let inside = |v: f64| if keep_above { v >= level } else { v <= level }; + let mut out = Vec::with_capacity(poly.len() + 1); + for (i, &(a, va)) in poly.iter().enumerate() { + let Some(&(b, vb)) = poly.get((i + 1) % poly.len()) else { continue }; + if inside(va) { + out.push((a, va)); + } + if inside(va) != inside(vb) { + let t = (level - va) / (vb - va); + out.push((a + (b - a) * t, level)); + } + } + out +} + +/// The colour of band `k` of `count`: the colour scale `stops` (low to high) sampled at the +/// band's middle. +pub fn band_color(stops: &[Rgb], k: u32, count: u32) -> Rgb { + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Rgb(128, 128, 128) }; + if stops.len() == 1 { + return *first; + } + let count = count.max(1); + let t = (f64::from(k.min(count - 1)) + 0.5) / f64::from(count) * (stops.len() - 1) as f64; + let i = (t.floor() as usize).min(stops.len() - 2); + let f = t - i as f64; + let (Some(a), Some(b)) = (stops.get(i), stops.get(i + 1)) else { return *last }; + let mix = |x: u8, y: u8| (f64::from(x) + (f64::from(y) - f64::from(x)) * f).round().clamp(0.0, 255.0) as u8; + Rgb(mix(a.0, b.0), mix(a.1, b.1), mix(a.2, b.2)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn area(p: &[Vec3]) -> f64 { + let mut n = Vec3::ZERO; + for i in 1..p.len() - 1 { + n = n + (p[i] - p[0]).cross(p[i + 1] - p[0]); + } + n.len() * 0.5 + } + + #[test] + fn bands_and_levels() { + let b = Bands::new(0.0, 10.0, 5); + assert_eq!( + (b.band(0.0), b.band(1.99), b.band(2.0), b.band(10.0), b.band(-5.0), b.band(99.0)), + (Some(0), Some(0), Some(1), Some(4), Some(0), Some(4)) + ); + assert_eq!((b.level(0), b.level(5)), (0.0, 10.0)); + assert_eq!(b.band(f64::NAN), None); + // One value: one band in the middle; reversed and non-finite ranges are made safe. + assert_eq!(Bands::new(3.0, 3.0, 6).band(3.0), Some(3)); + assert_eq!(Bands::new(5.0, 1.0, 4), Bands::new(1.0, 5.0, 4)); + assert_eq!(Bands::new(f64::NAN, 1.0, 0).count, 1); + } + + #[test] + fn a_triangle_splits_along_the_levels_and_keeps_its_area() { + let p = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0), Vec3::new(0.0, 10.0, 0.0)]; + // The value is x: pieces are strips between x = 0, 2, 4, … + let b = Bands::new(0.0, 10.0, 5); + let mut pieces = Vec::new(); + split_triangle(p, [0.0, 10.0, 0.0], &b, &mut |q, k| pieces.push((q.to_vec(), k))); + assert_eq!(pieces.iter().map(|(_, k)| k.unwrap()).collect::>(), vec![0, 1, 2, 3, 4]); + let total: f64 = pieces.iter().map(|(q, _)| area(q)).sum(); + assert!((total - 50.0).abs() < 1e-9, "{total}"); + for (q, k) in &pieces { + let k = k.unwrap() as f64; + assert!(q.iter().all(|c| c.x >= 2.0 * k - 1e-9 && c.x <= 2.0 * k + 2.0 + 1e-9), "{q:?} in band {k}"); + } + // A band-uniform triangle stays whole; a NaN value hands it over with no band. + let mut n = 0; + split_triangle(p, [0.5, 1.0, 1.5], &b, &mut |q, k| { + n += 1; + assert_eq!((q.len(), k), (3, Some(0))); + }); + split_triangle(p, [0.5, f64::NAN, 1.5], &b, &mut |_, k| { + n += 1; + assert_eq!(k, None); + }); + assert_eq!(n, 2); + } + + #[test] + fn band_colours_run_through_the_stops() { + let stops = [Rgb(0, 0, 255), Rgb(0, 255, 0), Rgb(255, 0, 0)]; + assert_eq!(band_color(&stops, 0, 2), Rgb(0, 128, 128)); + assert_eq!(band_color(&stops, 1, 2), Rgb(128, 128, 0)); + let first = band_color(&stops, 0, 12); + let last = band_color(&stops, 11, 12); + assert!(first.2 > 200 && last.0 > 200, "{first:?} {last:?}"); + assert_eq!(band_color(&[], 0, 3), Rgb(128, 128, 128)); + assert_eq!(band_color(&stops, 99, 3), band_color(&stops, 2, 3)); + } +} diff --git a/crates/render/src/display.rs b/crates/render/src/display.rs index e713ae1..a66e294 100644 --- a/crates/render/src/display.rs +++ b/crates/render/src/display.rs @@ -1,43 +1,77 @@ -//! What a finite element model looks like on screen: the faces nobody shares (the skin of the -//! solid elements, and every shell), the element edges on those faces, line elements and nodes. +//! What a model looks like on screen. //! -//! Faces shared by two solid elements are inside the mesh and are left out, so a model of a -//! million elements draws its surface, not its interior. Higher-order elements (tet10, hex20, -//! tri6, quad8) draw through their corner nodes. The output is deterministic: faces come out in -//! element label order, edges sorted. +//! Finite elements: the faces nobody shares (the skin of the solid elements, and every shell), +//! the element edges on those faces, line elements and nodes. Faces shared by two solid elements +//! are inside the mesh and are left out, so a model of a million elements draws its surface, not +//! its interior. Higher-order elements (tet10, hex20, tri6, quad8) draw through their corner +//! nodes. Every element primitive keeps its node labels, so results (displacements, contours) +//! can be drawn on it ([`DisplayMesh::displaced`]). +//! +//! Geometry: triangulated boundary surfaces (tessellated bodies, imported surfaces) added with +//! [`DisplayMesh::add_surface`], drawn with the edges between their faces. +//! +//! The output is deterministic: elements come out in label order, edges sorted. use modelcraft_model::{ElementKind, Model}; use crate::{Aabb3, Vec3}; -/// One drawn triangle. +/// One drawn triangle of an element face. #[derive(Clone, Copy, Debug, PartialEq)] pub struct DisplayTri { pub p: [Vec3; 3], + /// The node labels at the corners. + pub nodes: [u32; 3], /// The face's unit normal, pointing out of its element (a shell's own orientation). pub normal: Vec3, pub kind: ElementKind, } +/// An element edge. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DisplayEdge { + pub p: [Vec3; 2], + pub nodes: [u32; 2], +} + /// A line element. #[derive(Clone, Copy, Debug, PartialEq)] pub struct DisplayLine { pub a: Vec3, pub b: Vec3, + pub nodes: [u32; 2], pub kind: ElementKind, } -/// Display data for a model. +/// A node. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DisplayNode { + pub label: u32, + pub p: Vec3, +} + +/// A triangle of a geometry surface. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SurfaceTri { + pub p: [Vec3; 3], + pub normal: Vec3, +} + +/// Display data for a model and its geometry. #[derive(Clone, Debug, Default, PartialEq)] pub struct DisplayMesh { - /// Visible faces as triangles (quads are split in two). + /// Visible element faces as triangles (quads are split in two). pub tris: Vec, /// Element edges on the visible faces, each once. - pub edges: Vec<[Vec3; 2]>, + pub edges: Vec, /// Line elements (rods, beams). pub lines: Vec, - /// Every node. - pub nodes: Vec, + /// Every node, in label order. + pub nodes: Vec, + /// Geometry surface triangles. + pub surface_tris: Vec, + /// Geometry edges: where two faces meet, and open boundaries. + pub surface_edges: Vec<[Vec3; 2]>, pub bounds: Aabb3, } @@ -86,9 +120,9 @@ impl DisplayMesh { pub fn from_model(model: &Model) -> DisplayMesh { let mut out = DisplayMesh::default(); let pos = |label: u32| model.node(label).map(|n| Vec3::from(n.xyz)); - for n in model.nodes().values() { + for (&label, n) in model.nodes() { let p = Vec3::from(n.xyz); - out.nodes.push(p); + out.nodes.push(DisplayNode { label, p }); out.bounds.add(p); } // Faces two solid elements share are inside the mesh: sort every solid face's key and @@ -120,8 +154,8 @@ impl DisplayMesh { } let at = at.get(..corners).unwrap_or(&[]); if e.kind.dimension() == 1 { - if let (Some(&a), Some(&b)) = (at.first(), at.get(1)) { - out.lines.push(DisplayLine { a, b, kind: e.kind }); + if let (Some(&a), Some(&b), Some(&na), Some(&nb)) = (at.first(), at.get(1), e.nodes.first(), e.nodes.get(1)) { + out.lines.push(DisplayLine { a, b, nodes: [na, nb], kind: e.kind }); } continue; } @@ -132,13 +166,16 @@ impl DisplayMesh { continue; } let p: Vec = f.iter().filter_map(|&i| at.get(i).copied()).collect(); + let labels: Vec = f.iter().filter_map(|&i| e.nodes.get(i).copied()).collect(); let Some(mut normal) = polygon_normal(&p) else { continue }; if solid && normal.dot(centre(&p) - element_centre) < 0.0 { normal = -normal; } for k in 1..p.len().saturating_sub(1) { - if let (Some(&a), Some(&b), Some(&c)) = (p.first(), p.get(k), p.get(k + 1)) { - out.tris.push(DisplayTri { p: [a, b, c], normal, kind: e.kind }); + if let (Some(&a), Some(&b), Some(&c), Some(&la), Some(&lb), Some(&lc)) = + (p.first(), p.get(k), p.get(k + 1), labels.first(), labels.get(k), labels.get(k + 1)) + { + out.tris.push(DisplayTri { p: [a, b, c], nodes: [la, lb, lc], normal, kind: e.kind }); } } for (j, &i) in f.iter().enumerate() { @@ -151,7 +188,116 @@ impl DisplayMesh { } edges.sort_unstable(); edges.dedup(); - out.edges = edges.into_iter().filter_map(|(a, b)| Some([pos(a)?, pos(b)?])).collect(); + out.edges = edges.into_iter().filter_map(|(a, b)| Some(DisplayEdge { p: [pos(a)?, pos(b)?], nodes: [a, b] })).collect(); + out + } + + /// Add a triangulated geometry surface: `positions`, counter-clockwise `triangles` seen from + /// outside, and the face each triangle lies on. Edges are drawn where triangles of different + /// faces meet and along open boundaries; degenerate triangles and out-of-range indices are + /// skipped. + pub fn add_surface(&mut self, positions: &[[f64; 3]], triangles: &[[u32; 3]], tri_face: &[u32]) { + let pos = |i: u32| positions.get(i as usize).map(|p| Vec3::from(*p)).filter(|p| p.is_finite()); + // Tessellations may repeat a point for each triangle or face: copies with identical + // coordinates are one vertex (the lowest index of the copies). + let mut order: Vec = (0..u32::try_from(positions.len()).unwrap_or(u32::MAX)).collect(); + let coords = |i: &u32| positions.get(*i as usize).copied().unwrap_or([f64::NAN; 3]); + order.sort_by(|a, b| { + let (p, q) = (coords(a), coords(b)); + p[0].total_cmp(&q[0]).then(p[1].total_cmp(&q[1])).then(p[2].total_cmp(&q[2])).then(a.cmp(b)) + }); + let mut vertex: Vec = (0..order.len()).map(|i| i as u32).collect(); + for run in order.chunk_by(|a, b| coords(a).map(f64::to_bits) == coords(b).map(f64::to_bits)) { + let first = run.first().copied().unwrap_or(0); + for &i in run { + if let Some(v) = vertex.get_mut(i as usize) { + *v = first; + } + } + } + let vertex = |i: u32| vertex.get(i as usize).copied().unwrap_or(i); + // (low vertex, high vertex, face) per triangle side; a side alone, or sides on + // different faces, make an edge to draw. + let mut sides: Vec<(u32, u32, u32)> = Vec::with_capacity(triangles.len().saturating_mul(3)); + for (t, &[i0, i1, i2]) in triangles.iter().enumerate() { + let (Some(a), Some(b), Some(c)) = (pos(i0), pos(i1), pos(i2)) else { continue }; + let Some(normal) = (b - a).cross(c - a).normalized() else { continue }; + for p in [a, b, c] { + self.bounds.add(p); + } + self.surface_tris.push(SurfaceTri { p: [a, b, c], normal }); + let face = tri_face.get(t).copied().unwrap_or(u32::MAX); + let [v0, v1, v2] = [vertex(i0), vertex(i1), vertex(i2)]; + for (u, v) in [(v0, v1), (v1, v2), (v2, v0)] { + sides.push((u.min(v), u.max(v), face)); + } + } + sides.sort_unstable(); + for run in sides.chunk_by(|a, b| a.0 == b.0 && a.1 == b.1) { + let Some(&(u, v, face)) = run.first() else { continue }; + if (run.len() == 1 || run.iter().any(|s| s.2 != face)) + && let (Some(a), Some(b)) = (pos(u), pos(v)) + { + self.surface_edges.push([a, b]); + } + } + } + + /// The node nearest to where a ray (`origin`, `dir`) first hits an element face ahead of + /// its origin: the hit face's closest corner, and the hit point. Line elements and geometry + /// are not hit. + pub fn pick(&self, origin: Vec3, dir: Vec3) -> Option<(u32, Vec3)> { + let mut best: Option<(f64, &DisplayTri)> = None; + for t in &self.tris { + // Möller–Trumbore, both sides. + let (e1, e2) = (t.p[1] - t.p[0], t.p[2] - t.p[0]); + let h = dir.cross(e2); + let det = e1.dot(h); + if !(det.abs() > 1e-300) { + continue; + } + let s = origin - t.p[0]; + let u = s.dot(h) / det; + let q = s.cross(e1); + let v = dir.dot(q) / det; + let d = e2.dot(q) / det; + if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && d >= 0.0 && d.is_finite() && best.is_none_or(|(b, _)| d < b) { + best = Some((d, t)); + } + } + let (d, t) = best?; + let hit = origin + dir * d; + let (i, _) = t.p.iter().enumerate().min_by(|a, b| a.1.dist(hit).total_cmp(&b.1.dist(hit)))?; + Some((*t.nodes.get(i)?, hit)) + } + + /// The elements moved by `offset(label)` (no offset for a node it has none for): faces, + /// edges, line elements and nodes move, face normals follow the deformation. Geometry + /// surfaces are left out (they do not deform). + pub fn displaced(&self, offset: impl Fn(u32) -> Option) -> DisplayMesh { + let at = |p: Vec3, label: u32| p + offset(label).filter(|d| d.is_finite()).unwrap_or(Vec3::ZERO); + let tris = self + .tris + .iter() + .map(|t| { + let p = [at(t.p[0], t.nodes[0]), at(t.p[1], t.nodes[1]), at(t.p[2], t.nodes[2])]; + // The triangle's own normal, on the side its face's normal was. + let before = (t.p[1] - t.p[0]).cross(t.p[2] - t.p[0]); + let side = if before.dot(t.normal) < 0.0 { -1.0 } else { 1.0 }; + let normal = ((p[1] - p[0]).cross(p[2] - p[0]) * side).normalized().unwrap_or(t.normal); + DisplayTri { p, normal, ..*t } + }) + .collect(); + let mut out = DisplayMesh { + tris, + edges: self.edges.iter().map(|e| DisplayEdge { p: [at(e.p[0], e.nodes[0]), at(e.p[1], e.nodes[1])], nodes: e.nodes }).collect(), + lines: self.lines.iter().map(|l| DisplayLine { a: at(l.a, l.nodes[0]), b: at(l.b, l.nodes[1]), ..*l }).collect(), + nodes: self.nodes.iter().map(|n| DisplayNode { label: n.label, p: at(n.p, n.label) }).collect(), + ..Default::default() + }; + for n in &out.nodes { + out.bounds.add(n.p); + } out } } @@ -226,6 +372,66 @@ mod tests { assert_eq!(d.edges.len(), 6 + 2); let tet_normals: Vec = d.tris.iter().filter(|t| t.kind == ElementKind::Tet4).map(|t| t.normal).collect(); assert!(tet_normals.contains(&Vec3::new(0.0, 0.0, -1.0))); + assert_eq!(d.lines[0].nodes, [4, 5]); + assert!(d.tris.iter().all(|t| t.nodes.iter().zip(t.p).all(|(&l, p)| Vec3::from(m.node(l).unwrap().xyz) == p))); + } + + #[test] + fn surfaces_draw_their_face_boundaries() { + // A unit square in two faces (one triangle each), plus a degenerate and an out-of-range + // triangle, which are skipped. + let p = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]; + let mut d = DisplayMesh::default(); + d.add_surface(&p, &[[0, 1, 2], [0, 2, 3], [0, 0, 1], [0, 1, 9]], &[0, 1, 1, 1]); + assert_eq!(d.surface_tris.len(), 2); + // Four open sides and the diagonal between the two faces. + assert_eq!(d.surface_edges.len(), 5); + let mut one_face = DisplayMesh::default(); + one_face.add_surface(&p, &[[0, 1, 2], [0, 2, 3]], &[0, 0]); + assert_eq!(one_face.surface_edges.len(), 4); + assert_eq!(one_face.bounds.max, Vec3::new(1.0, 1.0, 0.0)); + // A triangle soup (each triangle with its own copies of the corners) is welded first. + let soup = [p[0], p[1], p[2], p[0], p[2], p[3]]; + let mut welded = DisplayMesh::default(); + welded.add_surface(&soup, &[[0, 1, 2], [3, 4, 5]], &[0, 0]); + assert_eq!(welded.surface_edges.len(), 4); + let mut hostile = DisplayMesh::default(); + hostile.add_surface(&[[f64::NAN, 0., 0.], [1., 0., 0.], [0., 1., 0.]], &[[0, 1, 2], [u32::MAX, 0, 1]], &[]); + assert!(hostile.surface_tris.is_empty() && hostile.surface_edges.is_empty()); + } + + #[test] + fn displacing_moves_elements_by_node_and_keeps_normals_outward() { + let d = DisplayMesh::from_model(&block(2, 1, 1)); + // Stretch along x by 50 %: node labels carry the offset to every primitive. + let m = block(2, 1, 1); + let moved = d.displaced(|label| m.node(label).map(|n| Vec3::new(n.xyz[0] * 0.5, 0.0, 0.0))); + assert_eq!(moved.tris.len(), d.tris.len()); + assert_eq!(moved.bounds.max, Vec3::new(3.0, 1.0, 1.0)); + assert!(moved.edges.iter().all(|e| e.p[0].x == 1.5 * m.node(e.nodes[0]).unwrap().xyz[0])); + let c = moved.bounds.center(); + for t in &moved.tris { + let fc = (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0); + assert!(t.normal.dot(fc - c) > 0.0, "{t:?}"); + } + // No offset, or a non-finite one, leaves a node where it is. + let same = d.displaced(|label| (label == 1).then_some(Vec3::new(f64::NAN, 0.0, 0.0))); + assert_eq!(same.tris, d.tris); + } + + #[test] + fn picking_finds_the_nearest_corner_of_the_face_hit_first() { + let m = block(2, 1, 1); + let d = DisplayMesh::from_model(&m); + // From above, aimed near the corner (2, 1, 1): the top face is hit, not the bottom. + let (label, hit) = d.pick(Vec3::new(1.9, 0.9, 10.0), Vec3::new(0.0, 0.0, -1.0)).unwrap(); + assert_eq!(m.node(label).unwrap().xyz, [2.0, 1.0, 1.0]); + assert!((hit.z - 1.0).abs() < 1e-12); + // From below the bottom face wins. + let (label, _) = d.pick(Vec3::new(0.1, 0.1, -10.0), Vec3::new(0.0, 0.0, 1.0)).unwrap(); + assert_eq!(m.node(label).unwrap().xyz, [0.0, 0.0, 0.0]); + assert_eq!(d.pick(Vec3::new(5.0, 5.0, 10.0), Vec3::new(0.0, 0.0, -1.0)), None); + assert_eq!(d.pick(Vec3::new(f64::NAN, 0.0, 0.0), Vec3::new(0.0, 0.0, -1.0)), None); } #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8abb29f..cc28809 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6,12 +6,13 @@ #![forbid(unsafe_code)] mod camera; +pub mod contour; mod display; mod raster; mod vec; pub use camera::{Camera, Mat4, StandardView}; -pub use display::{DisplayLine, DisplayMesh, DisplayTri}; +pub use display::{DisplayEdge, DisplayLine, DisplayMesh, DisplayNode, DisplayTri, SurfaceTri}; pub use raster::{ Canvas, MAX_DIM, MAX_PIXELS, MAX_PRIMITIVES, RenderError, Rgb, Scene, SceneLine, ScenePoint, SceneTri, fit_size, png, render, render_png, shade, }; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index b8aeb7a..3a0d261 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -12,9 +12,12 @@ workspace = true modelcraft-model = { workspace = true } modelcraft-engine = { workspace = true } modelcraft-render = { workspace = true } +modelcraft-explicit = { workspace = true } +modelcraft-geometry = { workspace = true } egui = { workspace = true } egui-wgpu = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } resvg = { workspace = true } + diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs new file mode 100644 index 0000000..78b4272 --- /dev/null +++ b/crates/ui-egui/src/capture.rs @@ -0,0 +1,365 @@ +//! The whole window drawn without a window: the app's UI pass run in an offscreen egui context, +//! tessellated, and rasterised on the CPU (panels, toolbar, dialogs, and the viewport through the +//! CPU model renderer). +//! +//! `ui.screenshot` needs a presented frame, and a hidden window or a locked screen (macOS marks +//! the window occluded) presents none: eframe then skips the UI pass. `ui.capture` works in any +//! of those states, and headless tests use it to look at the real UI. +//! +//! The offscreen context keeps its own textures (font atlas, icons, the viewport image) and +//! memory between captures. Its rasteriser follows egui's painting contract: premultiplied sRGBA +//! vertex colours multiplied by the texel, blended "over" in gamma space, triangles clipped to +//! their clip rectangle; egui's feathering supplies the anti-aliasing. + +use std::collections::HashMap; + +use egui::{Color32, ColorImage, Pos2, Rect, TextureId, ViewportId, ViewportInfo}; + +use crate::icons::IconCache; +use crate::theme::Tokens; +use crate::{ModelApp, Viewport}; + +/// Largest capture side in physical pixels. +pub const MAX_SIDE: usize = 8192; +/// Most physical pixels in one capture. +pub const MAX_PIXELS: usize = 4096 * 4096; +/// Pixels per point a capture may ask for. +pub const SCALE_RANGE: std::ops::RangeInclusive = 0.5..=4.0; +/// Frames run before the one rasterised: egui sizes some panels and windows in a first pass. +const WARMUP_FRAMES: usize = 2; + +/// A texture the offscreen context uploaded. +struct Texture { + size: [usize; 2], + pixels: Vec, + linear: bool, +} + +/// The offscreen context and what belongs to it (textures, icon textures, the viewport image), +/// kept between captures. +#[derive(Default)] +pub struct Offscreen { + ctx: egui::Context, + textures: HashMap, + icons: IconCache, + viewport: Viewport, +} + +/// A capture's size: points and pixels per point. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureSize { + pub width: f32, + pub height: f32, + pub scale: f32, +} + +impl CaptureSize { + /// The image size in physical pixels, or why it is refused. + pub fn pixels(&self) -> Result<[usize; 2], String> { + if !SCALE_RANGE.contains(&self.scale) { + return Err(format!("`scale` must be from {} to {}", SCALE_RANGE.start(), SCALE_RANGE.end())); + } + let px = |v: f32| (v * self.scale).round(); + let (w, h) = (px(self.width), px(self.height)); + let ok = |v: f32| v.is_finite() && (1.0..=MAX_SIDE as f32).contains(&v); + if !(ok(w) && ok(h)) || (w as usize).saturating_mul(h as usize) > MAX_PIXELS { + return Err(format!( + "capture size {} x {} points at scale {} is out of range (at most {MAX_SIDE} pixels a side, {MAX_PIXELS} in all)", + self.width, self.height, self.scale + )); + } + Ok([w as usize, h as usize]) + } +} + +impl ModelApp { + /// The whole window as it would be drawn at `size`, rendered offscreen. + pub fn capture(&mut self, size: CaptureSize) -> Result { + let [w, h] = size.pixels()?; + let mut off = self.offscreen.take().unwrap_or_default(); + // The UI pass draws with the offscreen context's own icon textures and a viewport with no + // GPU target, which renders the model on the CPU into an offscreen texture. + std::mem::swap(&mut self.icons, &mut off.icons); + std::mem::swap(&mut self.viewport, &mut off.viewport); + let toolbar = std::mem::take(&mut self.toolbar_rects); + crate::theme::apply(&off.ctx, self.ui.dark); + let mut shapes = Vec::new(); + let mut ppp = size.scale; + // Textures freed by the frame painted, released after it is painted. + let mut free = Vec::new(); + for _ in 0..WARMUP_FRAMES { + let mut raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(size.width, size.height))), + time: Some(self.now), + ..Default::default() + }; + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(size.scale), ..Default::default() }); + let mut out = off.ctx.run_ui(raw, |ui| self.ui(ui)); + off.free(free.drain(..)); + let mut delta = std::mem::take(&mut out.textures_delta); + for (id, deltas) in &delta.set { + for d in deltas { + off.set_texture(*id, d); + } + } + free.extend(delta.free.iter().copied()); + // Every delta is applied (egui checks that none is dropped unhandled). + delta.clear(); + shapes = out.shapes; + ppp = out.pixels_per_point; + } + std::mem::swap(&mut self.icons, &mut off.icons); + std::mem::swap(&mut self.viewport, &mut off.viewport); + self.toolbar_rects = toolbar; + let primitives = off.ctx.tessellate(shapes, ppp); + let mut canvas = Canvas::new(w, h, Tokens::get(self.ui.dark).panel)?; + for p in &primitives { + if let egui::epaint::Primitive::Mesh(mesh) = &p.primitive { + canvas.mesh(mesh, p.clip_rect, ppp, off.textures.get(&mesh.texture_id)); + } + } + off.free(free.drain(..)); + self.offscreen = Some(off); + Ok(canvas.into_image()) + } +} + +impl Offscreen { + fn set_texture(&mut self, id: TextureId, d: &egui::epaint::ImageDelta) { + let egui::ImageData::Color(image) = &d.image; + let linear = d.options.magnification == egui::TextureFilter::Linear; + match d.pos { + None => { + self.textures.insert(id, Texture { size: image.size, pixels: image.pixels.clone(), linear }); + } + Some([x0, y0]) => { + let Some(t) = self.textures.get_mut(&id) else { return }; + let [pw, ph] = image.size; + for y in 0..ph { + for x in 0..pw { + let (tx, ty) = (x0 + x, y0 + y); + if tx < t.size[0] + && ty < t.size[1] + && let (Some(dst), Some(src)) = (t.pixels.get_mut(ty * t.size[0] + tx), image.pixels.get(y * pw + x)) + { + *dst = *src; + } + } + } + } + } + } + + fn free(&mut self, ids: impl IntoIterator) { + for id in ids { + self.textures.remove(&id); + } + } +} + +/// Premultiplied RGBA in gamma space, 0–255 per channel. +type Rgba = [f32; 4]; + +fn rgba(c: Color32) -> Rgba { + [f32::from(c.r()), f32::from(c.g()), f32::from(c.b()), f32::from(c.a())] +} + +impl Texture { + /// The texel at `uv` (clamped to the edge), bilinear for a linear texture. + fn sample(&self, u: f32, v: f32) -> Rgba { + let [w, h] = self.size; + let at = |x: usize, y: usize| self.pixels.get(y.min(h.saturating_sub(1)) * w + x.min(w.saturating_sub(1))).copied().map_or([0.0; 4], rgba); + let (x, y) = (u * w as f32 - 0.5, v * h as f32 - 0.5); + if !self.linear || !(x.is_finite() && y.is_finite()) { + let (x, y) = ((u * w as f32).max(0.0) as usize, (v * h as f32).max(0.0) as usize); + return at(x, y); + } + let (x0, y0) = (x.floor(), y.floor()); + let (fx, fy) = (x - x0, y - y0); + let (xa, ya) = (x0.max(0.0) as usize, y0.max(0.0) as usize); + let (xb, yb) = ((x0 + 1.0).max(0.0) as usize, (y0 + 1.0).max(0.0) as usize); + let (a, b, c, d) = (at(xa, ya), at(xb, ya), at(xa, yb), at(xb, yb)); + std::array::from_fn(|k| { + let top = a[k] + (b[k] - a[k]) * fx; + let bottom = c[k] + (d[k] - c[k]) * fx; + top + (bottom - top) * fy + }) + } +} + +/// The capture's pixels. +struct Canvas { + w: usize, + h: usize, + px: Vec, +} + +impl Canvas { + fn new(w: usize, h: usize, background: Color32) -> Result { + let n = w.checked_mul(h).ok_or("capture too large")?; + let mut px = Vec::new(); + px.try_reserve_exact(n).map_err(|_| "not enough memory for the capture".to_string())?; + px.resize(n, rgba(background)); + Ok(Canvas { w, h, px }) + } + + /// Rasterise a mesh (positions in points) inside `clip` (points). + fn mesh(&mut self, mesh: &egui::Mesh, clip: Rect, ppp: f32, texture: Option<&Texture>) { + // Clip rectangles snap to whole pixels, as a GPU scissor rectangle does. + let cx0 = (clip.min.x * ppp).round().clamp(0.0, self.w as f32); + let cy0 = (clip.min.y * ppp).round().clamp(0.0, self.h as f32); + let cx1 = (clip.max.x * ppp).round().clamp(0.0, self.w as f32); + let cy1 = (clip.max.y * ppp).round().clamp(0.0, self.h as f32); + if !(cx0 < cx1 && cy0 < cy1) { + return; + } + for &[i, j, k] in mesh.indices.as_chunks::<3>().0 { + let v = |i: u32| mesh.vertices.get(i as usize); + let (Some(a), Some(b), Some(c)) = (v(i), v(j), v(k)) else { continue }; + self.triangle([a, b, c], [cx0, cy0, cx1, cy1], ppp, texture); + } + } + + fn triangle(&mut self, v: [&egui::epaint::Vertex; 3], clip: [f32; 4], ppp: f32, texture: Option<&Texture>) { + let p = v.map(|v| [v.pos.x * ppp, v.pos.y * ppp]); + let [a, b, c] = p; + let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + if !(area.abs() > 1e-6) { + return; + } + let x0 = a[0].min(b[0]).min(c[0]).floor().max(clip[0]); + let x1 = a[0].max(b[0]).max(c[0]).ceil().min(clip[2]); + let y0 = a[1].min(b[1]).min(c[1]).floor().max(clip[1]); + let y1 = a[1].max(b[1]).max(c[1]).ceil().min(clip[3]); + if !(x0 < x1 && y0 < y1) { + return; + } + // Edge functions oriented so inside is positive; the top-left rule gives a pixel centre + // on an edge shared by two triangles to exactly one of them. + let s = area.signum(); + let edge = |p: [f32; 2], q: [f32; 2], x: f32, y: f32| s * ((q[0] - p[0]) * (y - p[1]) - (q[1] - p[1]) * (x - p[0])); + let top_left = |p: [f32; 2], q: [f32; 2]| { + let (dx, dy) = (s * (q[0] - p[0]), s * (q[1] - p[1])); + (dy == 0.0 && dx < 0.0) || dy > 0.0 + }; + let bias = [top_left(b, c), top_left(c, a), top_left(a, b)]; + let col = v.map(|v| rgba(v.color)); + let uv = v.map(|v| [v.uv.x, v.uv.y]); + let inv = 1.0 / area.abs(); + for y in y0 as usize..y1 as usize { + for x in x0 as usize..x1 as usize { + let (px, py) = (x as f32 + 0.5, y as f32 + 0.5); + let w = [edge(b, c, px, py), edge(c, a, px, py), edge(a, b, px, py)]; + if w.iter().zip(bias).any(|(w, tl)| *w < 0.0 || (*w == 0.0 && !tl)) { + continue; + } + let l = w.map(|w| w * inv); + let mut src: Rgba = std::array::from_fn(|k| l[0] * col[0][k] + l[1] * col[1][k] + l[2] * col[2][k]); + if let Some(t) = texture { + let (u, vv) = (l[0] * uv[0][0] + l[1] * uv[1][0] + l[2] * uv[2][0], l[0] * uv[0][1] + l[1] * uv[1][1] + l[2] * uv[2][1]); + let texel = t.sample(u, vv); + for k in 0..4 { + src[k] *= texel[k] / 255.0; + } + } + if let Some(d) = self.px.get_mut(y * self.w + x) { + let keep = 1.0 - src[3] / 255.0; + for k in 0..4 { + d[k] = src[k] + d[k] * keep; + } + } + } + } + } + + fn into_image(self) -> ColorImage { + let pixels = self + .px + .iter() + .map(|p| { + Color32::from_rgb(p[0].round().clamp(0.0, 255.0) as u8, p[1].round().clamp(0.0, 255.0) as u8, p[2].round().clamp(0.0, 255.0) as u8) + }) + .collect(); + ColorImage { size: [self.w, self.h], source_size: egui::vec2(self.w as f32, self.h as f32), pixels } + } +} + +#[cfg(test)] +mod tests { + use modelcraft_engine::Session; + + use super::*; + + fn size(width: f32, height: f32) -> CaptureSize { + CaptureSize { width, height, scale: 1.0 } + } + + fn pixel(image: &ColorImage, x: usize, y: usize) -> Color32 { + image.pixels[y * image.size[0] + x] + } + + #[test] + fn a_textured_mesh_is_clipped_and_blended() { + let mut c = Canvas::new(8, 8, Color32::BLACK).unwrap(); + let mut mesh = egui::Mesh::default(); + mesh.add_colored_rect(Rect::from_min_max(Pos2::ZERO, egui::pos2(8.0, 8.0)), Color32::from_rgba_premultiplied(100, 0, 0, 128)); + c.mesh(&mesh, Rect::from_min_max(Pos2::ZERO, egui::pos2(4.0, 8.0)), 1.0, None); + let image = c.into_image(); + // Half-transparent red over black inside the clip rectangle, black outside. + assert_eq!(pixel(&image, 1, 1), Color32::from_rgb(100, 0, 0)); + assert_eq!(pixel(&image, 6, 1), Color32::BLACK); + // The two triangles of the rectangle meet on its diagonal without blending twice. + assert_eq!(pixel(&image, 2, 2), Color32::from_rgb(100, 0, 0)); + let mut c = Canvas::new(4, 4, Color32::BLACK).unwrap(); + let t = Texture { size: [1, 1], pixels: vec![Color32::from_rgb(0, 200, 0)], linear: true }; + let mut mesh = egui::Mesh::with_texture(TextureId::default()); + mesh.add_rect_with_uv( + Rect::from_min_max(Pos2::ZERO, egui::pos2(4.0, 4.0)), + Rect::from_min_max(Pos2::ZERO, egui::pos2(1.0, 1.0)), + Color32::WHITE, + ); + c.mesh(&mesh, Rect::EVERYTHING, 1.0, Some(&t)); + assert_eq!(pixel(&c.into_image(), 3, 3), Color32::from_rgb(0, 200, 0)); + } + + #[test] + fn the_whole_window_is_captured_with_panels_and_viewport() { + let mut app = ModelApp::new(Session::default()); + app.run_script(&modelcraft_engine::sample::script()).unwrap(); + let image = app.capture(size(900.0, 600.0)).unwrap(); + assert_eq!(image.size, [900, 600]); + let t = Tokens::dark(); + // The menu bar along the top, the status bar along the bottom (their own colour). + let bar = pixel(&image, 450, 595); + assert_eq!(bar, t.app_bar, "status bar"); + // The viewport shows the model: the middle is neither panel nor background. + let middle = pixel(&image, 450, 330); + assert!(middle != t.panel && middle != t.app_bar, "{middle:?}"); + // Text was drawn: the menu bar has pixels brighter than its fill. + let bright = (0..300).any(|x| (2..20).any(|y| pixel(&image, x, y).r() > 150)); + assert!(bright, "menu text"); + // The live window's state is untouched. + assert!(app.viewport.rect.is_none() && app.toolbar_rects.is_empty()); + // A second capture reuses the offscreen context; at scale 2 it doubles the pixels. + let twice = app.capture(CaptureSize { width: 450.0, height: 300.0, scale: 2.0 }).unwrap(); + assert_eq!(twice.size, [900, 600]); + } + + #[test] + fn hostile_capture_sizes_are_refused() { + let mut app = ModelApp::new(Session::default()); + for s in [ + size(0.0, 10.0), + size(f32::NAN, 10.0), + size(f32::INFINITY, 10.0), + size(-5.0, 10.0), + size(10_000.0, 10.0), + size(8000.0, 8000.0), + CaptureSize { width: 10.0, height: 10.0, scale: 0.0 }, + CaptureSize { width: 10.0, height: 10.0, scale: f32::NAN }, + CaptureSize { width: 10.0, height: 10.0, scale: 100.0 }, + ] { + assert!(app.capture(s).is_err(), "{s:?}"); + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 780897d..22d3104 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -11,6 +11,7 @@ use std::sync::mpsc::Sender; use modelcraft_render::StandardView; use serde_json::{Value, json}; +use crate::capture::CaptureSize; use crate::{ModelApp, UiState}; /// `ui.render`'s image size when none is given. @@ -112,6 +113,8 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> "dialog": app.dialog.as_ref().map(|d| json!({"command": d.command, "text": d.text, "error": d.error})), "status": app.status, "model": {"nodes": app.session.model.nodes().len(), "elements": app.session.model.elements().len()}, + "results": app.session.results_report(), + "probe": app.probe, "version": crate::VERSION, })), "ui.toolbar" => ok(crate::toolbar::describe(app)), @@ -241,6 +244,18 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> ok(Value::Null) } "ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) }, + "ui.capture" => { + let live = window_size(ctx); + let dim = |k: &str, default: f32| match p.get(k) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_f64().filter(|x| x.is_finite()).map(|x| x as f32).ok_or_else(|| format!("`{k}` must be a number")), + }; + let size = match (dim("width", live.width), dim("height", live.height), dim("scale", live.scale)) { + (Ok(width), Ok(height), Ok(scale)) => CaptureSize { width, height, scale }, + (Err(e), ..) | (_, Err(e), _) | (.., Err(e)) => return err(e), + }; + Outcome::Done(capture_to(app, size, s("path"))) + } "ui.render" => { let Some(path) = s("path") else { return err("missing `path`") }; let (w, h) = match (render_dim(p, "width", RENDER_DEFAULT[0]), render_dim(p, "height", RENDER_DEFAULT[1])) { @@ -265,6 +280,34 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> } } +/// The window's size in points and its pixels per point (1400 × 900 at 1 before a window was +/// laid out). +pub fn window_size(ctx: &egui::Context) -> CaptureSize { + let r = ctx.content_rect(); + let fits = |v: f32| v.is_finite() && (1.0..=crate::capture::MAX_SIDE as f32).contains(&v); + let scale = Some(ctx.pixels_per_point()).filter(|s| crate::capture::SCALE_RANGE.contains(s)).unwrap_or(1.0); + if fits(r.width()) && fits(r.height()) { + CaptureSize { width: r.width(), height: r.height(), scale } + } else { + CaptureSize { width: 1400.0, height: 900.0, scale } + } +} + +/// Capture the whole window offscreen ([`ModelApp::capture`]) and save it as PNG (a reply). +pub fn capture_to(app: &mut ModelApp, size: CaptureSize, path: Option<&str>) -> Value { + match app.capture(size) { + Ok(image) => { + let mut reply = save_screenshot(&image, path); + if let Some(r) = reply.get_mut("result").and_then(Value::as_object_mut) { + r.insert("offscreen".into(), json!(true)); + r.insert("scale".into(), json!(size.scale)); + } + reply + } + Err(e) => json!({"ok": false, "error": e}), + } +} + /// Save a screenshot as PNG: at `path`, or `modelcraft-shot.png` in the temporary folder. pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { let [w, h] = image.size; @@ -387,6 +430,7 @@ mod tests { "ui.worldToScreen", "ui.resize", "ui.render", + "ui.capture", "ui.toolbar", ] { for p in &hostile { @@ -411,6 +455,23 @@ mod tests { assert_eq!((r["ok"].as_bool(), r["error"].as_str()), (Some(false), Some("Quadratic is coming soon (M2)"))); } + #[test] + fn ui_capture_saves_the_whole_window() { + let mut app = ModelApp::new(Session::default()); + let path = std::env::temp_dir().join(format!("modelcraft-capture-{}.png", std::process::id())); + let r = call(&mut app, "ui.capture", json!({"path": path.to_string_lossy(), "width": 640, "height": 400, "scale": 1})); + assert_eq!( + (r["ok"].as_bool(), r["result"]["width"].as_u64(), r["result"]["offscreen"].as_bool()), + (Some(true), Some(640), Some(true)), + "{r}" + ); + assert!(std::fs::read(&path).unwrap().starts_with(&[0x89, b'P', b'N', b'G'])); + let _ = std::fs::remove_file(&path); + for bad in [json!({"width": "x"}), json!({"width": 1e9}), json!({"scale": 0}), json!({"height": -1})] { + assert_eq!(call(&mut app, "ui.capture", bad.clone())["ok"], false, "{bad}"); + } + } + #[test] fn screenshots_are_saved_as_png() { let image = egui::ColorImage::filled([4, 3], egui::Color32::RED); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3d814d1..2f95d7c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,20 +11,25 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod capture; pub mod control; pub mod gpu; pub mod icons; pub mod panels; +pub mod results; +pub mod results_panel; pub mod scene; pub mod theme; pub mod toolbar; pub mod viewport; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender}; use modelcraft_engine::Session; use modelcraft_engine::catalogue::{self, Tab}; -use modelcraft_render::{Camera, DisplayMesh, Scene, StandardView}; +use modelcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -96,6 +101,30 @@ pub struct Viewport { pub gpu_errors: gpu::GpuErrors, } +/// A probed node: its values as lines of text, for the step, mode and frame they were read from. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct Probe { + pub node: u32, + pub step: String, + pub mode: usize, + /// The explicit field frame (`None`: the last). + pub frame: Option, + pub lines: Vec, +} + +/// What a results plot was built for. +#[derive(Clone, Debug, PartialEq)] +struct PlotKey { + revision: u64, + /// The result's identity: every solve stores a new one. + result: usize, + current: bool, + view: modelcraft_engine::results::ResultsView, + /// The mode and field frame drawn (an animated explicit step changes frames). + pick: modelcraft_engine::results::Pick, + frame: Option, +} + /// A screenshot asked for over the control channel. struct Shot { token: u64, @@ -123,6 +152,18 @@ pub struct ModelApp { /// Bumped whenever the model may have changed. revision: u64, display: (Option, DisplayMesh), + /// The results plot and what it was built for. + plot: (Option, Option>), + /// The node probed last (a click on a results plot). + pub probe: Option, + /// The reaction sums asked for last in the results panel. + pub reactions: Option, + /// Whether results were shown last frame (the Results tab comes up when they appear). + results_shown: bool, + /// Tessellated design bodies, kept while the same design is open. + bodies: (Option>, Vec), + /// The frame-independent part of the results plot (scale, contour) and what it was built for. + prepared: (Option, results::Prepared), /// The built scene and its key. scene: (Option, Scene), /// The theme last applied to the context. @@ -133,6 +174,8 @@ pub struct ModelApp { control_rx: Option>, shots: Vec, shot_token: u64, + /// The offscreen context `ui.capture` draws the window with (made on first use). + offscreen: Option>, /// The app time (seconds) of the current frame. pub now: f64, pub quit_requested: bool, @@ -152,6 +195,12 @@ impl ModelApp { gpu_info: None, revision: 0, display: (None, DisplayMesh::default()), + plot: (None, None), + probe: None, + reactions: None, + results_shown: false, + bodies: (None, Vec::new()), + prepared: (None, results::Prepared::default()), scene: (None, Scene::default()), styled: None, synthetic: Vec::new(), @@ -159,6 +208,7 @@ impl ModelApp { control_rx: None, shots: Vec::new(), shot_token: 0, + offscreen: None, now: 0.0, quit_requested: false, }; @@ -184,7 +234,6 @@ impl ModelApp { /// Run a command with JSON parameters: what every panel, the control channel and scripts /// do. The result or the error goes to the status bar. pub fn run(&mut self, id: &str, params: Value) -> Result { - let was_empty = self.session.model.nodes().is_empty(); let revision = self.session.revision(); let label = command_label(id); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); @@ -192,29 +241,24 @@ impl ModelApp { if changed { self.model_changed(); } + // The results display commands reply with what is shown, which the viewport shows. + let display = id.starts_with("results.") && id != "results.reactions"; match &r { - Ok(v) if v.is_null() || changed => self.set_status(format!("{label} done"), false), + Ok(v) if v.is_null() || changed || display => self.set_status(format!("{label} done"), false), Ok(v) => self.set_status(format!("{label}: {v}"), false), Err(e) => self.set_status(format!("{label}: {e}"), true), } - if was_empty && !self.session.model.nodes().is_empty() { - self.fit_view(); - } r } /// Run a script (a JSON array of `{command, params}`), stopping at the first error. pub fn run_script(&mut self, script: &Value) -> Result<(), String> { - let was_empty = self.session.model.nodes().is_empty(); let r = self.session.run_script(script).map_err(|e| e.to_string()); self.model_changed(); match &r { Ok(()) => self.set_status("Script done", false), Err(e) => self.set_status(format!("Script: {e}"), true), } - if was_empty && !self.session.model.nodes().is_empty() { - self.fit_view(); - } r } @@ -260,6 +304,49 @@ impl ModelApp { r } + /// Probe a node of the results shown (`solve.probe`) and keep its values for the viewport. + pub fn probe_node(&mut self, node: u32) -> Result<(), String> { + let Some(view) = self.session.results_view.clone() else { return Err("no results are shown".into()) }; + let pick = self.plot().map_or(view.pick(), |p| p.pick); + let mut params = json!({"step": view.step, "nodes": [node], "mode": pick.mode}); + if let Some(frame) = pick.frame { + params["frame"] = json!(frame); + } + let reply = match self.run("solve.probe", params) { + Ok(v) => v, + Err(e) => { + self.probe = None; + return Err(e); + } + }; + let at = reply.pointer("/nodes/0").cloned().unwrap_or(Value::Null); + let vector = |key: &str| at.get(key).and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok()); + let triple = |v: [f64; 3]| format!("{}, {}, {}", results::number(v[0]), results::number(v[1]), results::number(v[2])); + let mut lines = vec![format!("Node {node}")]; + if let Some(u) = vector("displacement") { + lines.push(format!("U {} |U| {}", triple(u), results::number(modelcraft_engine::results::magnitude(u)))); + } + if let Some(rf) = vector("reaction").filter(|r| r.iter().any(|c| *c != 0.0)) { + lines.push(format!("RF {}", triple(rf))); + } + if let Some(m) = at.get("von_mises").and_then(Value::as_f64) { + lines.push(format!("Von Mises {}", results::number(m))); + } + if let Some(v) = vector("velocity") { + lines.push(format!("V {} |V| {}", triple(v), results::number(modelcraft_engine::results::magnitude(v)))); + } + if let Some(shape) = vector("shape") { + lines.push(format!("Mode {} shape {}", pick.mode, triple(shape))); + } + let contour = self.plot().and_then(|p| p.contour.as_ref().map(|c| (c.field, c.at(p.result.nodes(), node)))); + if let Some((field, v)) = contour.filter(|(f, _)| *f != modelcraft_engine::results::Field::VonMises) { + lines.push(format!("{} {}", field.label(), results::number(v))); + } + self.set_status(lines.join(" "), false); + self.probe = Some(Probe { node, step: view.step, mode: pick.mode, frame: pick.frame, lines }); + Ok(()) + } + pub fn set_status(&mut self, text: impl Into, error: bool) { let text = text.into(); if error { @@ -281,30 +368,101 @@ impl ModelApp { self.revision = self.revision.wrapping_add(1); } - /// The model's display data, rebuilt when the model changed. + /// The model's display data with its geometry (the open design's bodies, tessellated once + /// per design, and the imported surfaces), rebuilt when the model changed. The first content + /// to appear is framed. pub fn display(&mut self) -> &DisplayMesh { if self.display.0 != Some(self.revision) { - self.display = (Some(self.revision), DisplayMesh::from_model(&self.session.model)); + let mut d = DisplayMesh::from_model(&self.session.model); + let design = self.session.design.clone(); + if !match (&design, &self.bodies.0) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + } { + let mut failed = Vec::new(); + let meshes: Vec<_> = design + .iter() + .flat_map(|d| &d.bodies) + .filter_map(|body| { + let tolerance = body.measure().map(|m| display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })); + tolerance.and_then(|t| body.tessellate(t)).map_err(|e| failed.push(format!("{}: {e}", body.name))).ok() + }) + .collect(); + self.bodies = (design, meshes); + if !failed.is_empty() { + self.set_status(format!("Some bodies cannot be drawn: {}", failed.join("; ")), true); + } + } + for mesh in self.bodies.1.iter().chain(self.session.surfaces.iter().map(|s| &s.mesh)) { + d.add_surface(&mesh.positions, &mesh.triangles, &mesh.tri_face); + } + let was_empty = self.display.1.bounds.is_empty(); + self.display = (Some(self.revision), d); + if was_empty && !self.display.1.bounds.is_empty() { + self.cam.fit(&self.display.1.bounds); + } } &self.display.1 } - /// The key of the current scene: the model revision and every setting the scene depends on. + /// The key of the current scene: the model revision, every setting the scene depends on and + /// the results plot shown. pub fn scene_key(&self) -> u64 { - let style = Style::ALL.iter().position(|s| *s == self.ui.style).unwrap_or(0) as u64; - (self.revision << 4) | (style << 2) | (u64::from(self.ui.show_nodes) << 1) | u64::from(self.ui.dark) + let mut h = std::hash::DefaultHasher::new(); + let plot = self.plot.1.as_ref().map(|p| Arc::as_ptr(p) as usize); + (self.revision, self.ui.style, self.ui.show_nodes, self.ui.dark, plot).hash(&mut h); + h.finish() + } + + /// What the results plot is built for: the model, the result, its view and the animation + /// frame; `None` when no results are shown. + fn plot_key(&self) -> Option { + let (view, result, current) = results::shown(&self.session)?; + let frame = results::frame(view, self.now); + let pick = results::pick(view, result, frame); + Some(PlotKey { revision: self.revision, result: Arc::as_ptr(result) as usize, current, view: view.clone(), pick, frame }) + } + + /// The results plot the viewport draws, rebuilt when its key changes; `None` shows the + /// model. Only the deformation follows the animation frame; the scale and contour values are + /// kept while the rest of the key (the mode and field frame drawn among it) stays. + pub fn plot(&mut self) -> Option> { + let key = self.plot_key(); + if self.plot.0 != key { + self.display(); + let still = key.as_ref().map(|k| PlotKey { frame: None, ..k.clone() }); + if self.prepared.0 != still { + let pick = key.as_ref().map(|k| k.pick).unwrap_or_default(); + self.prepared = (still, results::prepare(&self.session, &self.display.1, pick).unwrap_or_default()); + } + let frame = key.as_ref().and_then(|k| k.frame); + let p = key.as_ref().and_then(|_| results::plot(&self.session, &self.display.1, frame, &self.prepared.1)); + self.plot = (key, p.map(Arc::new)); + } + self.plot.1.clone() } /// The scene the viewport draws, rebuilt when its key changes; its radius fits the camera. pub fn scene(&mut self) -> (u64, &Scene) { + self.display(); + self.plot(); let key = self.scene_key(); if self.scene.0 != Some(key) { let t = theme::Tokens::get(self.ui.dark); let (style, nodes) = (self.ui.style, self.ui.show_nodes); - let s = scene::build(self.display(), style, nodes, &t); + let s = match &self.plot.1 { + Some(p) => results::scene(p, style, nodes, &t), + None => scene::build(&self.display.1, style, nodes, &t), + }; self.scene = (Some(key), s); } - self.scene.1.radius = scene::radius(&self.display.1, &self.cam); + let mut bounds = self.display.1.bounds; + if let Some(p) = &self.plot.1 { + bounds.add(p.display.bounds.min); + bounds.add(p.display.bounds.max); + } + self.scene.1.radius = scene::radius(&bounds, &self.cam); (key, &self.scene.1) } @@ -338,6 +496,15 @@ impl ModelApp { if !self.synthetic.is_empty() { ctx.request_repaint(); } + // Results coming up (a solve, a results command) bring the Results tab. + let shown = self.session.results_view.is_some(); + if shown && !self.results_shown { + self.ui.tab = Tab::Results; + } + self.results_shown = shown; + if self.session.results_view.as_ref().is_some_and(|v| v.animate) { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(results::PERIOD / results::FRAMES as f64)); + } self.screenshots(ctx); } @@ -451,11 +618,14 @@ impl ModelApp { } } let now = self.now; + // No frame was presented in time (a hidden window, a locked screen): draw the window + // offscreen instead. + let (late, waiting): (Vec, Vec) = std::mem::take(&mut self.shots).into_iter().partition(|s| now > s.deadline); + self.shots = waiting; + for s in late { + let _ = s.reply.send(control::capture_to(self, control::window_size(ctx), s.path.as_deref())); + } self.shots.retain_mut(|s| { - if now > s.deadline { - let _ = s.reply.send(json!({"ok": false, "error": "no frame was presented (window hidden?); use ui.render"})); - return false; - } s.frames += 1; if !s.requested && s.frames >= 3 { ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(s.token))); @@ -469,6 +639,12 @@ impl ModelApp { } } +/// The chordal tolerance bodies are drawn with: a thousandth of their size, within 1 µm–1 mm. +fn display_tolerance(b: &Aabb3) -> f64 { + let d = b.diagonal(); + if d.is_finite() { (d * 1e-3).clamp(1e-3, 1.0) } else { 0.1 } +} + /// Seconds a screenshot waits for a presented frame. const SHOT_TIMEOUT: f64 = 8.0; /// Vertical field of view of the perspective camera (radians). @@ -481,6 +657,8 @@ pub fn command_label(id: &str) -> &str { #[cfg(test)] mod tests { + use modelcraft_render::Vec3; + use super::*; #[test] @@ -543,6 +721,167 @@ mod tests { assert!(png.starts_with(&[0x89, b'P', b'N', b'G'])); } + #[test] + fn the_open_design_and_imported_surfaces_are_drawn() { + let dir = std::env::temp_dir().join(format!("modelcraft-ui-design-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let design = dir.join("plate.solidcraft"); + let bytes = modelcraft_geometry::sample::plate_design(&modelcraft_geometry::sample::Plate::default()).unwrap(); + std::fs::write(&design, bytes).unwrap(); + let mut app = ModelApp::new(Session::default()); + app.run("geometry.open", json!({"path": design.to_string_lossy()})).unwrap(); + let d = app.display().clone(); + assert!(!d.surface_tris.is_empty() && !d.surface_edges.is_empty(), "the design's bodies are drawn"); + assert!(d.tris.is_empty()); + // Without elements, geometry draws as faces with their boundaries; the camera frames it. + let t = theme::Tokens::dark(); + let s = scene::build(&d, Style::ShadedEdges, false, &t); + assert_eq!(s.tris.len(), d.surface_tris.len()); + assert!(s.tris.iter().all(|tri| tri.color == theme::rgb(t.geometry))); + assert!(!d.bounds.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The sample cantilever in an app, solved (static step `Load`, frequency step `Modes`). + fn solved_app() -> ModelApp { + ModelApp::new(results::tests::solved_cantilever()) + } + + fn node_at(app: &ModelApp, xyz: [f64; 3]) -> u32 { + results::tests::node_at(&app.session, xyz) + } + + #[test] + fn solving_shows_the_result_on_the_results_tab() { + let mut app = ModelApp::new(Session::default()); + app.run_script(&modelcraft_engine::sample::cantilever()).unwrap(); + app.start("solve.static").unwrap(); + app.run_dialog().unwrap(); + let view = app.session.results_view.clone().unwrap(); + assert_eq!((view.step.as_str(), view.contour, view.deformed), ("Load", Some(modelcraft_engine::results::Field::VonMises), true)); + app.logic(&egui::Context::default()); + assert_eq!(app.ui.tab, Tab::Results); + app.run("solve.frequency", json!({})).unwrap(); + app.run("results.deformed", json!({"step": "Modes"})).unwrap(); + let view = app.session.results_view.clone().unwrap(); + assert_eq!((view.step.as_str(), view.mode, view.contour), ("Modes", 1, Some(modelcraft_engine::results::Field::Displacement))); + // The plot draws contour bands: more triangles than the model's faces. + let faces = app.display().tris.len(); + assert!(app.scene().1.tris.len() > faces); + app.run("results.clear", json!({})).unwrap(); + assert!(app.plot().is_none()); + assert_eq!(app.scene().1.tris.len(), faces); + } + + /// Rendered on the CPU from the front, undeformed, with Z displacement contours: the free end + /// (lowest band) is blue, the clamped end (highest band) red. + #[test] + fn contours_render_with_known_colours_at_known_nodes() { + let mut app = solved_app(); + app.run("results.contour", json!({"step": "Load", "field": "displacement_z", "bands": 6})).unwrap(); + app.run("results.deformed", json!({"show": false})).unwrap(); + app.ui.style = Style::Shaded; + app.set_view(StandardView::Front); + app.fit_view(); + let (w, h) = (400, 200); + let cam = app.cam; + let radius = app.scene().1.radius; + let canvas = modelcraft_render::render(app.scene().1, &cam, w, h).unwrap(); + let pixel = |p: Vec3| { + let (x, y, _) = cam.to_screen(p, w as f64, h as f64, radius).unwrap(); + let i = (y as usize * w + x as usize) * 4; + [canvas.rgba[i], canvas.rgba[i + 1], canvas.rgba[i + 2]] + }; + // Cell centres on the front face (y = 0), one at each end. + let tip = pixel(Vec3::new(97.5, 0.0, 12.5)); + let root = pixel(Vec3::new(2.5, 0.0, 12.5)); + assert!(tip[2] > tip[0] + 60, "the free end is blue: {tip:?}"); + assert!(root[0] > root[2] + 60, "the clamped end is red: {root:?}"); + } + + #[test] + fn probing_a_node_reads_its_values_and_stale_results_say_so() { + let mut app = solved_app(); + app.run("results.deformed", json!({"step": "Load"})).unwrap(); + let tip = node_at(&app, [100.0, 0.0, 0.0]); + app.probe_node(tip).unwrap(); + let probe = app.probe.clone().unwrap(); + assert_eq!((probe.node, probe.step.as_str()), (tip, "Load")); + assert!(probe.lines.iter().any(|l| l.starts_with("U ")) && probe.lines.iter().any(|l| l.starts_with("Von Mises")), "{probe:?}"); + // A root node carries a reaction. + app.probe_node(node_at(&app, [0.0, 0.0, 0.0])).unwrap(); + assert!(app.probe.as_ref().unwrap().lines.iter().any(|l| l.starts_with("RF "))); + // The plot under a click: a ray onto the tip's end face picks a node there. + let plot = app.plot().unwrap(); + let (node, _) = plot.display.pick(Vec3::new(1000.0, 0.5, 0.5), Vec3::new(-1.0, 0.0, 0.0)).unwrap(); + assert_eq!(app.session.model.node(node).unwrap().xyz[0], 100.0); + // An edit leaves the plot shown but stale; probing it is refused. + app.run( + "model.material.edit", + json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 70000, "poisson": 0.33}, "density": 2.7e-9}), + ) + .unwrap(); + assert!(app.plot().is_some_and(|p| p.stale)); + let e = app.probe_node(tip).unwrap_err(); + assert!(e.contains("stale") && app.probe.is_none(), "{e}"); + } + + #[test] + fn explicit_frames_are_drawn_and_animated() { + let mut app = ModelApp::new(Session::default()); + app.run_script(&modelcraft_engine::sample::impact()).unwrap(); + app.run("solve.explicit", json!({})).unwrap(); + // The last frame at true scale: the bar has moved back off the wall's side (x < 0 is + // the wall, so nothing passes it) and carries stress contours. + let last = app.plot().unwrap(); + assert_eq!(last.frame().map(|(k, _, n)| (k, n)), Some((10, 11))); + assert!(last.contour.as_ref().is_some_and(|c| c.field == modelcraft_engine::results::Field::VonMises)); + assert!(last.display.bounds.min.x > -1e-6, "{:?}", last.display.bounds); + // Frame 0 is the undeformed bar at rest on its initial velocity. + app.run("results.frame", json!({"frame": 0})).unwrap(); + let first = app.plot().unwrap(); + assert_eq!(first.display.tris, app.display().tris); + // Animated, the frames run through once per cycle. + app.run("results.animate", json!({})).unwrap(); + app.now = results::PERIOD * 0.5; + assert_eq!(app.plot().unwrap().pick.frame, Some(5)); + let image = app.capture(capture::CaptureSize { width: 1200.0, height: 800.0, scale: 1.0 }).unwrap(); + assert_eq!(image.size, [1200, 800]); + // Probing reads the frame drawn. + app.run("results.animate", json!({"on": false})).unwrap(); + app.run("results.frame", json!({"frame": 4})).unwrap(); + app.probe_node(node_at(&app, [100.0, 0.0, 0.0])).unwrap(); + let probe = app.probe.clone().unwrap(); + assert_eq!(probe.frame, Some(4)); + assert!(probe.lines.iter().any(|l| l.starts_with("U ")), "{probe:?}"); + } + + #[test] + fn the_capture_shows_the_legend_and_panel_over_the_plot() { + let mut app = solved_app(); + app.run("results.deformed", json!({"step": "Load"})).unwrap(); + let image = app.capture(capture::CaptureSize { width: 1200.0, height: 800.0, scale: 1.0 }).unwrap(); + let t = theme::Tokens::dark(); + // The legend's top swatch (the highest band, red) is drawn left of the middle. + let red = t.contour[4]; + let near = |c: egui::Color32, d: egui::Color32| { + (i32::from(c.r()) - i32::from(d.r())).abs() < 30 + && (i32::from(c.g()) - i32::from(d.g())).abs() < 40 + && (i32::from(c.b()) - i32::from(d.b())).abs() < 30 + }; + let found = (0..600).any(|x| (150..400).any(|y| near(image.pixels[y * 1200 + x], red))); + assert!(found, "a red legend swatch"); + } + + #[test] + fn display_tolerance_follows_the_size() { + let b = |max: [f64; 3]| Aabb3 { min: Vec3::ZERO, max: max.into() }; + assert_eq!(display_tolerance(&b([100.0, 0.0, 0.0])), 0.1); + assert_eq!(display_tolerance(&b([0.0; 3])), 1e-3); + assert_eq!(display_tolerance(&b([1e9, 0.0, 0.0])), 1.0); + assert_eq!(display_tolerance(&Aabb3::EMPTY), 1e-3); + } + #[test] fn graphics_device_errors_reach_the_status_bar() { let mut app = ModelApp::new(Session::default()); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7ffc1d1..bdb894b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -73,6 +73,8 @@ pub fn status_bar(app: &mut ModelApp, ui: &mut egui::Ui) { pub fn model_tree(app: &mut ModelApp, ui: &mut egui::Ui) { let t = Tokens::get(app.ui.dark); let model = &app.session.model; + let shown = app.session.results_view.as_ref().map(|v| v.step.clone()); + let mut show = None; egui::Panel::left("mc_tree").resizable(true).default_size(220.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::same(8))).show( ui, |ui| { @@ -96,10 +98,32 @@ pub fn model_tree(app: &mut ModelApp, ui: &mut egui::Ui) { } }, ); + if let Some(d) = &app.session.design { + ui.label(format!("Design {} ({} bodies)", d.name, d.bodies.len())); + } + if !app.session.surfaces.is_empty() { + ui.label(format!("Surfaces ({})", app.session.surfaces.len())); + } + let steps: Vec<(&str, bool)> = app.session.result_steps().collect(); + if !steps.is_empty() { + egui::CollapsingHeader::new(format!("Results ({})", steps.len())).id_salt("mc_tree_results").default_open(true).show(ui, |ui| { + for (name, current) in steps { + let text = if current { RichText::new(name) } else { RichText::new(format!("{name} (stale)")).color(t.text_dim) }; + let selected = shown.as_deref() == Some(name); + if ui.selectable_label(selected, text).on_hover_text("Show this step's results").clicked() { + show = Some(name.to_string()); + } + } + }); + } ui.label(format!("History ({} commands)", app.session.journal.len())); }); }, ); + if let Some(step) = show { + // Errors show in the status bar. + let _ = app.run("results.deformed", serde_json::json!({"step": step})); + } } /// The command registry by area (the id's first part), each command a button that starts it. diff --git a/crates/ui-egui/src/results.rs b/crates/ui-egui/src/results.rs new file mode 100644 index 0000000..ee60208 --- /dev/null +++ b/crates/ui-egui/src/results.rs @@ -0,0 +1,324 @@ +//! 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 +//! ([`modelcraft_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. + +use std::sync::Arc; + +use modelcraft_engine::results::{Field, Kind, Pick, ResultsView, displacements, field_values, magnitude, range}; +use modelcraft_engine::{Session, StepResult}; +use modelcraft_render::contour::{Bands, band_color, split_triangle}; +use modelcraft_render::{DisplayMesh, Scene, SceneTri, 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; +/// The automatic scale draws the largest displacement this fraction of the model's size. +const AUTO_FRACTION: f64 = 0.1; + +/// 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. + pub min: (u32, f64), + pub max: (u32, f64), + /// The value at every node, in the result's node order. + 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 scale factor the deformation is drawn with (0: undeformed), before animation. + pub scale: f64, + /// The elements as drawn (deformed at the current animation frame). + pub display: DisplayMesh, + pub contour: Option>, + /// The mode and field frame drawn. + pub pick: Pick, +} + +/// 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>, +} + +impl Plot { + /// A frequency step's mode shown, and its frequency. + pub fn mode(&self) -> Option<(usize, f64)> { + match self.result.kind() { + Kind::Frequency(f) => f.modes.get(self.pick.mode.checked_sub(1)?).map(|m| (self.pick.mode, m.frequency)), + Kind::Static(_) | Kind::Explicit(_) => None, + } + } + + /// An explicit step's field frame shown, its time and the number of frames. + pub fn frame(&self) -> Option<(usize, f64, usize)> { + let Kind::Explicit(e) = self.result.kind() else { return None }; + let n = e.frames.len(); + let k = self.pick.frame.unwrap_or(n.checked_sub(1)?); + Some((k, e.frames.get(k)?.time, n)) + } + + /// The node's position in the plot, if it is drawn. + pub fn node_position(&self, label: u32) -> Option { + self.display.nodes.binary_search_by_key(&label, |n| n.label).ok().and_then(|i| self.display.nodes.get(i)).map(|n| n.p) + } +} + +/// 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(), + Kind::Static(_) => 0.5 * (1.0 - a.cos()), + Kind::Explicit(_) => 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(); + if let (Some(k), Kind::Explicit(e)) = (frame, result.kind()) + && let Some(last) = e.frames.len().checked_sub(1) + { + p.frame = Some((k as usize * e.frames.len() / FRAMES as usize).min(last)); + } + p +} + +/// The deformation scale factor: the view's, or automatic (the largest displacement a tenth of +/// the model's size); 0 when the view is undeformed or nothing moves. +pub fn scale(view: &ResultsView, result: &StepResult, pick: Pick, base: &DisplayMesh) -> f64 { + if !view.deformed { + return 0.0; + } + if let Some(s) = view.scale { + return s; + } + let largest = displacements(result, pick).map_or(0.0, |u| 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 } +} + +/// 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)?; + let contour = view.contour.and_then(|field| { + let values = field_values(&session.model, result, field, pick).ok()?; + let (min, max) = range(result.nodes(), &values)?; + Some(Arc::new(Contour { field, bands: Bands::new(min.1, max.1, view.bands), min, max, values })) + }); + Some(Prepared { scale: scale(view, result, pick, base), contour }) +} + +/// 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 = prepared.scale * swing(result, frame); + let u = displacements(result, pick).unwrap_or(&[]); + let display = 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(), + pick, + }) +} + +/// 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. +pub fn scene(plot: &Plot, style: Style, show_nodes: bool, t: &Tokens) -> Scene { + let mut s = crate::scene::build(&plot.display, style, show_nodes, t); + let Some(c) = plot.contour.as_ref().filter(|_| style != Style::Wireframe) else { return s }; + let nodes = plot.result.nodes(); + let stops = t.contour_stops(); + s.tris.clear(); + for tri in &plot.display.tris { + split_triangle(tri.p, tri.nodes.map(|l| c.at(nodes, l)), &c.bands, &mut |piece, band| { + let color = band.map_or(rgb(t.no_result), |k| band_color(&stops, k, c.bands.count)); + for k in 1..piece.len().saturating_sub(1) { + if let (Some(&a), Some(&b), Some(&cc)) = (piece.first(), piece.get(k), piece.get(k + 1)) { + s.tris.push(SceneTri { p: [a, b, cc], normal: tri.normal, color }); + } + } + }); + } + s +} + +/// A number for the legend and probe: four significant digits, scientific 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 { + "0".into() + } else if !(1e-3..1e5).contains(&a) { + format!("{v:.3e}") + } else { + let decimals = (3 - a.log10().floor() as i32).clamp(0, 6) as usize; + format!("{v:.decimals$}") + } +} + +#[cfg(test)] +pub(crate) mod tests { + use serde_json::json; + + 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(&modelcraft_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); + assert!((p.scale * largest / base.bounds.diagonal() - AUTO_FRACTION).abs() < 1e-12, "{}", p.scale); + s.execute("results.deformed", &json!({"show": false})).unwrap(); + assert_eq!(plot_at(&s, &base, None).unwrap().display.tris, base.tris); + } + + #[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, false, &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()); + } + + #[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), "—"); + } +} diff --git a/crates/ui-egui/src/results_panel.rs b/crates/ui-egui/src/results_panel.rs new file mode 100644 index 0000000..940400d --- /dev/null +++ b/crates/ui-egui/src/results_panel.rs @@ -0,0 +1,283 @@ +//! What the viewport shows over a results plot: the legend (field, bands, range, deformation +//! scale, a stale-result warning), the probed node, and the results panel whose controls run +//! the `results.*` commands. + +use egui::{Align2, Color32, FontId, Rect, RichText, Stroke, pos2, vec2}; +use modelcraft_engine::results::{Field, Kind}; +use modelcraft_explicit::{Energies, ExplicitResult}; +use serde_json::{Value, json}; + +use crate::ModelApp; +use crate::results::{Plot, number}; +use crate::theme::Tokens; + +/// Width of the results panel and of the legend's colour swatches (points). +const PANEL_WIDTH: f32 = 260.0; +/// Width of the panel's drop-down lists. +const COMBO_WIDTH: f32 = 160.0; +const SWATCH: egui::Vec2 = vec2(18.0, 15.0); +/// Height of the energy plot (points), and the most points one of its lines draws. +const PLOT_HEIGHT: f32 = 90.0; +const MAX_PLOT_POINTS: usize = 400; + +/// Draw the legend, the probe and the panel when a results plot is shown. +pub fn show(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) { + let Some(plot) = app.plot() else { return }; + let painter = ui.painter_at(rect); + legend(&plot, &painter, rect, t); + probe(app, &plot, &painter, t); + panel(app, &plot, ui, rect, t); +} + +/// The legend, top left under the view buttons. +fn legend(plot: &Plot, painter: &egui::Painter, rect: Rect, t: &Tokens) { + let mut lines: Vec<(String, Color32)> = Vec::new(); + let title = match (&plot.contour, plot.mode()) { + (Some(l), _) => l.field.label().to_string(), + (None, Some(_)) => "Mode shape".into(), + (None, None) => "Deformed shape".into(), + }; + let mut step = format!("Step {}", plot.view.step); + if let Some((mode, hz)) = plot.mode() { + step.push_str(&format!(", mode {mode}: {} Hz", number(hz))); + } + if let Some((k, time, n)) = plot.frame() { + step.push_str(&format!(", frame {k} of {}: t = {}", n.saturating_sub(1), number(time))); + } + lines.push((step, t.overlay_text)); + if plot.scale > 0.0 { + lines.push((format!("Deformation × {}", number(plot.scale)), t.text_dim)); + } else { + lines.push(("Undeformed".into(), t.text_dim)); + } + if plot.stale { + lines.push(("Stale: the model changed since this step was solved".into(), t.warm)); + } + let origin = rect.left_top() + vec2(12.0, 44.0); + let font = FontId::proportional(12.0); + let line_h = 16.0; + let bands = plot.contour.as_ref().map_or(0, |l| l.bands.count) as f32; + let width = 250.0; + // Padding and title, the lines, then the swatches with the range lines under them. + let height = 36.0 + line_h * lines.len() as f32 + if bands > 0.0 { 16.0 + bands * SWATCH.y + 2.0 * line_h } else { 0.0 }; + let frame = Rect::from_min_size(origin, vec2(width, height)); + painter.rect_filled(frame, 4.0, t.overlay); + let mut y = origin.y + 8.0; + painter.text(pos2(origin.x + 10.0, y), Align2::LEFT_TOP, title, FontId::proportional(14.0), t.overlay_text); + y += 20.0; + for (text, color) in &lines { + painter.text(pos2(origin.x + 10.0, y), Align2::LEFT_TOP, text, font.clone(), *color); + y += line_h; + } + let Some(l) = &plot.contour else { return }; + y += 6.0; + let stops = t.contour_stops(); + // High values on top, each band's lower boundary written beside its swatch's bottom edge. + for k in (0..l.bands.count).rev() { + let row = (l.bands.count - 1 - k) as f32; + let r = Rect::from_min_size(pos2(origin.x + 10.0, y + row * SWATCH.y), SWATCH); + let c = modelcraft_render::contour::band_color(&stops, k, l.bands.count); + painter.rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2)); + if k + 1 == l.bands.count { + painter.text(pos2(r.right() + 6.0, r.top()), Align2::LEFT_CENTER, number(l.bands.level(k + 1)), font.clone(), t.overlay_text); + } + painter.text(pos2(r.right() + 6.0, r.bottom()), Align2::LEFT_CENTER, number(l.bands.level(k)), font.clone(), t.overlay_text); + } + painter.rect_stroke( + Rect::from_min_size(pos2(origin.x + 10.0, y), vec2(SWATCH.x, SWATCH.y * l.bands.count as f32)), + 0.0, + Stroke::new(1.0, t.border), + egui::StrokeKind::Outside, + ); + y += SWATCH.y * l.bands.count as f32 + 10.0; + painter.text(pos2(origin.x + 10.0, y), Align2::LEFT_TOP, format!("Max {} at node {}", number(l.max.1), l.max.0), font.clone(), t.text_dim); + painter.text(pos2(origin.x + 10.0, y + line_h), Align2::LEFT_TOP, format!("Min {} at node {}", number(l.min.1), l.min.0), font, t.text_dim); +} + +/// The probed node: a marker on it and its values beside it. +fn probe(app: &ModelApp, plot: &Plot, painter: &egui::Painter, t: &Tokens) { + let Some(p) = app.probe.as_ref().filter(|p| p.step == plot.view.step && p.mode == plot.pick.mode && p.frame == plot.pick.frame) else { return }; + let Some(at) = plot.node_position(p.node).and_then(|w| crate::viewport::to_screen(app, w)) else { return }; + painter.circle(at, 5.0, t.accent, Stroke::new(2.0, t.overlay_text)); + let font = FontId::proportional(12.0); + let galley = painter.layout_no_wrap(p.lines.join("\n"), font, t.overlay_text); + let box_rect = Rect::from_min_size(at + vec2(12.0, -10.0), galley.size() + vec2(16.0, 8.0)); + painter.rect_filled(box_rect, 4.0, t.overlay); + painter.galley(box_rect.left_top() + vec2(8.0, 4.0), galley, t.overlay_text); +} + +/// The results panel, top right: step, contour field, deformation, modes, animation, reaction +/// sums. Every control runs a command. +fn panel(app: &mut ModelApp, plot: &Plot, ui: &mut egui::Ui, rect: Rect, t: &Tokens) { + let area = Rect::from_min_size(pos2(rect.right() - PANEL_WIDTH - 12.0, rect.top() + 44.0), vec2(PANEL_WIDTH, rect.height() - 56.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::top_down(egui::Align::Min))); + let mut run: Vec<(&'static str, Value)> = Vec::new(); + let steps: Vec<(String, bool)> = app.session.result_steps().map(|(n, c)| (n.to_string(), c)).collect(); + egui::Frame::NONE.fill(t.overlay).corner_radius(4.0).inner_margin(egui::Margin::same(8)).show(&mut child, |ui| { + ui.set_width(PANEL_WIDTH - 16.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Results").strong().color(t.overlay_text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Close").on_hover_text("Back to the model (results.clear)").clicked() { + run.push(("results.clear", json!({}))); + } + }); + }); + egui::Grid::new("mc_results_grid").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| { + ui.label("Step"); + egui::ComboBox::from_id_salt("mc_results_step").selected_text(&plot.view.step).width(COMBO_WIDTH).truncate().show_ui(ui, |ui| { + for (name, current) in &steps { + let label = if *current { name.clone() } else { format!("{name} (stale)") }; + if ui.selectable_label(*name == plot.view.step, label).clicked() && *name != plot.view.step { + run.push(("results.deformed", json!({"step": name}))); + } + } + }); + ui.end_row(); + ui.label("Contours"); + let shown = plot.view.contour.map_or("None", Field::label); + egui::ComboBox::from_id_salt("mc_results_field").selected_text(shown).width(COMBO_WIDTH).truncate().show_ui(ui, |ui| { + if ui.selectable_label(plot.view.contour.is_none(), "None").clicked() { + run.push(("results.contour", json!({"field": "none"}))); + } + for f in Field::available(&plot.result) { + if ui.selectable_label(plot.view.contour == Some(f), f.label()).clicked() { + run.push(("results.contour", json!({"field": f.id()}))); + } + } + }); + ui.end_row(); + ui.label("Deformed"); + ui.horizontal(|ui| { + let mut deformed = plot.view.deformed; + if ui.checkbox(&mut deformed, "").changed() { + run.push(("results.deformed", json!({"show": deformed}))); + } + let mut auto = plot.view.scale.is_none(); + if ui.checkbox(&mut auto, "Auto").on_hover_text("Scale the largest displacement to a tenth of the model").changed() { + run.push(("results.deformed", json!({"scale": if auto { json!("auto") } else { json!(plot.scale.max(f64::MIN_POSITIVE)) }}))); + } + let mut scale = plot.scale; + let drag = egui::DragValue::new(&mut scale).speed(plot.scale.abs().max(1e-6) * 0.01).range(1e-9..=1e12).prefix("× "); + if ui.add_enabled(plot.view.deformed, drag).changed() { + run.push(("results.deformed", json!({"scale": scale}))); + } + }); + ui.end_row(); + ui.label("Animate"); + let mut animate = plot.view.animate; + if ui.checkbox(&mut animate, "").changed() { + run.push(("results.animate", json!({"on": animate}))); + } + ui.end_row(); + }); + if let Kind::Explicit(e) = plot.result.kind() { + ui.separator(); + explicit(ui, e, plot, t, &mut run); + } else if let Kind::Frequency(f) = plot.result.kind() { + ui.separator(); + ui.label(RichText::new("Modes").color(t.text_dim)); + egui::ScrollArea::vertical().id_salt("mc_results_modes").max_height(150.0).show(ui, |ui| { + for (i, m) in f.modes.iter().enumerate() { + let mode = i + 1; + if ui.selectable_label(plot.view.mode == mode, format!("{mode} {} Hz", number(m.frequency))).clicked() { + run.push(("results.modes", json!({"mode": mode}))); + } + } + }); + } else { + ui.separator(); + if ui.button("Reaction sums").on_hover_text("Reaction force and moment per boundary-condition set (results.reactions)").clicked() { + run.push(("results.reactions", json!({"step": plot.view.step}))); + } + if let Some(r) = app.reactions.as_ref().filter(|r| r["step"] == plot.view.step.as_str()) { + reactions_table(ui, r, t); + } + } + ui.label(RichText::new("Click the model to probe a node").small().color(t.text_dim)); + }); + for (id, params) in run { + let reply = app.run(id, params); + if id == "results.reactions" { + app.reactions = reply.ok(); + } + } +} + +/// An explicit step: a frame slider (the deformed shape and contours follow it) and the energy +/// histories, plotted and read at the frame's time. +fn explicit(ui: &mut egui::Ui, e: &ExplicitResult, plot: &Plot, t: &Tokens, run: &mut Vec<(&'static str, Value)>) { + if let Some((k, time, n)) = plot.frame() { + ui.horizontal(|ui| { + ui.label("Frame"); + let mut frame = k; + if ui.add(egui::Slider::new(&mut frame, 0..=n.saturating_sub(1))).changed() { + run.push(("results.frame", json!({"frame": frame}))); + } + }); + ui.label(RichText::new(format!("t = {}", number(time))).color(t.text_dim)); + } + let h = &e.history; + if h.time.is_empty() { + return; + } + let series: [(&str, Color32, fn(&Energies) -> f64); 4] = [ + ("Kinetic", t.accent, |e| e.kinetic), + ("Internal", t.ok, |e| e.internal), + ("Hourglass", t.warm, |e| e.hourglass), + ("External", t.error, |e| e.external), + ]; + ui.label(RichText::new("Energies").color(t.text_dim)); + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), PLOT_HEIGHT), egui::Sense::hover()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 2.0, t.field); + let t_end = h.time.last().copied().unwrap_or(0.0).max(f64::MIN_POSITIVE); + let top = h.energies.iter().flat_map(|e| series.iter().map(move |(_, _, f)| f(e).abs())).filter(|v| v.is_finite()).fold(0.0, f64::max); + let top = if top > 0.0 { top } else { 1.0 }; + let at = + |time: f64, v: f64| pos2(rect.left() + (time / t_end) as f32 * rect.width(), rect.bottom() - 2.0 - (v / top) as f32 * (rect.height() - 4.0)); + // At most a few hundred points per line, however long the history. + let stride = (h.time.len() / MAX_PLOT_POINTS).max(1); + for (_, color, f) in &series { + let points: Vec = + h.time.iter().zip(&h.energies).step_by(stride).filter(|(_, e)| f(e).is_finite()).map(|(time, e)| at(*time, f(e))).collect(); + painter.add(egui::Shape::line(points, Stroke::new(1.5, *color))); + } + // The frame's time, and the sample nearest it. + let now = plot.frame().map_or(t_end, |(_, time, _)| time); + let x = at(now, 0.0).x; + painter.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], Stroke::new(1.0, t.text_dim)); + let i = h.time.iter().enumerate().min_by(|a, b| (a.1 - now).abs().total_cmp(&(b.1 - now).abs())).map_or(0, |(i, _)| i); + let Some(at_now) = h.energies.get(i) else { return }; + egui::Grid::new("mc_results_energies").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| { + for (name, color, f) in &series { + ui.label(RichText::new(*name).color(*color)); + ui.label(number(f(at_now))); + ui.end_row(); + } + ui.label(RichText::new("Balance").color(t.text_dim)); + ui.label(number(at_now.balance)); + ui.end_row(); + }); +} + +fn reactions_table(ui: &mut egui::Ui, r: &Value, t: &Tokens) { + let row = |ui: &mut egui::Ui, name: &str, v: &Value| { + let f = |k: usize| v.pointer(&format!("/force/{k}")).and_then(Value::as_f64).map_or("—".into(), number); + ui.label(name); + ui.label(f(0)); + ui.label(f(1)); + ui.label(f(2)); + ui.end_row(); + }; + egui::Grid::new("mc_results_reactions").num_columns(4).spacing(vec2(6.0, 2.0)).show(ui, |ui| { + for h in ["Set", "Fx", "Fy", "Fz"] { + ui.label(RichText::new(h).small().color(t.text_dim)); + } + ui.end_row(); + for s in r["sets"].as_array().into_iter().flatten() { + row(ui, s["set"].as_str().unwrap_or("?"), s); + } + row(ui, "All", &r["total"]); + }); +} diff --git a/crates/ui-egui/src/scene.rs b/crates/ui-egui/src/scene.rs index 1031441..661c4cc 100644 --- a/crates/ui-egui/src/scene.rs +++ b/crates/ui-egui/src/scene.rs @@ -1,7 +1,11 @@ //! What the viewport draws: the model's display data in the chosen style and the theme's //! colours. The GPU viewport and the headless render (`ui.render`) draw the same [`Scene`]. +//! +//! Geometry (the open design, imported surfaces) draws as faces with their boundaries while +//! the model has no element faces; once it is meshed, as boundaries only, so the mesh is not +//! hidden behind coincident faces. -use modelcraft_render::{Camera, DisplayMesh, Scene, SceneLine, ScenePoint, SceneTri}; +use modelcraft_render::{Aabb3, Camera, DisplayMesh, Scene, SceneLine, ScenePoint, SceneTri}; use serde::{Deserialize, Serialize}; use crate::theme::{Tokens, rgb}; @@ -30,8 +34,9 @@ impl Style { } } -/// Mesh edge width, line element width and node size, in points. +/// Mesh edge width, geometry edge width, line element width and node size, in points. const EDGE_WIDTH: f64 = 1.0; +const GEOMETRY_EDGE_WIDTH: f64 = 1.5; const LINE_ELEMENT_WIDTH: f64 = 3.0; const NODE_SIZE: f64 = 5.0; @@ -40,23 +45,29 @@ pub fn build(display: &DisplayMesh, style: Style, show_nodes: bool, t: &Tokens) let mut s = Scene { background: Some((rgb(t.viewport_top), rgb(t.viewport_bottom))), ..Default::default() }; if style != Style::Wireframe { s.tris = display.tris.iter().map(|d| SceneTri { p: d.p, normal: d.normal, color: rgb(t.element(d.kind)) }).collect(); + if display.tris.is_empty() { + s.tris.extend(display.surface_tris.iter().map(|d| SceneTri { p: d.p, normal: d.normal, color: rgb(t.geometry) })); + } } if style != Style::Shaded { // Without faces behind them the edges take the element colour, so they read on the // background. let color = rgb(if style == Style::Wireframe { t.hex } else { t.mesh_edge }); - s.lines = display.edges.iter().map(|[a, b]| SceneLine { a: *a, b: *b, color, width: EDGE_WIDTH }).collect(); + s.lines = display.edges.iter().map(|e| SceneLine { a: e.p[0], b: e.p[1], color, width: EDGE_WIDTH }).collect(); } + // Face boundaries in every style: they are the geometry's outline. + let faces_drawn = style != Style::Wireframe && display.tris.is_empty(); + let edge = rgb(if faces_drawn { t.mesh_edge } else { t.geometry_edge }); + s.lines.extend(display.surface_edges.iter().map(|[a, b]| SceneLine { a: *a, b: *b, color: edge, width: GEOMETRY_EDGE_WIDTH })); s.lines.extend(display.lines.iter().map(|l| SceneLine { a: l.a, b: l.b, color: rgb(t.element(l.kind)), width: LINE_ELEMENT_WIDTH })); if show_nodes { - s.points = display.nodes.iter().map(|p| ScenePoint { p: *p, color: rgb(t.node), size: NODE_SIZE }).collect(); + s.points = display.nodes.iter().map(|n| ScenePoint { p: n.p, color: rgb(t.node), size: NODE_SIZE }).collect(); } s } -/// The radius around the camera target that holds the whole model (for the clipping planes). -pub fn radius(display: &DisplayMesh, cam: &Camera) -> f64 { - let b = &display.bounds; +/// The radius around the camera target that holds the bounds (for the clipping planes). +pub fn radius(b: &Aabb3, cam: &Camera) -> f64 { if b.is_empty() { 1.0 } else { (cam.target.dist(b.center()) + b.diagonal() * 0.5).max(1e-3) } } @@ -82,6 +93,6 @@ mod tests { let s = build(&d, Style::Wireframe, false, &t); assert_eq!((s.tris.len(), s.lines.len()), (0, 6)); assert_eq!(s.tris.first().map(|t| t.color), None); - assert!(radius(&d, &Camera::default()) > 0.5); + assert!(radius(&d.bounds, &Camera::default()) > 0.5); } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 9f25a60..d56f8f0 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -43,8 +43,27 @@ pub struct Tokens { /// Element edges (the mesh wireframe). pub mesh_edge: Color32, pub node: Color32, + // ---- geometry ---- + /// Faces of the open design and imported surfaces. + pub geometry: Color32, + /// Their face boundaries. + pub geometry_edge: Color32, + // ---- results ---- + /// The contour colour scale, low to high (the same in both themes). + pub contour: [Color32; 5], + /// Faces with no result at a node. + pub no_result: Color32, } +/// The contour colour scale: blue, cyan, green, yellow, red. +const CONTOUR: [Color32; 5] = [ + Color32::from_rgb(0x2b, 0x4f, 0xd8), + Color32::from_rgb(0x2f, 0xb8, 0xe0), + Color32::from_rgb(0x3c, 0xc8, 0x64), + Color32::from_rgb(0xf0, 0xd2, 0x3c), + Color32::from_rgb(0xe0, 0x40, 0x2e), +]; + impl Tokens { pub fn get(dark: bool) -> Tokens { if dark { Tokens::dark() } else { Tokens::light() } @@ -79,6 +98,10 @@ impl Tokens { line_element: Color32::from_rgb(0xf0, 0xc0, 0x50), mesh_edge: Color32::from_rgb(0x14, 0x18, 0x1e), node: Color32::from_rgb(0xf2, 0xe6, 0x6e), + geometry: Color32::from_rgb(0xa8, 0xae, 0xb8), + geometry_edge: Color32::from_rgb(0xe8, 0xec, 0xf2), + contour: CONTOUR, + no_result: Color32::from_rgb(0x70, 0x76, 0x80), } } @@ -111,9 +134,18 @@ impl Tokens { line_element: Color32::from_rgb(0xc8, 0x8c, 0x1e), mesh_edge: Color32::from_rgb(0x1e, 0x23, 0x2a), node: Color32::from_rgb(0xb4, 0x3c, 0x8c), + geometry: Color32::from_rgb(0xb4, 0xba, 0xc4), + geometry_edge: Color32::from_rgb(0x2a, 0x30, 0x3a), + contour: CONTOUR, + no_result: Color32::from_rgb(0x9a, 0xa0, 0xaa), } } + /// The contour colour scale for the renderer, low to high. + pub fn contour_stops(&self) -> [Rgb; 5] { + self.contour.map(rgb) + } + /// The colour elements of this kind are drawn in. pub fn element(&self, kind: ElementKind) -> Color32 { match kind { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 157a315..b522715 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -19,6 +19,11 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) { app.viewport.rect = Some(rect); let response = ui.allocate_rect(rect, Sense::click_and_drag()); navigate(app, ui, &response, rect); + if response.clicked() + && let Some(at) = response.interact_pointer_pos() + { + probe_at(app, at, rect); + } let t = Tokens::get(app.ui.dark); let painter = ui.painter_at(rect); gradient(&painter, rect, t.viewport_top, t.viewport_bottom); @@ -27,17 +32,28 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) { } else { paint_cpu(app, ui, &painter, rect); } - if app.session.model.nodes().is_empty() { + if app.display().bounds.is_empty() { painter.text( rect.center(), egui::Align2::CENTER_CENTER, - "The model is empty.\nAdd nodes and elements from the Commands panel, or start with --sample.", + "The model is empty.\nOpen a design or add nodes and elements, or start with --sample.", egui::FontId::proportional(15.0), t.text_dim, ); } axis_indicator(app, &painter, rect, &t); view_buttons(app, ui, rect, &t); + crate::results_panel::show(app, ui, rect, &t); +} + +/// A click on a results plot probes the node nearest to the face under the pointer. +fn probe_at(app: &mut ModelApp, at: Pos2, rect: Rect) { + let Some(plot) = app.plot() else { return }; + let (origin, dir) = app.cam.ray(f64::from(at.x - rect.left()), f64::from(at.y - rect.top()), f64::from(rect.width()), f64::from(rect.height())); + if let Some((node, _)) = plot.display.pick(origin, dir) { + // A failure (a stale result) shows in the status bar. + let _ = app.probe_node(node); + } } fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect: Rect) { diff --git a/docs/architecture.md b/docs/architecture.md index c61ef75..0d2b17e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,14 +23,14 @@ crates in strictly lower layers. | L1 | `modelcraft-mesh` | Surface preparation (weld, watertightness, feature edges), robust predicates, Delaunay tetrahedralization and refinement, Voronoi-based optimization, hex meshing, quality metrics. | started | | L1 | `modelcraft-fem` | Element kernels shared by every structural solver: the element table (tet4, tet10, wedge6, hex8 full and incompatible modes, hex20 full and reduced), shape functions, quadrature, element stiffness, consistent and lumped mass, body and pressure loads, stresses and their extrapolation, isotropic and orthotropic elasticity, and the uniform-strain (one-point) hex8 and tet4 kinematics, hourglass shape vectors and element frequency bounds of the explicit solver. Pure functions, no solver loop. | started | | L1 | `modelcraft-decks` | Abaqus input, Nastran bulk data and LS-DYNA keyword readers and writers, to and from `model`. Text out, no file system; `deck.write` saves it. | started | -| L1 | `modelcraft-render` | Display data without a GPU dependency: the visible faces, element edges and nodes of a model, the orbit camera, a CPU rasterizer for headless snapshots; contour maps later. | started | +| L1 | `modelcraft-render` | Display data without a GPU dependency: the visible faces, element edges and nodes of a model, the orbit camera, a CPU rasterizer for headless snapshots, geometry surfaces with their face boundaries, deformed shapes and banded contour plots of nodal results, picking. | started | | L2 | `modelcraft-solver` | Implicit solver: assembly from the resolved model, boundary conditions, sparse direct solution, linear static and modal analysis, stresses and reactions. The lattice Boltzmann solver (`modelcraft-lbm`) joins this layer. | started | | L2 | `modelcraft-explicit` | Explicit dynamics: central-difference time integration with lumped mass, one-point hex8 with hourglass control, nodal-pressure tet4, bulk viscosity, rigid walls, energy account, histories and field frames. | started | | L3 | `modelcraft-fsi` | Fluid–structure coupling between the L2 solvers (later). | planned | | L4 | `modelcraft-doc` | The document: geometry, mesh, model, results, history. | planned | | L5 | `modelcraft-io` | `.modelcraft` files (docs/formats/modelcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery. Bytes in, bytes out; the file system only in `fs` and `recovery`. Result files later. | started | | L6 | `modelcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts. | started | -| L7 | `modelcraft-ui-egui`, `modelcraft-mcp` | Desktop UI (egui + wgpu viewport, model tree, commands, status bar) and control channel requests (docs/control-protocol.md); MCP server. | ui started | +| L7 | `modelcraft-ui-egui`, `modelcraft-mcp` | Desktop UI (egui + wgpu viewport, model tree, commands, status bar, results display with legend, panel and probe, offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server. | ui started | | testkit | `modelcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started | | apps | `modelcraft`, `modelcraft-cli`, `modelcraft-web` | Desktop, command line, browser. | desktop, cli started | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e53d285..e09d645 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -34,13 +34,13 @@ Commands never open forms when run this way. `modelcraft-cli commands` prints th | Method | Params | Notes | |---|---|---| -| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, version | +| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes) and the probed node (`probe`), version | | `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `perspective`, `dark`, `show_tree`, `show_commands`, `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`) | an unknown setting or value is an error; returns the settings | | `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | returns the camera | | `ui.toolbar` | — | the toolbar (`ui.set {tab}` switches tabs): `tab`, `tabs`, and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its drop-down's commands), `coming_soon` (the milestone, or null), `rect` (its button in screen points last frame, on the shown tab; a collapsed panel's entries share its drop-down button) and `fit` (`full`, `compact` icon-only, or `collapsed` into the panel's drop-down, by window width) | | `ui.start` | `command`: a command or toolbar entry id | like clicking it on the toolbar or in the Commands panel: a command without parameters runs, one with parameters opens its form; an entry that is coming soon is an error naming its milestone | | `ui.dialog` | `params?` (JSON object, fills the form), `run?`, `cancel?` | drives the open command form; `run` returns the command's result and closes the form on success | -| `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input | +| `ui.click` | `x`, `y` (screen points), `button?`: `left`/`right`/`middle`, `shift?`, `ctrl?`, `alt?`, `double?` | real pointer input; a click on a results plot probes the nearest node of the face under it (`solve.probe`; `ui.inspect` shows the values) | | `ui.move` | `x`, `y` | | | `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` (1–200) | press, move, release: in the viewport a left drag orbits, a right or middle drag (or Shift + left) pans | | `ui.scroll` | `x`, `y`, `delta?` (wheel lines, −100–100; positive zooms in) | zooms at the point | @@ -48,6 +48,7 @@ Commands never open forms when run this way. `modelcraft-cli commands` prints th | `ui.text` | `text` | typed text (into the focused field) | | `ui.worldToScreen` | `points`: `[[x, y, z]…]` (at most 1000) | screen points in the viewport drawn last frame (null when not on screen) | | `ui.resize` | `width`, `height` | window inner size in points | -| `ui.screenshot` | `path?` | PNG of the window, taken a few frames later; needs a presented frame (a hidden window or a locked screen answers with an error: use `ui.render`). Without `path`: `modelcraft-shot.png` in the temporary folder | +| `ui.screenshot` | `path?` | PNG of the window, taken a few frames later; waits for a presented frame; when none comes within 8 seconds (a hidden window, a locked screen) it answers with `ui.capture`'s offscreen picture (`"offscreen": true`). Without `path`: `modelcraft-shot.png` in the temporary folder | +| `ui.capture` | `path?`, `width?`, `height?` (points; default the window's), `scale?` (pixels per point, 0.5–4; default the window's) | PNG of the whole window (menu, toolbar, panels, forms and the viewport) drawn offscreen: the UI pass runs in a separate egui context and is rasterised on the CPU, so it works with the window hidden, minimised or the screen locked. At most 8192 pixels a side and 16 megapixels. Without `path`: `modelcraft-shot.png` in the temporary folder | | `ui.render` | `path`, `width?` (default 1280), `height?` (default 800): whole pixels, each 1–8192 and at most 16 megapixels in all | CPU render of the model with the current camera and view settings (no window or GPU needed). A size out of range, or a scene over 20 million primitives, is an error | | `app.quit` | — | closes the window | diff --git a/docs/features.md b/docs/features.md index c4d9dec..68c91b6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -5,7 +5,7 @@ Generated from the capability catalogue (`crates/engine/src/catalogue.rs`) by Every entry is a toolbar button. **Ready** entries run their command; **coming soon** entries are shown greyed with the roadmap milestone that brings them (ROADMAP.md). -37 ready, 63 coming soon. +42 ready, 58 coming soon. ## Geometry @@ -130,12 +130,12 @@ shown greyed with the roadmap milestone that brings them (ROADMAP.md). | Panel | Capability | Status | Command | |---|---|---|---| -| Display | Deformed — The deformed shape | coming soon (M4) | | -| Display | Contours — Contour plots of stress, strain and displacement | coming soon (M4) | | -| Display | Mode Shapes — Mode shapes of a frequency step | coming soon (M4) | | -| Display | Animate — Animate modes and time histories | coming soon (M9) | | -| Query | Probe — Displacement, reaction and stress at nodes, a mode shape, or an explicit field frame | ready | `solve.probe`, `solve.history` | -| Query | Reactions — Reaction force and moment sums | coming soon (M4) | | +| Display | Deformed — The deformed shape of a solved step, to scale | ready | `results.deformed`, `results.clear` | +| Display | Contours — Contours of displacement and stress, with a legend | ready | `results.contour` | +| Display | Mode Shapes — Mode shapes and frequencies of a frequency step | ready | `results.modes` | +| Display | Animate — Animate the deformed shape, a mode or an explicit step's frames | ready | `results.animate`, `results.frame` | +| Query | Probe — Displacement, reaction and stress at nodes, a mode shape, or an explicit field frame (or click the model) | ready | `solve.probe`, `solve.history` | +| Query | Reactions — Reaction force and moment sums per set | ready | `results.reactions` | | Query | Free Body — Free-body forces across a cut | coming soon (M9) | | | Plots | XY Plot — Plot a quantity against time or another | coming soon (M9) | | | Plots | Path Plot — Plot a quantity along a path | coming soon (M9) | | diff --git a/docs/solvers/literature.md b/docs/solvers/literature.md index bc964e2..72556be 100644 --- a/docs/solvers/literature.md +++ b/docs/solvers/literature.md @@ -969,6 +969,8 @@ the module docs): - Mass lumping: E. Hinton, T. Rock, O. C. Zienkiewicz, Earthquake Eng. Struct. Dyn. 4(3):245–249, 1976. - Stress extrapolation: E. Hinton, J. S. Campbell, IJNME 8(3):461–480, 1974. +- Principal stresses (results display): O. K. Smith, *Eigenvalues of a symmetric 3 × 3 matrix*, + Commun. ACM 4(4):168, 1961 (closed form). - Ordering: A. George, SIAM J. Numer. Anal. 10(2):345–363, 1973; H. D. Simon, Computing Systems in Engineering 2:135–148, 1991. - Sparse Cholesky: T. A. Davis, *Direct Methods for Sparse Linear Systems*, SIAM 2006; J. W. H.