Files
OneCAD-Simulation/crates/engine/src/view.rs
T
jyvikoandClaude Opus 5.5 91e8467e4f engine, render: hidden bodies hide their mesh and results; solve totals without round-off
- DisplayMesh::from_model_hiding leaves out a hidden body's elements (and the
  nodes only they use); Session::hidden_elements names them, so the viewport,
  probing and headless pictures skip hidden bodies (#365).
- A solve summary's applied and reaction totals set the round-off of
  cancelling sums to 0 (#319).
- results::principal for the probe's principal stresses (#329).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:36:54 -04:00

379 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Headless pictures of a session: the model, its geometry and the results display drawn with
//! the CPU rasterizer of [`fieldcraft_render`], with no window or GPU (the MCP server's
//! screenshots without an app, docs/mcp.md).
//!
//! The picture follows the desktop viewport's conventions: geometry draws as faces with their
//! boundaries while the model has no element faces, then as boundaries only; elements in their
//! kind's colour with their edges; a results view ([`crate::results::ResultsView`]) draws the
//! deformed shape (automatic or chosen scale) with its contour bands. Animation is not drawn: a
//! picture shows the full deformation (a mode at its positive shape). The analysis setup's
//! symbols ([`fieldcraft_render::glyphs`]) are drawn over the model as the viewport draws them,
//! muted over a results plot (and only those its step used), at their default size on screen
//! once the camera is known.
use fieldcraft_model::ElementKind;
use fieldcraft_render::contour::{Bands, band_color, split_triangle};
use fieldcraft_render::glyphs::{self, GlyphColors, GlyphShow, GlyphView, Glyphs};
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, SceneTri, Vec3};
use serde_json::Value;
use crate::results::{auto_scale, displacements, field_values, range};
use crate::{EngineError, Result, Session};
/// The colours of a picture.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Palette {
/// Background gradient, top to bottom.
pub background: (Rgb, Rgb),
pub hex: Rgb,
pub tet: Rgb,
pub wedge: Rgb,
pub shell: Rgb,
pub line_element: Rgb,
pub mesh_edge: Rgb,
pub geometry: Rgb,
pub geometry_edge: Rgb,
/// The bodies of a design with several, in turn.
pub bodies: [Rgb; 8],
/// Faces of nodes without a value in a contour plot.
pub no_result: Rgb,
/// The contour colour scale, low to high.
pub contour: [Rgb; 5],
/// The analysis setup's symbols.
pub glyphs: GlyphColors,
}
impl Palette {
/// The desktop app's light theme.
pub const LIGHT: Palette = Palette {
background: (Rgb(0xf0, 0xf3, 0xf7), Rgb(0xc8, 0xd0, 0xdc)),
hex: Rgb(0x78, 0x9c, 0xc0),
tet: Rgb(0x80, 0xb4, 0x8a),
wedge: Rgb(0xc0, 0xaa, 0x78),
shell: Rgb(0xc0, 0x8c, 0x78),
line_element: Rgb(0xc8, 0x8c, 0x1e),
mesh_edge: Rgb(0x1e, 0x23, 0x2a),
geometry: Rgb(0xb4, 0xba, 0xc4),
geometry_edge: Rgb(0x2a, 0x30, 0x3a),
bodies: [
Rgb(0x8f, 0xb4, 0xd9),
Rgb(0xe0, 0xa4, 0x6c),
Rgb(0x8c, 0xc4, 0x8a),
Rgb(0xd6, 0x8c, 0x96),
Rgb(0xb0, 0x9c, 0xd8),
Rgb(0xd8, 0xc8, 0x70),
Rgb(0x7c, 0xc4, 0xc0),
Rgb(0xc0, 0xa0, 0x84),
],
no_result: Rgb(0x9a, 0xa0, 0xaa),
contour: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)],
glyphs: GlyphColors {
constraint: Rgb(0x00, 0x8a, 0xa8),
load: Rgb(0xd0, 0x24, 0x24),
interaction: Rgb(0x9a, 0x30, 0xc0),
thermal: Rgb(0xe8, 0x4c, 0x00),
label: Rgb(0x26, 0x2a, 0x32),
datum: Rgb(0x1e, 0x8c, 0x6e),
contact: Rgb(0x6a, 0x8a, 0x00),
highlight: Rgb(0xd6, 0x00, 0x7f),
},
};
fn element(&self, kind: ElementKind) -> Rgb {
match kind {
ElementKind::Hex8 | ElementKind::Hex20 => self.hex,
ElementKind::Tet4 | ElementKind::Tet10 => self.tet,
ElementKind::Wedge6 => self.wedge,
ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => self.shell,
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => self.line_element,
}
}
}
/// Mesh edge, geometry edge and line element widths, in output pixels.
const EDGE_WIDTH: f64 = 1.0;
const GEOMETRY_EDGE_WIDTH: f64 = 1.5;
const LINE_ELEMENT_WIDTH: f64 = 3.0;
/// The chordal tolerance geometry is tessellated with for display: a thousandth of its size.
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 }
}
/// What a picture shows besides the image.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Shown {
/// The solved step drawn, when the results display is on.
pub step: Option<String>,
/// The deformation scale factor drawn (0: undeformed).
pub scale: f64,
/// The contour field drawn, with its smallest and largest value and their nodes.
pub contour: Option<(String, (u32, f64), (u32, f64))>,
/// Bodies that could not be tessellated for display.
pub failed: Vec<String>,
/// Definitions whose symbols are a sample (`Clamp: 400 of 12000 nodes`).
pub sampled: Vec<String>,
}
/// The model's display data with the open design's bodies and the imported surfaces.
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
let mut d = DisplayMesh::from_model_hiding(&s.model, &s.hidden_elements());
let mut failed = Vec::new();
for body in s.design.iter().flat_map(|d| &d.bodies).filter(|b| s.shows(fieldcraft_io::ItemKind::Body, &b.name)) {
let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() })));
match mesh {
Ok(m) => d.add_surface(&m.positions, &m.triangles, &m.tri_face),
Err(e) => failed.push(format!("{}: {e}", body.name)),
}
}
for surface in s.surfaces.iter().filter(|x| s.shows(fieldcraft_io::ItemKind::Surface, &x.name)) {
d.add_surface(&surface.mesh.positions, &surface.mesh.triangles, &surface.mesh.tri_face);
}
(d, failed)
}
/// The model drawn as the viewport draws it (shaded with element edges).
fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene {
let mut s = Scene { background: Some(p.background), ..Default::default() };
s.tris = d.tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: p.element(t.kind) }).collect();
let faces_drawn = d.tris.is_empty();
if faces_drawn {
s.tris.extend(d.surface_tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: d.body_color(t, p.geometry, &p.bodies) }));
}
s.lines = d.edges.iter().map(|e| SceneLine { a: e.p[0], b: e.p[1], color: p.mesh_edge, width: EDGE_WIDTH }).collect();
if faces_drawn {
s.lines.extend(d.surface_edges.iter().map(|[a, b]| SceneLine { a: *a, b: *b, color: p.mesh_edge, width: GEOMETRY_EDGE_WIDTH }));
} else {
// Over the mesh the geometry's edges are drawn along it (the element edges on them), so
// they lie on the element faces.
let edge = p.geometry_edge;
s.lines.extend(d.edges.iter().filter(|e| e.feature).map(|e| SceneLine { a: e.p[0], b: e.p[1], color: edge, width: GEOMETRY_EDGE_WIDTH }));
}
s.lines.extend(d.lines.iter().map(|l| SceneLine { a: l.a, b: l.b, color: p.element(l.kind), width: LINE_ELEMENT_WIDTH }));
s
}
/// The model part of a picture: the model or the results view's plot, the shape the symbols sit
/// on (the deformed one of a plot), the box around it and what it shows.
struct Picture {
scene: Scene,
drawn: DisplayMesh,
bounds: Aabb3,
shown: Shown,
glyphs: Glyphs,
colors: GlyphColors,
/// The box around the datums drawn: inside the clipping radius, never framed (#295).
datums: Aabb3,
}
impl Picture {
/// The setup's symbols at symbol unit `unit` ([`glyphs::symbol_unit`]), in a scene of their
/// own; notes the definitions drawn with a sample. Returns the box around them.
fn symbols(&mut self, unit: f64, into: &mut Scene) -> Aabb3 {
let drawn = &self.drawn;
let view = GlyphView { show: GlyphShow::ALL, colors: &self.colors, unit, highlight: None };
let d = self.glyphs.draw(|label| drawn.node_position(label), &view, into);
self.shown.sampled = d.notes;
d.bounds
}
}
fn picture(s: &Session, p: &Palette) -> Picture {
let (base, failed) = display(s);
let mut shown = Shown { failed, ..Shown::default() };
let plot = s.results_view.as_ref().and_then(|v| s.latest_result(&v.step).map(|(r, _)| (v, r)));
// Over a results plot, the symbols of what its step used.
let glyphs = plot.map_or_else(|| s.glyphs(), |(v, _)| s.step_glyphs(&v.step));
let Some((view, result)) = plot else {
let mut scene = model_scene(&base, p);
let bounds = base.bounds;
let shows = |n: &str| s.shows(fieldcraft_io::ItemKind::Datum, n);
let datums = fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &shows, None, &p.glyphs, &mut scene);
return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs, datums };
};
shown.step = Some(view.step.clone());
let pick = view.pick();
let nodes = result.nodes();
let u = displacements(result, pick).unwrap_or(&[]);
let scale = if !view.deformed { 0.0 } else { view.scale.unwrap_or_else(|| auto_scale(result, pick, base.bounds.diagonal())) };
shown.scale = scale;
let drawn = base.displaced(|label| {
let i = nodes.binary_search(&label).ok()?;
u.get(i).map(|d| Vec3::from(*d) * scale)
});
let mut scene = model_scene(&drawn, p);
let contour = view.contour.and_then(|field| {
let values = field_values(&s.model, result, field, pick).ok()?;
let (min, max) = range(nodes, &values)?;
Some((field, min, max, values))
});
if let Some((field, min, max, values)) = contour {
shown.contour = Some((field.id().to_string(), min, max));
let bands = Bands::new(min.1, max.1, view.bands);
let at = |label: u32| nodes.binary_search(&label).ok().and_then(|i| values.get(i).copied()).unwrap_or(f64::NAN);
scene.tris.clear();
for tri in &drawn.tris {
split_triangle(tri.p, tri.nodes.map(at), &bands, &mut |piece, band| {
let color = band.map_or(p.no_result, |k| band_color(&p.contour, k, bands.count));
for k in 1..piece.len().saturating_sub(1) {
if let (Some(&a), Some(&b), Some(&c)) = (piece.first(), piece.get(k), piece.get(k + 1)) {
scene.tris.push(SceneTri { p: [a, b, c], normal: tri.normal, color });
}
}
});
}
}
let mut bounds = base.bounds;
bounds.union(drawn.bounds);
Picture { scene, drawn, bounds, shown, glyphs, colors: p.glyphs.over_results(p.background.1), datums: Aabb3::EMPTY }
}
/// The scene of the session (the model, or the results view's plot) with the setup's symbols
/// at the model's own symbol unit, the box around it, and what it shows.
pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) {
let mut pic = picture(s, p);
let mut symbols = Scene::default();
let b = pic.symbols(pic.glyphs.model_unit(), &mut symbols);
let mut scene = pic.scene;
scene.append(symbols);
pic.bounds.union(b);
(scene, pic.bounds, pic.shown)
}
/// A camera orientation from JSON, `{"yaw": …, "pitch": …, "distance"?: …}`: degrees, and the
/// distance from the target in model units (the control channel's `ui.camera`, the MCP
/// `screenshot` tool's `camera`). Hostile input is refused or clamped
/// ([`Orientation::checked`]).
pub fn orientation(p: &Value) -> Result<Orientation> {
let bad = |m: &str| EngineError::Params(format!("camera: {m}"));
let o = p.as_object().ok_or_else(|| bad("an object {yaw, pitch, distance?} in degrees"))?;
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "yaw" | "pitch" | "distance")) {
return Err(bad(&format!("unknown field `{}` (yaw, pitch, distance)", k.chars().take(40).collect::<String>())));
}
let number = |k: &str| o.get(k).filter(|v| !v.is_null()).map(|v| v.as_f64().ok_or_else(|| bad(&format!("`{k}` must be a number"))));
let yaw = number("yaw").ok_or_else(|| bad("`yaw` (degrees) is required"))??;
let pitch = number("pitch").ok_or_else(|| bad("`pitch` (degrees) is required"))??;
let distance = number("distance").transpose()?;
Orientation::checked(yaw, pitch, distance).ok_or_else(|| bad("`yaw` and `pitch` must be finite and `distance` finite and above 0"))
}
/// A PNG of the session seen from `at`, `w` × `h` pixels: framing everything drawn but the
/// datums (they are drawn at the model's extent, and framing them would only add margin), or from
/// `at`'s distance when it has one. The symbols are drawn at their default size on screen
/// ([`glyphs::DEFAULT_SIZE`] pixels) for that camera.
pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
let mut pic = picture(s, p);
let mut cam = Camera::default();
let aspect = w as f64 / h.max(1) as f64;
let unit = |cam: &Camera| cam.length_per_pixel(h as f64).and_then(|l| glyphs::symbol_unit(l, glyphs::DEFAULT_SIZE)).unwrap_or(f64::NAN);
// Framed on the model, then again with its symbols at that camera's size, which reach a
// little beyond it.
cam.orient(&Orientation { distance: None, ..*at });
cam.fit_in(&pic.bounds, aspect, fieldcraft_render::FULL_VIEW);
let mut symbols = Scene::default();
let mut framed = pic.bounds;
framed.union(pic.symbols(unit(&cam), &mut symbols));
cam.fit_in(&framed, aspect, fieldcraft_render::FULL_VIEW);
cam.orient(at);
symbols = Scene::default();
let mut bounds = pic.bounds;
bounds.union(pic.symbols(unit(&cam), &mut symbols));
bounds.union(pic.datums);
pic.scene.radius = if bounds.is_empty() { 1.0 } else { (cam.target.dist(bounds.center()) + bounds.diagonal() * 0.5).max(1e-3) };
let png = fieldcraft_render::render_layers_png(&pic.scene, &[&symbols], &cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
Ok((png, pic.shown))
}
#[cfg(test)]
mod tests {
use fieldcraft_render::StandardView;
use serde_json::json;
use super::*;
#[test]
fn renders_the_model_and_its_results() {
let iso = Orientation::of(StandardView::Iso);
let mut s = Session::default();
let empty = render_png(&s, &iso, 32, 24, &Palette::LIGHT).unwrap();
assert!(empty.0.starts_with(&[0x89, b'P', b'N', b'G']));
s.run_script(&crate::sample::cantilever()).unwrap();
let (model, shown) = render_png(&s, &iso, 64, 48, &Palette::LIGHT).unwrap();
assert_eq!(shown, Shown::default());
// The clamp's triangles and the push's arrows are drawn in their colours, as wireframes.
let (scene, ..) = super::scene(&s, &Palette::LIGHT);
assert!(scene.lines.iter().any(|l| l.color == Palette::LIGHT.glyphs.constraint));
assert!(scene.lines.iter().any(|l| l.color == Palette::LIGHT.glyphs.load));
assert!(!scene.tris.iter().any(|t| t.color == Palette::LIGHT.glyphs.constraint));
// And in the picture: pixels of the clamp's colour.
let (png, _) = render_png(&s, &iso, 320, 240, &Palette::LIGHT).unwrap();
assert!(count_color(&png, Palette::LIGHT.glyphs.constraint) > 20);
s.execute("solve.static", &json!({})).unwrap();
let (plot, shown) = render_png(&s, &iso, 64, 48, &Palette::LIGHT).unwrap();
assert_ne!(model, plot);
// A static step first shows its contours on the undeformed shape.
assert!(shown.scale == 0.0 && shown.step.is_some(), "{shown:?}");
let (field, min, max) = shown.contour.unwrap();
assert_eq!(field, "von_mises");
assert!(max.1 > min.1);
assert!(render_png(&s, &Orientation::of(StandardView::Top), 0, 48, &Palette::LIGHT).is_err());
// Over the plot the symbols are muted.
let (scene, ..) = super::scene(&s, &Palette::LIGHT);
let muted = Palette::LIGHT.glyphs.over_results(Palette::LIGHT.background.1);
assert!(scene.lines.iter().any(|l| l.color == muted.constraint));
assert!(!scene.lines.iter().any(|l| l.color == Palette::LIGHT.glyphs.constraint));
}
/// Pixels of colour `c` (within a few levels) in a PNG.
fn count_color(png: &[u8], c: Rgb) -> usize {
let d = png::Decoder::new(std::io::Cursor::new(png));
let mut r = d.read_info().unwrap();
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let info = r.next_frame(&mut buf).unwrap();
let close = |a: u8, b: u8| a.abs_diff(b) <= 6;
buf[..info.buffer_size()].chunks(4).filter(|p| close(p[0], c.0) && close(p[1], c.1) && close(p[2], c.2)).count()
}
/// The symbols are as large on screen in a close-up as in a distant view.
#[test]
fn symbols_keep_their_size_in_pictures_at_any_distance() {
let mut s = Session::default();
s.run_script(&crate::sample::cantilever()).unwrap();
let at = |d: f64| orientation(&json!({"yaw": -45, "pitch": 30, "distance": d})).unwrap();
let clamp = |d: f64| count_color(&render_png(&s, &at(d), 400, 300, &Palette::LIGHT).unwrap().0, Palette::LIGHT.glyphs.constraint);
let (near, far) = (clamp(120.0), clamp(1200.0));
// Zoomed out ten times, the symbols are as large (a few overlap and thin out).
assert!(far > 30 && far * 4 > near, "near {near}, far {far}");
}
#[test]
fn camera_orientation_is_checked() {
let o = orientation(&json!({"yaw": -45, "pitch": 30})).unwrap();
assert_eq!((o.yaw, o.pitch, o.distance), (-45.0, 30.0, None));
let o = orientation(&json!({"yaw": 400, "pitch": -400, "distance": 1e300})).unwrap();
assert_eq!((o.yaw, o.pitch, o.distance), (40.0, -90.0, Some(Orientation::DISTANCE.1)));
for hostile in [
json!(null),
json!([1, 2]),
json!({}),
json!({"yaw": 0}),
json!({"pitch": 0}),
json!({"yaw": "left", "pitch": 0}),
json!({"yaw": 0, "pitch": 0, "distance": 0}),
json!({"yaw": 0, "pitch": 0, "distance": -5}),
json!({"yaw": 0, "pitch": 0, "distance": "far"}),
json!({"yaw": 0, "pitch": 0, "roll": 3}),
json!({"yaw": f64::INFINITY, "pitch": 0}),
] {
assert!(orientation(&hostile).is_err(), "{hostile}");
}
// A picture from a chosen orientation and distance differs from the framed iso one.
let mut s = Session::default();
s.run_script(&crate::sample::cantilever()).unwrap();
let iso = render_png(&s, &Orientation::of(StandardView::Iso), 64, 48, &Palette::LIGHT).unwrap().0;
let side = render_png(&s, &orientation(&json!({"yaw": -80, "pitch": 10, "distance": 2000})).unwrap(), 64, 48, &Palette::LIGHT).unwrap().0;
assert_ne!(iso, side);
}
}