Files
OneCAD-Simulation/crates/engine/src/view.rs
T
jyvikoandClaude Opus 5.5 3da332be77 ui: pyramid colour stands out from the selection pinks (fix develop CI)
The pyramid5 colour added in #517 sat within the selection test's distance of
the hover pink (dark and light). Darker violet in both themes; the headless
palette matches.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-16 14:56:10 -04:00

1124 lines
52 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_color, split_triangle};
use fieldcraft_render::glyphs::{self, GlyphColors, GlyphShow, GlyphView, Glyphs};
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Orientation, Rgb, Scene, SceneLine, ScenePoint, 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 pyramid: Rgb,
pub shell: Rgb,
pub line_element: Rgb,
pub mesh_edge: Rgb,
/// Nodes, when they are shown.
pub node: 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],
/// Values above a clamped legend's maximum.
pub contour_over: Rgb,
/// The elements `mesh.quality.mark` marked.
pub marked: Rgb,
/// Faces merged for meshing (`geometry.faces.merge`).
pub merged: Rgb,
/// The analysis setup's symbols.
pub glyphs: GlyphColors,
}
/// Values above a clamped legend's maximum (both themes): a deep purple apart from the scale's red.
pub const CONTOUR_OVER: Rgb = Rgb(0x6e, 0x2c, 0x80);
impl Palette {
/// The desktop app's light theme (its `Tokens::light().palette()`, a test there checks).
pub const LIGHT: Palette = Palette {
background: (Rgb(240, 243, 247), Rgb(203, 210, 221)),
hex: Rgb(0x78, 0x9c, 0xc0),
tet: Rgb(0x80, 0xb4, 0x8a),
wedge: Rgb(0xc0, 0xaa, 0x78),
pyramid: Rgb(0x8c, 0x80, 0xb0),
shell: Rgb(0xc0, 0x8c, 0x78),
line_element: Rgb(0xc8, 0x8c, 0x1e),
mesh_edge: Rgb(0x1e, 0x23, 0x2a),
node: Rgb(0xb4, 0x3c, 0x8c),
geometry: Rgb(176, 186, 198),
geometry_edge: Rgb(40, 44, 52),
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)],
contour_over: CONTOUR_OVER,
marked: Rgb(214, 54, 54),
merged: Rgb(222, 150, 30),
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),
},
};
/// The colour elements of this kind are drawn in.
pub 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::Pyramid5 => self.pyramid,
ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => self.shell,
ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => self.line_element,
}
}
}
/// How the model is drawn.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Style {
/// Lit faces with the element edges on them.
#[default]
ShadedEdges,
/// Lit faces only.
Shaded,
/// Element edges only.
Wireframe,
}
impl Style {
pub const ALL: [Style; 3] = [Style::ShadedEdges, Style::Shaded, Style::Wireframe];
pub fn label(self) -> &'static str {
match self {
Style::ShadedEdges => "Shaded with edges",
Style::Shaded => "Shaded",
Style::Wireframe => "Wireframe",
}
}
}
/// What is shown (the browser's eye toggles).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Show {
/// The open design and imported surfaces.
pub geometry: bool,
/// Elements.
pub mesh: bool,
pub nodes: bool,
/// Boundary conditions and loads.
pub loads: bool,
/// Reference points, couplings and rigid walls.
pub interactions: bool,
}
impl Show {
/// Geometry, mesh and the analysis setup, without nodes.
pub const DEFAULT: Show = Show { geometry: true, mesh: true, nodes: false, loads: true, interactions: true };
}
/// 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;
/// The scene for `display` drawn in `style` with `p`'s colours: the one builder of the window's
/// viewport and the headless pictures, so both draw the same model.
///
/// Geometry (the open design, imported surfaces) draws as faces with their boundaries while
/// the model has no element faces; once it is meshed, as its boundaries drawn along the mesh
/// (the element edges on geometry edges), so the mesh is not hidden behind coincident faces and
/// every line lies on the element faces.
pub fn build(display: &DisplayMesh, style: Style, show: Show, p: &Palette) -> Scene {
let mut s = Scene { background: Some(p.background), ..Default::default() };
// Geometry faces draw where no element faces cover them.
let mesh_faces = show.mesh && !display.tris.is_empty();
if style != Style::Wireframe {
if show.mesh {
s.tris = display.tris.iter().map(|d| SceneTri { p: d.p, normal: d.normal, color: p.element(d.kind) }).collect();
}
if !mesh_faces && show.geometry {
s.tris.extend(display.surface_tris.iter().map(|d| SceneTri {
p: d.p,
normal: d.normal,
color: display.body_color(d, p.geometry, &p.bodies),
}));
}
}
if style != Style::Shaded && show.mesh {
// Without faces behind them the edges take the element colour, so they read on the
// background.
let color = if style == Style::Wireframe { p.hex } else { p.mesh_edge };
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. Over the mesh they are
// the element edges on geometry edges, so they lie exactly on the element faces; the
// geometry's own tessellation lies off them by its chord error.
if show.geometry {
let color = p.geometry_edge;
if mesh_faces {
s.lines.extend(display.edges.iter().filter(|e| e.feature).map(|e| SceneLine { a: e.p[0], b: e.p[1], color, width: GEOMETRY_EDGE_WIDTH }));
} else {
s.lines.extend(display.surface_edges.iter().map(|[a, b]| SceneLine { a: *a, b: *b, color, width: GEOMETRY_EDGE_WIDTH }));
}
}
if show.mesh {
s.lines.extend(display.lines.iter().map(|l| SceneLine { a: l.a, b: l.b, color: p.element(l.kind), width: LINE_ELEMENT_WIDTH }));
}
if show.nodes {
s.points = display.nodes.iter().map(|n| ScenePoint { p: n.p, color: p.node, size: NODE_SIZE }).collect();
}
s
}
/// 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 }
}
/// The findings of a `geometry.check`, to draw over the design it checked.
#[derive(Clone, Debug, Default)]
pub struct GeometryMarks {
/// The design checked: the marks are stale once another is open.
pub design: std::sync::Weak<fieldcraft_geometry::Design>,
pub marks: Vec<GeometryMark>,
}
/// One finding to draw: its faces, its edge and the point to look at.
#[derive(Clone, Debug, PartialEq)]
pub struct GeometryMark {
/// The faces by body name and face index.
pub faces: Vec<(String, u32)>,
/// A short edge's polyline (empty for the other kinds).
pub path: Vec<[f64; 3]>,
pub at: [f64; 3],
/// A small or sliver face, by body and persistent name: what `geometry.face.absorb`
/// `from_check` absorbs.
pub small: Option<(String, String)>,
}
/// The findings of the last `geometry.check`, while the design it checked is still open.
pub fn geometry_marks(s: &Session) -> Option<&GeometryMarks> {
let marks = s.geometry_marks.as_deref()?;
let design = s.design.as_ref()?;
std::sync::Weak::ptr_eq(&marks.design, &std::sync::Arc::downgrade(design)).then_some(marks)
}
/// Marker line width, and the marker dot's diameter, in output pixels.
const MARK_WIDTH: f64 = 4.0;
const MARK_DOT: f64 = 12.0;
/// Draw `marks` over `scene` in `color`: the faces they name recoloured when `faces` (the
/// scene's triangles start with `display`'s surface triangles in order, as they do while no
/// element faces are drawn; `bodies` names the drawn surfaces in order), short edges as thick
/// lines and a dot at each finding's point, both over everything so a feature too small to see
/// at the zoom, or inside the mesh, still shows where it is.
pub fn draw_geometry_marks(scene: &mut Scene, display: &DisplayMesh, bodies: &[&str], marks: &GeometryMarks, faces: bool, color: Rgb) {
if faces {
let marked: std::collections::BTreeSet<(u32, u32)> = marks
.marks
.iter()
.flat_map(|m| &m.faces)
.filter_map(|(body, f)| bodies.iter().position(|b| b == body).and_then(|b| u32::try_from(b).ok()).map(|b| (b, *f)))
.collect();
for (t, d) in scene.tris.iter_mut().zip(&display.surface_tris) {
if marked.contains(&(d.body, d.face)) {
t.color = color;
}
}
}
for m in &marks.marks {
for w in m.path.windows(2) {
if let [a, b] = w {
scene.on_top.push(SceneLine { a: Vec3::from(*a), b: Vec3::from(*b), color, width: MARK_WIDTH });
}
}
let at = Vec3::from(m.at);
scene.on_top.push(SceneLine { a: at, b: at, color, width: MARK_DOT });
}
}
/// Draw the merged faces (`geometry.faces.merge`) of the drawn bodies (`bodies`, in the order of
/// their surfaces) in `color`, so each shows as one face in one colour. The scene's triangles
/// start with `display`'s surface triangles in order, as they do while no element faces are drawn.
pub fn draw_merged_faces(scene: &mut Scene, display: &DisplayMesh, s: &Session, bodies: &[&str], color: Rgb) {
let Some(design) = s.design.as_deref() else { return };
let merged: std::collections::BTreeSet<(u32, u32)> = bodies
.iter()
.enumerate()
.filter_map(|(i, name)| Some((u32::try_from(i).ok()?, design.body(name)?)))
.flat_map(|(i, b)| b.merged_faces().into_iter().map(move |(f, _)| (i, f)))
.collect();
if merged.is_empty() {
return;
}
for (t, d) in scene.tris.iter_mut().zip(&display.surface_tris) {
if merged.contains(&(d.body, d.face)) {
t.color = color;
}
}
}
/// 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>,
}
/// A section through the mesh: the elements whose centroid lies on the camera's side of a plane
/// normal to an axis are not drawn, so the element faces inside show (an element cut: whole elements, not
/// sliced ones). Geometry surfaces are not drawn over a sectioned mesh.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Section {
/// 0, 1, 2: the plane is normal to X, Y, Z.
pub axis: usize,
/// Where the plane crosses the axis; `None` at the middle of the mesh.
pub at: Option<f64>,
}
impl Section {
/// `x`, `y`, `z` (the middle of the mesh) or `x=12.5`.
pub fn parse(s: &str) -> Option<Section> {
let (axis, at) = match s.split_once('=') {
Some((a, v)) => (a, Some(v.trim().parse::<f64>().ok().filter(|v| v.is_finite())?)),
None => (s, None),
};
let axis = ["x", "y", "z"].iter().position(|n| n.eq_ignore_ascii_case(axis.trim()))?;
Some(Section { axis, at })
}
/// The elements on the side of the plane `toward` points to (the direction toward the eye).
fn beyond(&self, m: &fieldcraft_model::Model, toward: Vec3) -> std::collections::BTreeSet<u32> {
let sign = match [toward.x, toward.y, toward.z].get(self.axis) {
Some(v) if *v < 0.0 => -1.0,
_ => 1.0,
};
let coord = |label: &u32| m.node(*label).and_then(|n| n.xyz.get(self.axis).copied());
let centroid = |nodes: &[u32]| {
let v: Vec<f64> = nodes.iter().filter_map(coord).collect();
(!v.is_empty()).then(|| v.iter().sum::<f64>() / v.len() as f64)
};
let at = self.at.unwrap_or_else(|| {
let (lo, hi) = m
.nodes()
.values()
.filter_map(|n| n.xyz.get(self.axis).copied())
.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(v), b.max(v)));
if lo <= hi { (lo + hi) * 0.5 } else { 0.0 }
});
m.elements().iter().filter(|(_, e)| centroid(&e.nodes).is_some_and(|c| (c - at) * sign > 0.0)).map(|(l, _)| *l).collect()
}
}
/// The model's display data with the open design's bodies and the imported surfaces.
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
display_cut(s, None, Vec3::Z)
}
/// [`display`] with a section through the mesh, cut away on the side `toward` points to.
pub fn display_cut(s: &Session, section: Option<Section>, toward: Vec3) -> (DisplayMesh, Vec<String>) {
let (d, failed, _) = display_parts(s, section, toward);
(d, failed)
}
/// [`display_cut`], and the names of the bodies drawn in the order of their surfaces
/// ([`fieldcraft_render::SurfaceTri::body`]).
fn display_parts(s: &Session, section: Option<Section>, toward: Vec3) -> (DisplayMesh, Vec<String>, Vec<String>) {
let mut bodies = Vec::new();
let mut hidden = s.hidden_elements();
let cut = section.filter(|_| !s.model.elements().is_empty());
if let Some(c) = cut {
hidden.extend(c.beyond(&s.model, toward));
}
let mut d = DisplayMesh::from_model_hiding(&s.model, &hidden);
let mut failed = Vec::new();
if cut.is_some() {
return (d, failed, bodies);
}
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(mut m) => {
// A merged face's B-rep faces were tessellated apart: their copies of the points
// they share differ by up to the weld distance, and would draw as edges.
if !body.merged_faces().is_empty() {
weld_points(&mut m.positions, m.weld_distance);
}
d.add_surface(&m.positions, &m.triangles, &m.tri_face);
bodies.push(body.name.clone());
}
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, bodies)
}
/// Move every point within `distance` of an earlier one (in x order) onto it, so copies of one
/// point are identical.
fn weld_points(positions: &mut [[f64; 3]], distance: f64) {
let mut order: Vec<usize> = (0..positions.len()).collect();
order.sort_by(|&a, &b| positions.get(a).map_or(0.0, |p| p[0]).total_cmp(&positions.get(b).map_or(0.0, |p| p[0])));
let close = |p: [f64; 3], q: [f64; 3]| (0..3).map(|k| (p[k] - q[k]).powi(2)).sum::<f64>() <= distance * distance;
for (k, &i) in order.iter().enumerate() {
let Some(p) = positions.get(i).copied() else { continue };
// The earliest point within reach, among those not far behind in x.
let mut to = p;
for &j in order.get(..k).unwrap_or_default().iter().rev() {
let Some(q) = positions.get(j).copied() else { continue };
if p[0] - q[0] > distance {
break;
}
if close(p, q) {
to = q;
}
}
if let Some(slot) = positions.get_mut(i) {
*slot = to;
}
}
}
/// The model drawn as the viewport draws it by default (shaded with element edges).
fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene {
build(d, Style::ShadedEdges, Show::DEFAULT, p)
}
/// The elements `mesh.quality.mark` marked (its set), drawn in the marker colour.
pub fn marked(s: &Session) -> std::collections::BTreeSet<u32> {
s.model.set(crate::cmd::mesh::MARKED_SET).map(|set| set.elements.iter().copied().collect()).unwrap_or_default()
}
/// Colour the faces of the `marked` elements in `scene`, whose triangles start with the element
/// faces of `display` in order (as [`model_scene`] and the app's viewport build them).
pub fn mark(scene: &mut Scene, display: &DisplayMesh, marked: &std::collections::BTreeSet<u32>, color: Rgb) {
if marked.is_empty() {
return;
}
for (t, d) in scene.tris.iter_mut().zip(&display.tris) {
if marked.contains(&d.element) {
t.color = color;
}
}
}
/// 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,
/// The bodies drawn, in the order of their surfaces.
bodies: Vec<String>,
}
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, section: Option<Section>, toward: Vec3) -> Picture {
let (base, failed, bodies) = display_parts(s, section, toward);
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);
mark(&mut scene, &base, &marked(s), p.marked);
// `bodies` names the drawn surfaces in order.
let drawn: Vec<&str> = bodies.iter().map(String::as_str).collect();
if base.tris.is_empty() {
draw_merged_faces(&mut scene, &base, s, &drawn, p.merged);
}
if let Some(marks) = geometry_marks(s) {
draw_geometry_marks(&mut scene, &base, &drawn, marks, base.tris.is_empty(), p.marked);
}
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, bodies };
};
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 = view.bands(min.1, max.1);
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| bands_color(&p.contour, p.contour_over, &bands, k));
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, bodies }
}
/// 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, None, Vec3::Z);
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"))
}
/// What a camera looks at: a point, or the centre of mass or of the bounding box of the bodies
/// named (every body drawn when `None`).
#[derive(Clone, Debug, PartialEq)]
pub enum Target {
Point(Vec3),
Mass(Option<Vec<String>>),
Bbox(Option<Vec<String>>),
}
impl Target {
/// The default: the centre of mass of the model drawn, so a sweep of yaws orbits it.
pub const MODEL: Target = Target::Mass(None);
fn bodies(&self) -> Option<&[String]> {
match self {
Target::Point(_) => None,
Target::Mass(b) | Target::Bbox(b) => b.as_deref(),
}
}
/// The JSON form [`camera`] reads.
pub fn json(&self) -> Value {
let center = |what: &str, bodies: &Option<Vec<String>>| match bodies {
Some(b) => serde_json::json!({"center_of": what, "bodies": b}),
None => serde_json::json!({"center_of": what}),
};
match self {
Target::Point(p) => serde_json::json!([p.x, p.y, p.z]),
Target::Mass(b) => center("mass", b),
Target::Bbox(b) => center("bbox", b),
}
}
}
/// A camera aimed as agents give it ([`camera`]): an orientation, what it looks at, whether its
/// distance fits the target's bodies to the frame, and a perspective field of view.
#[derive(Clone, Debug, PartialEq)]
pub struct Aim {
pub at: Orientation,
/// `None` keeps the camera's target (the app's `ui.camera`); a picture of its own aims at
/// [`Target::MODEL`].
pub target: Option<Target>,
/// Without a distance: the target's bodies fill the frame from this direction. Otherwise the
/// distance keeps the whole model in the frame from every direction, so a sweep keeps its
/// size.
pub fit: bool,
/// Vertical field of view in degrees; 0 is orthographic.
pub fov: Option<f64>,
}
impl Aim {
/// Largest field of view, in degrees.
pub const MAX_FOV: f64 = 150.0;
/// Most bodies a target names.
pub const MAX_BODIES: usize = 1000;
/// Looking from `at`, with the target left as it is.
pub fn new(at: Orientation) -> Self {
Aim { at, target: None, fit: false, fov: None }
}
/// The JSON form [`camera`] reads.
pub fn json(&self) -> Value {
let mut v = serde_json::json!({"yaw": self.at.yaw, "pitch": self.at.pitch});
if let Some(d) = self.at.distance {
v["distance"] = d.into();
}
if let Some(t) = &self.target {
v["target"] = t.json();
}
if self.fit {
v["fit"] = true.into();
}
if let Some(f) = self.fov {
v["fov"] = f.into();
}
v
}
}
/// A camera from JSON: [`orientation`]'s `{yaw, pitch, distance?}` and `target`: `[x, y, z]` or
/// `{"center_of": "mass" | "bbox", "bodies"?: [names]}`; `fit` (the target's bodies fill the
/// frame; not with a distance) and `fov` (vertical, degrees, 0 orthographic). Hostile input is
/// refused or clamped.
pub fn camera(p: &Value) -> Result<Aim> {
let bad = |m: &str| EngineError::Params(format!("camera: {m}"));
let o = p.as_object().ok_or_else(|| bad("an object {yaw, pitch, distance?, target?, fit?, fov?}"))?;
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "yaw" | "pitch" | "distance" | "target" | "fit" | "fov")) {
return Err(bad(&format!("unknown field `{}` (yaw, pitch, distance, target, fit, fov)", k.chars().take(40).collect::<String>())));
}
let given = |k: &str| o.get(k).filter(|v| !v.is_null());
let mut core = serde_json::Map::new();
for k in ["yaw", "pitch", "distance"] {
if let Some(v) = given(k) {
core.insert(k.into(), v.clone());
}
}
let at = orientation(&Value::Object(core))?;
let target = given("target").map(target).transpose()?;
let fit = match given("fit") {
None => false,
Some(v) => v.as_bool().ok_or_else(|| bad("`fit` must be true or false"))?,
};
if fit && at.distance.is_some() {
return Err(bad("give `distance` or `fit`, not both"));
}
let fov = match given("fov") {
None => None,
Some(v) => Some(
v.as_f64()
.filter(|f| f.is_finite() && (0.0..=Aim::MAX_FOV).contains(f))
.ok_or_else(|| bad(&format!("`fov` must be a number of degrees from 0 (orthographic) to {}", Aim::MAX_FOV)))?,
),
};
Ok(Aim { at, target, fit, fov })
}
fn target(v: &Value) -> Result<Target> {
let bad = |m: &str| EngineError::Params(format!("camera: target {m}"));
if let Some(a) = v.as_array() {
let c: Vec<f64> = a.iter().filter_map(Value::as_f64).filter(|x| x.is_finite() && x.abs() <= 1e12).collect();
return match c[..] {
[x, y, z] if a.len() == 3 => Ok(Target::Point(Vec3::new(x, y, z))),
_ => Err(bad("must be [x, y, z] (finite numbers)")),
};
}
let o = v.as_object().ok_or_else(|| bad("must be [x, y, z] or {center_of: mass | bbox, bodies?}"))?;
if let Some(k) = o.keys().find(|k| !matches!(k.as_str(), "center_of" | "bodies")) {
return Err(bad(&format!("has an unknown field `{}` (center_of, bodies)", k.chars().take(40).collect::<String>())));
}
let bodies = match o.get("bodies").filter(|v| !v.is_null()) {
None => None,
Some(b) => {
let a = b
.as_array()
.filter(|a| !a.is_empty() && a.len() <= Aim::MAX_BODIES)
.ok_or_else(|| bad(&format!("bodies must be a list of 1 to {} names", Aim::MAX_BODIES)))?;
Some(a.iter().map(|n| n.as_str().map(str::to_string).ok_or_else(|| bad("bodies must be names"))).collect::<Result<Vec<_>>>()?)
}
};
match o.get("center_of").and_then(Value::as_str).unwrap_or("mass") {
"mass" => Ok(Target::Mass(bodies)),
"bbox" => Ok(Target::Bbox(bodies)),
other => Err(bad(&format!("center_of `{}`: mass or bbox", other.chars().take(40).collect::<String>()))),
}
}
/// The centre of mass of the solid a closed triangle surface bounds (the divergence theorem), or
/// the centre of area of an open one; `None` without triangles.
fn centre_of_mass<'a>(tris: impl Iterator<Item = &'a [Vec3; 3]>) -> Option<Vec3> {
let (mut volume, mut moment, mut area, mut areal) = (0.0, Vec3::ZERO, 0.0, Vec3::ZERO);
for [a, b, c] in tris {
let v = a.dot(b.cross(*c)) / 6.0;
volume += v;
moment = moment + (*a + *b + *c) * (v / 4.0);
let s = (*b - *a).cross(*c - *a).len() * 0.5;
area += s;
areal = areal + (*a + *b + *c) * (s / 3.0);
}
if !(area > 0.0) {
return None;
}
// A closed surface's volume is well away from 0 compared with its area times its size;
// an open one (a shell) has none worth the name.
let size = area.sqrt();
let c = if volume.abs() > 1e-6 * area * size { moment * (1.0 / volume) } else { areal * (1.0 / area) };
c.is_finite().then_some(c)
}
impl Picture {
/// The point `target` names, and the box around what it frames: the bodies it names, or
/// everything drawn.
fn aim_point(&self, target: &Target) -> Result<(Vec3, Aabb3)> {
let surfaces: Vec<&[Vec3; 3]> = match target.bodies() {
Some(names) => {
let mut picked = Vec::new();
for n in names {
let i = self.bodies.iter().position(|b| b == n).ok_or_else(|| {
EngineError::Params(format!(
"camera: no body `{}` is drawn (drawn: {})",
n.chars().take(80).collect::<String>(),
if self.bodies.is_empty() { "none".into() } else { self.bodies.join(", ") }
))
})?;
picked.push(u32::try_from(i).unwrap_or(u32::MAX));
}
self.drawn.surface_tris.iter().filter(|t| picked.contains(&t.body)).map(|t| &t.p).collect()
}
// What is drawn: the elements' faces over the geometry's.
None if !self.drawn.tris.is_empty() => self.drawn.tris.iter().map(|t| &t.p).collect(),
None => self.drawn.surface_tris.iter().map(|t| &t.p).collect(),
};
let mut b = Aabb3::EMPTY;
for p in surfaces.iter().flat_map(|t| t.iter()) {
b.add(*p);
}
let framed = if target.bodies().is_some() { b } else { self.bounds };
let whole = if b.is_empty() { self.bounds } else { b };
let point = match target {
Target::Point(p) => *p,
Target::Bbox(_) => whole.center(),
Target::Mass(_) => centre_of_mass(surfaces.into_iter()).unwrap_or_else(|| whole.center()),
};
Ok((point, framed))
}
}
/// `cam` aimed as `aim` says with the picture `pic` and a view `aspect` wide per unit of height:
/// turned, its field of view set, then (with a target) moved to look at it from `aim`'s distance,
/// from the distance that fits the target's bodies (`fit`), or from the distance at which the
/// sphere about the target through the farthest corner of everything drawn fits the view.
fn aimed(pic: &Picture, aim: &Aim, mut cam: Camera, aspect: f64) -> Result<Camera> {
cam.orient(&Orientation { distance: None, ..aim.at });
if let Some(f) = aim.fov {
cam.fov = f.to_radians();
}
let Some(target) = &aim.target else {
cam.orient(&aim.at);
return Ok(cam);
};
let (point, framed) = pic.aim_point(target)?;
if !point.is_finite() {
return Ok(cam);
}
cam.target = point;
if aim.at.distance.is_some() {
cam.orient(&aim.at);
} else if aim.fit {
cam.fit_about(&framed, aspect);
} else if !pic.bounds.is_empty() {
let aspect = if aspect.is_finite() && aspect > 1e-6 { aspect } else { 1.0 };
let radius = pic.bounds.corners().iter().map(|c| c.dist(point)).fold(1e-3, f64::max) / (1.0 - 2.0 * fieldcraft_render::FIT_MARGIN);
// The half height the sphere needs, then the distance that shows it.
let half = radius / aspect.min(1.0);
let d = if cam.fov > 0.0 {
let half_angle = ((cam.fov * 0.5).tan() * aspect.min(1.0)).atan();
radius / half_angle.sin().max(1e-6)
} else {
half / Camera { distance: 1.0, ..cam }.half_height()
};
if d.is_finite() {
cam.distance = d.clamp(Orientation::DISTANCE.0, Orientation::DISTANCE.1);
}
}
Ok(cam)
}
/// The app's camera `cam` aimed as `aim` says at the session `s` drawn in a view `aspect` wide
/// per unit of height (the control channel's `ui.camera`); `cam` as it is but turned when `aim`
/// has no target.
pub fn aim(s: &Session, aim: &Aim, cam: Camera, aspect: f64) -> Result<Camera> {
let pic = picture(s, &Palette::LIGHT, None, cam.back());
aimed(&pic, aim, cam, aspect)
}
/// A picture ready to draw: the scene (with its background and clipping radius), the setup's
/// symbols as a layer over it, and the camera.
pub struct Framed {
pub scene: Scene,
pub symbols: Scene,
pub cam: Camera,
pub shown: Shown,
}
/// The session seen from `at` in a `w` × `h` pixel picture: 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 sized for their default size on screen
/// ([`glyphs::DEFAULT_SIZE`] pixels) for that camera. The CPU rasterizer ([`render_png`]) and
/// the GPU (`fieldcraft-cli snapshot`) draw the same [`Framed`]. A `section` draws the mesh cut
/// ([`Section`]).
pub fn frame(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette, section: Option<Section>) -> Framed {
let mut cam = Camera::default();
cam.orient(at);
let mut pic = picture(s, p, section, cam.back());
let aspect = w as f64 / h.max(1) as f64;
let unit = |cam: &Camera| glyphs::unit_for(cam, h as f64, glyphs::DEFAULT_SIZE, 1.0).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) };
Framed { scene: pic.scene, symbols, cam, shown: pic.shown }
}
/// The session seen through `aim` ([`camera`]) in a `w` × `h` pixel picture, aimed at
/// [`Target::MODEL`] unless it names a target; the symbols at their default size on screen and
/// a `section` as [`frame`] draws them. An error names a body the target asks for that is not
/// drawn.
pub fn frame_at(s: &Session, aim: &Aim, w: usize, h: usize, p: &Palette, section: Option<Section>) -> Result<Framed> {
let aim = Aim { target: Some(aim.target.clone().unwrap_or(Target::MODEL)), ..aim.clone() };
let mut cam = Camera::default();
cam.orient(&aim.at);
let mut pic = picture(s, p, section, cam.back());
let cam = aimed(&pic, &aim, Camera { fov: 0.0, ..cam }, w as f64 / h.max(1) as f64)?;
let mut symbols = Scene::default();
let mut bounds = pic.bounds;
bounds.union(pic.symbols(glyphs::unit_for(&cam, h as f64, glyphs::DEFAULT_SIZE, 1.0).unwrap_or(f64::NAN), &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) };
Ok(Framed { scene: pic.scene, symbols, cam, shown: pic.shown })
}
/// A PNG of the session seen from `at`, `w` × `h` pixels ([`frame`]), drawn on the CPU.
pub fn render_png(s: &Session, at: &Orientation, w: usize, h: usize, p: &Palette) -> Result<(Vec<u8>, Shown)> {
let f = frame(s, at, w, h, p, None);
let png = fieldcraft_render::render_layers_png(&f.scene, &[&f.symbols], &f.cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
Ok((png, f.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));
}
/// A session of hex8 boxes `[min, max]`, one element each.
fn boxes(b: &[([f64; 3], [f64; 3])]) -> Session {
let mut s = Session::default();
for (e, (lo, hi)) in b.iter().enumerate() {
let e = e as u32;
let corner = |i: usize| {
let pick = |k: usize, bit: bool| if bit { hi[k] } else { lo[k] };
let (x, y, z) = ([false, true, true, false][i % 4], [false, false, true, true][i % 4], i >= 4);
[pick(0, x), pick(1, y), pick(2, z)]
};
for i in 0..8 {
s.execute("mesh.node.add", &json!({"label": e * 8 + i as u32 + 1, "xyz": corner(i)})).unwrap();
}
s.execute("mesh.element.add", &json!({"label": e + 1, "kind": "hex8", "nodes": (1..=8).map(|i| e * 8 + i).collect::<Vec<_>>()})).unwrap();
}
s
}
/// RGBA pixels of a PNG.
fn pixels(png: &[u8]) -> Vec<u8> {
let mut r = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
let mut buf = vec![0; r.output_buffer_size().unwrap()];
let info = r.next_frame(&mut buf).unwrap();
buf.truncate(info.buffer_size());
buf
}
/// The centroid of the pixels of `png` that differ from the empty picture `empty`, and how
/// many there are.
fn drawn_centroid(png: &[u8], empty: &[u8], w: usize) -> (f64, f64, usize) {
let (a, b) = (pixels(png), pixels(empty));
let (mut sx, mut sy, mut n) = (0.0, 0.0, 0usize);
for (i, (p, q)) in a.chunks(4).zip(b.chunks(4)).enumerate() {
if p.iter().zip(q).take(3).any(|(x, y)| x.abs_diff(*y) > 8) {
sx += (i % w) as f64 + 0.5;
sy += (i / w) as f64 + 0.5;
n += 1;
}
}
(sx / n.max(1) as f64, sy / n.max(1) as f64, n)
}
fn shot(s: &Session, aim: &Aim, w: usize, h: usize) -> Vec<u8> {
let f = frame_at(s, aim, w, h, &Palette::LIGHT, None).unwrap();
fieldcraft_render::render_layers_png(&f.scene, &[&f.symbols], &f.cam, w, h).unwrap()
}
/// A yaw sweep of a box far from the origin orbits it: the box stays at the picture's
/// centre and keeps its size, from below and above, orthographic and in perspective (#467).
#[test]
fn a_yaw_sweep_orbits_the_model() {
let s = boxes(&[([200.0, 40.0, 30.0], [260.0, 70.0, 50.0])]);
let (w, h) = (240, 160);
let empty = shot(&Session::default(), &camera(&json!({"yaw": 0, "pitch": 0})).unwrap(), w, h);
for extra in [json!({}), json!({"fov": 40}), json!({"fit": true})] {
for pitch in [25.0, -30.0] {
let mut sizes = Vec::new();
for yaw in (0..360).step_by(30) {
let mut p = json!({"yaw": yaw, "pitch": pitch});
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
let (x, y, n) = drawn_centroid(&shot(&s, &camera(&p).unwrap(), w, h), &empty, w);
assert!(n > 500, "{p}: {n} pixels");
// In perspective the near end of the box is drawn larger than the far end, which
// moves its silhouette's centroid off the centre of mass by a few percent of
// the width; the centre of mass itself projects to the centre (checked below).
let tolerance = if extra.get("fov").is_some() { 0.05 * w as f64 } else { 2.0 };
let off = (x - w as f64 / 2.0).abs().max((y - h as f64 / 2.0).abs());
assert!(off < tolerance, "{p}: centroid ({x:.1}, {y:.1})");
sizes.push(n);
let cam = frame_at(&s, &camera(&p).unwrap(), w, h, &Palette::LIGHT, None).unwrap().cam;
let (cx, cy, _) = cam.to_screen(Vec3::new(230.0, 55.0, 40.0), w as f64, h as f64, 100.0).unwrap();
assert!((cx - w as f64 / 2.0).abs() < 1e-6 && (cy - h as f64 / 2.0).abs() < 1e-6, "{p}");
}
// Without `fit` the distance is the same from every side; with it the box fills
// the frame, so it is larger.
if extra.get("fit").is_none() {
let (lo, hi) = (sizes.iter().min().unwrap(), sizes.iter().max().unwrap());
assert!(*hi < lo * 3, "{extra} {pitch}: {sizes:?}");
}
}
}
}
/// The target is the centre of mass by default, the bounding box's centre or a point when
/// asked; bodies name what it frames, and a body not drawn is an error.
#[test]
fn camera_targets() {
// An L: a long box and a short one on top of its end.
let s = boxes(&[([0.0, 0.0, 0.0], [90.0, 10.0, 10.0]), ([0.0, 0.0, 10.0], [10.0, 10.0, 40.0])]);
let pic = picture(&s, &Palette::LIGHT, None, Vec3::Z);
let (mass, _) = pic.aim_point(&Target::MODEL).unwrap();
// Volumes 9000 and 3000: x = (45·9000 + 5·3000)/12000 = 35, z = (5·9000 + 25·3000)/12000 = 10.
assert!(mass.dist(Vec3::new(35.0, 5.0, 10.0)) < 1e-9, "{mass:?}");
let (bbox, _) = pic.aim_point(&Target::Bbox(None)).unwrap();
assert!(bbox.dist(Vec3::new(45.0, 5.0, 20.0)) < 1e-9, "{bbox:?}");
let (point, _) = pic.aim_point(&Target::Point(Vec3::new(1.0, 2.0, 3.0))).unwrap();
assert_eq!(point, Vec3::new(1.0, 2.0, 3.0));
// An open surface (one face of a box) has its centre of area.
let face = [[Vec3::ZERO, Vec3::X, Vec3::new(1.0, 1.0, 0.0)], [Vec3::ZERO, Vec3::new(1.0, 1.0, 0.0), Vec3::Y]];
assert!(centre_of_mass(face.iter()).unwrap().dist(Vec3::new(0.5, 0.5, 0.0)) < 1e-12);
assert_eq!(centre_of_mass([].iter()), None);
let err = frame_at(
&s,
&camera(&json!({"yaw": 0, "pitch": 0, "target": {"center_of": "mass", "bodies": ["Gear"]}})).unwrap(),
64,
48,
&Palette::LIGHT,
None,
);
assert!(err.is_err_and(|e| e.to_string().contains("no body `Gear`")));
// The app's camera without a target keeps its own and only turns.
let cam = Camera { target: Vec3::new(7.0, 8.0, 9.0), distance: 321.0, ..Camera::default() };
let turned = aim(&s, &camera(&json!({"yaw": 90, "pitch": 10})).unwrap(), cam, 1.5).unwrap();
assert_eq!((turned.target, turned.distance), (cam.target, cam.distance));
let aimed = aim(&s, &camera(&json!({"yaw": 90, "pitch": 10, "target": {"center_of": "mass"}, "fit": true})).unwrap(), cam, 1.5).unwrap();
assert!(aimed.target.dist(mass) < 1e-9 && aimed.distance != cam.distance);
}
#[test]
fn camera_json_is_checked() {
let a = camera(&json!({"yaw": 10, "pitch": 20, "target": [1, 2, 3], "fov": 35})).unwrap();
assert_eq!((a.target, a.fov, a.fit), (Some(Target::Point(Vec3::new(1.0, 2.0, 3.0))), Some(35.0), false));
let a = camera(&json!({"yaw": 0, "pitch": 0, "target": {"center_of": "bbox", "bodies": ["A"]}, "fit": true})).unwrap();
assert_eq!(camera(&a.json()).unwrap(), a);
assert_eq!(a.target, Some(Target::Bbox(Some(vec!["A".into()]))));
assert_eq!(camera(&json!({"yaw": 0, "pitch": 0, "target": {}})).unwrap().target, Some(Target::MODEL));
for bad in [
json!({"yaw": 0, "pitch": 0, "target": [1, 2]}),
json!({"yaw": 0, "pitch": 0, "target": [1, 2, "x"]}),
json!({"yaw": 0, "pitch": 0, "target": [1, 2, 1e300]}),
json!({"yaw": 0, "pitch": 0, "target": {"center_of": "gravity"}}),
json!({"yaw": 0, "pitch": 0, "target": {"center_of": "mass", "bodies": []}}),
json!({"yaw": 0, "pitch": 0, "target": {"center_of": "mass", "bodies": [3]}}),
json!({"yaw": 0, "pitch": 0, "target": {"middle": 1}}),
json!({"yaw": 0, "pitch": 0, "target": "mass"}),
json!({"yaw": 0, "pitch": 0, "fit": true, "distance": 10}),
json!({"yaw": 0, "pitch": 0, "fit": 1}),
json!({"yaw": 0, "pitch": 0, "fov": -1}),
json!({"yaw": 0, "pitch": 0, "fov": 179}),
json!({"yaw": 0, "pitch": 0, "fov": "wide"}),
json!({"yaw": 0, "pitch": 0, "zoom": 2}),
json!({"pitch": 0}),
] {
assert!(camera(&bad).is_err(), "{bad}");
}
}
/// 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);
}
}