forked from 1b-agent/fieldcraft-upstream
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 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs/features.md after the rebase Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
30 files changed
+2989
-86
No files matched your search
@@ -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<f32> = 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<Color32>,
|
||||
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<TextureId, Texture>,
|
||||
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<ColorImage, String> {
|
||||
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<Item = TextureId>) {
|
||||
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<Rgba>,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
fn new(w: usize, h: usize, background: Color32) -> Result<Canvas, String> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+360
-21
@@ -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<usize>,
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
/// 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<u64>, DisplayMesh),
|
||||
/// The results plot and what it was built for.
|
||||
plot: (Option<PlotKey>, Option<Arc<results::Plot>>),
|
||||
/// The node probed last (a click on a results plot).
|
||||
pub probe: Option<Probe>,
|
||||
/// The reaction sums asked for last in the results panel.
|
||||
pub reactions: Option<Value>,
|
||||
/// 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<Arc<modelcraft_geometry::Design>>, Vec<modelcraft_geometry::SurfaceMesh>),
|
||||
/// The frame-independent part of the results plot (scale, contour) and what it was built for.
|
||||
prepared: (Option<PlotKey>, results::Prepared),
|
||||
/// The built scene and its key.
|
||||
scene: (Option<u64>, Scene),
|
||||
/// The theme last applied to the context.
|
||||
@@ -133,6 +174,8 @@ pub struct ModelApp {
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
shots: Vec<Shot>,
|
||||
shot_token: u64,
|
||||
/// The offscreen context `ui.capture` draws the window with (made on first use).
|
||||
offscreen: Option<Box<capture::Offscreen>>,
|
||||
/// 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<Value, String> {
|
||||
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<String>, 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<PlotKey> {
|
||||
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<Arc<results::Plot>> {
|
||||
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<Shot>, Vec<Shot>) = 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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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), "—");
|
||||
}
|
||||
}
|
||||
@@ -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<egui::Pos2> =
|
||||
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"]);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user