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

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

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

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

* Draw the open design and imported surfaces in the viewport

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

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

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

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

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

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

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

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

* Simplify the results display (review)

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

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

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

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

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

* docs/features.md after the rebase

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

---------

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

No files matched your search

+324
View File
@@ -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<f64>,
}
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<StepResult>,
/// 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<Arc<Contour>>,
/// 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<Arc<Contour>>,
}
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<Vec3> {
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<StepResult>, 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<u64> {
(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<u64>) -> 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<u64>) -> 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<Prepared> {
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<u64>, prepared: &Prepared) -> Option<Plot> {
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<u64>) -> Option<Plot> {
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::<Vec<_>>();
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::<Vec<_>>();
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), "—");
}
}