Setup symbols: wireframe glyphs per held degree of freedom, a constant size on screen

Boundary condition, load and interaction symbols are wireframes (thin lines,
no shaded cones), so they never hide the mesh or a contour plot:

- a crossed wire triangle per constrained translation, tip on the node,
  coming from outside the part where the set's faces say which side that is;
  a held rotation adds a smaller triangle behind it (the double mark); fixed
  shows all three axes, a symmetry plane only its normal;
- forces as open-headed arrows ending on the node, as long as four support
  units at one place and 2.5 over a set, so loads and supports stay in
  proportion (#78); moments as an axis line and a right-handed curved arrow
  (#79); contact and tie surfaces as small squares on their faces;
- a constant size on screen at any zoom (#80): the symbol unit is the size
  setting (View > Symbol size, `ui.set {symbol_size}`, 14 pt by default)
  times the model length a pixel spans, rounded to a quarter of a doubling;
- spread evenly on a grid a few symbol units across, so their density follows
  the screen, never more than 400 per definition;
- a layer of their own on the desktop (GPU and CPU): zooming rebuilds and
  uploads the symbols only, never the mesh's scene; framing leaves them out;
- colour per kind from the theme's tokens (a new contact colour), and the
  definition selected in the browser drawn in the selection colour, wider;
- depth-tested against the model with the line bias that keeps them on their
  own surface; a load at a reference point or a single node and a reference
  point's cross are drawn over the part (#78).

Headless pictures (MCP screenshot, `ui.render`) frame the camera first and
draw the symbols at the default size for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 23:14:40 -04:00
1 parent 1fcc7acf84
commit 52c12e2ad7
13 files changed
+1221 -436

No files matched your search

+1
View File
@@ -27,4 +27,5 @@ thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
png = { workspace = true }
zip = { workspace = true }
+92 -21
View File
@@ -8,11 +8,11 @@
//! 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.
//! muted over a results plot, 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::{GlyphColors, GlyphShow, Glyphs};
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;
@@ -73,6 +73,8 @@ impl Palette {
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),
},
};
@@ -153,18 +155,39 @@ fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene {
s
}
/// The scene of the session: the model, or the results view's plot, and what it shows.
pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) {
/// 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,
}
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 glyphs = Glyphs::from_model(&s.model);
let mut shown = Shown { failed, sampled: glyphs.notes.clone(), ..Shown::default() };
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)));
let Some((view, result)) = plot else {
let mut bounds = base.bounds;
let mut scene = model_scene(&base, p);
bounds.union(glyphs.draw(|label| base.node_position(label), GlyphShow::ALL, &p.glyphs, &mut scene));
let mut bounds = base.bounds;
bounds.union(fieldcraft_render::glyphs::datums(&s.model, &base.bounds, &p.glyphs, &mut scene));
return (scene, bounds, shown);
return Picture { scene, drawn: base, bounds, shown, glyphs, colors: p.glyphs };
};
shown.step = Some(view.step.clone());
let pick = view.pick();
@@ -206,11 +229,21 @@ pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) {
});
}
}
let symbols = glyphs.draw(|label| drawn.node_position(label), GlyphShow::ALL, &p.glyphs.over_results(p.background.1), &mut scene);
let mut bounds = base.bounds;
bounds.union(drawn.bounds);
bounds.union(symbols);
(scene, bounds, shown)
Picture { scene, drawn, bounds, shown, glyphs, colors: p.glyphs.over_results(p.background.1) }
}
/// 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
@@ -231,16 +264,28 @@ pub fn orientation(p: &Value) -> Result<Orientation> {
}
/// A PNG of the session seen from `at`, `w` × `h` pixels: framing everything drawn, or from
/// `at`'s distance when it has one.
/// `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 scene, bounds, shown) = scene(s, p);
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(&bounds, w as f64 / h.max(1) as f64, fieldcraft_render::FULL_VIEW);
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);
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_png(&scene, &cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?;
Ok((png, shown))
symbols = Scene::default();
let mut bounds = pic.bounds;
bounds.union(pic.symbols(unit(&cam), &mut symbols));
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)]
@@ -259,10 +304,14 @@ mod tests {
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 cones and the push's arrows are drawn in their colours.
// 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.tris.iter().any(|t| t.color == Palette::LIGHT.glyphs.constraint));
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);
@@ -275,8 +324,30 @@ mod tests {
// 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.tris.iter().any(|t| t.color == muted.constraint));
assert!(!scene.tris.iter().any(|t| t.color == Palette::LIGHT.glyphs.constraint));
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]
File diff suppressed because it is too large. Load diff
+11
View File
@@ -67,6 +67,17 @@ pub struct Scene {
pub radius: f64,
}
impl Scene {
/// Add `other`'s primitives (not its background or radius) to this scene.
pub fn append(&mut self, other: Scene) {
self.tris.extend(other.tris);
self.lines.extend(other.lines);
self.points.extend(other.points);
self.labels.extend(other.labels);
self.on_top.extend(other.on_top);
}
}
/// The lit colour of a surface with normal `n` seen along `view_dir` (toward the eye). The GPU
/// shader computes the same.
pub fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] {
+14
View File
@@ -164,6 +164,20 @@ pub enum Def {
}
impl Def {
/// The kind of setup symbols a definition of this kind draws, if any.
pub fn symbols(self) -> Option<fieldcraft_render::glyphs::Definition> {
use fieldcraft_render::glyphs::Definition;
Some(match self {
Def::BoundaryCondition => Definition::BoundaryCondition,
Def::Load => Definition::Load,
Def::ReferencePoint => Definition::ReferencePoint,
Def::Coupling => Definition::Coupling,
Def::Contact => Definition::Contact,
Def::Step => Definition::Step,
Def::Set | Def::Material | Def::Section | Def::FluidDomain => return None,
})
}
/// The reference fields that name one of these.
pub fn collection(self) -> Collection {
match self {
+59 -19
View File
@@ -1,9 +1,11 @@
//! GPU viewport: lit triangles and screen-space wide lines with a depth buffer, drawn in an
//! `egui_wgpu` paint callback. Geometry is uploaded when the scene changes; each frame only the
//! camera uniform changes. Nodes are drawn as zero-length lines, which the line shader turns
//! into squares. The selection highlight is a second, small scene (the overlay) drawn over the
//! first: its faces pass the depth test over the faces they repeat, so a hover change uploads
//! only the overlay.
//! into squares. The setup's symbols are a second scene, uploaded on their own, so zooming
//! (which resizes them) never uploads the mesh again. The selection highlight is a third, small
//! scene (the overlay) drawn over the first: its faces pass the depth test over the faces they
//! repeat, so a hover change uploads only the overlay. Every layer's `on_top` lines are drawn
//! last, with no depth test.
//!
//! A large mesh does not fit one vertex buffer (wgpu's default `max_buffer_size`, which eframe
//! requests, is 256 MiB): geometry is split into batches of at most [`MAX_BATCH_BYTES`] and the
@@ -38,13 +40,15 @@ pub type GpuErrors = Arc<Mutex<Option<String>>>;
pub struct GpuScene {
pub tris: Vec<u8>,
pub lines: Vec<u8>,
/// Lines drawn over everything ([`Scene::on_top`]).
pub top_lines: Vec<u8>,
}
impl GpuScene {
/// The scene's triangles, lines and points; widths and sizes scaled by `pixels_per_point`
/// (the scene gives them in points).
pub fn from_scene(scene: &Scene, pixels_per_point: f32) -> GpuScene {
let mut g = GpuScene { tris: Vec::with_capacity(scene.tris.len() * 3 * TRI_SIZE), lines: Vec::new() };
let mut g = GpuScene { tris: Vec::with_capacity(scene.tris.len() * 3 * TRI_SIZE), lines: Vec::new(), top_lines: Vec::new() };
for t in &scene.tris {
for p in t.p {
g.vertex(p.to_f32(), t.normal.to_f32(), t.color);
@@ -56,6 +60,9 @@ impl GpuScene {
for p in &scene.points {
g.line(p.p.to_f32(), p.p.to_f32(), p.color, p.size as f32 * pixels_per_point);
}
for l in &scene.on_top {
push_line(&mut g.top_lines, l.a.to_f32(), l.b.to_f32(), l.color, l.width as f32 * pixels_per_point);
}
g
}
fn vertex(&mut self, p: [f32; 3], n: [f32; 3], c: Rgb) {
@@ -65,14 +72,19 @@ impl GpuScene {
self.tris.extend_from_slice(&[c.0, c.1, c.2, 255]);
}
fn line(&mut self, a: [f32; 3], b: [f32; 3], c: Rgb, width: f32) {
for v in a.iter().chain(&b) {
self.lines.extend_from_slice(&v.to_le_bytes());
}
self.lines.extend_from_slice(&[c.0, c.1, c.2, 255]);
self.lines.extend_from_slice(&width.to_le_bytes());
push_line(&mut self.lines, a, b, c, width);
}
}
/// A line instance into `buf`.
fn push_line(buf: &mut Vec<u8>, a: [f32; 3], b: [f32; 3], c: Rgb, width: f32) {
for v in a.iter().chain(&b) {
buf.extend_from_slice(&v.to_le_bytes());
}
buf.extend_from_slice(&[c.0, c.1, c.2, 255]);
buf.extend_from_slice(&width.to_le_bytes());
}
/// Where the UI hands a new scene to the renderer (taken on the next frame's `prepare`).
pub type SceneSlot = Arc<Mutex<Option<GpuScene>>>;
@@ -81,6 +93,9 @@ pub struct ViewportCallback {
/// Changes whenever the scene does; the slot holds the scene for a new key.
pub key: u64,
pub slot: SceneSlot,
/// The same for the setup's symbols.
pub symbols_key: u64,
pub symbols_slot: SceneSlot,
/// The same for the overlay (the selection highlight).
pub overlay_key: u64,
pub overlay_slot: SceneSlot,
@@ -105,6 +120,7 @@ struct Layer {
key: Option<u64>,
tris: Vec<Batch>,
lines: Vec<Batch>,
top_lines: Vec<Batch>,
}
impl Layer {
@@ -116,9 +132,11 @@ impl Layer {
// The old buffers go first, so the new ones don't have to fit beside them.
self.tris.clear();
self.lines.clear();
self.top_lines.clear();
let max = device.limits().max_buffer_size;
self.tris = upload(device, queue, "fc_tris", &sc.tris, TRI_SIZE, 3, max);
self.lines = upload(device, queue, "fc_lines", &sc.lines, LINE_SIZE, 1, max);
self.top_lines = upload(device, queue, "fc_top_lines", &sc.top_lines, LINE_SIZE, 1, max);
self.key = Some(key);
}
}
@@ -132,14 +150,14 @@ impl Layer {
}
}
}
}
fn paint_lines(&self, pass: &mut wgpu::RenderPass<'static>, line: &wgpu::RenderPipeline) {
if !self.lines.is_empty() {
pass.set_pipeline(line);
for b in &self.lines {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..6, 0..b.count);
}
fn paint_line_batches(batches: &[Batch], pass: &mut wgpu::RenderPass<'static>, line: &wgpu::RenderPipeline) {
if !batches.is_empty() {
pass.set_pipeline(line);
for b in batches {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..6, 0..b.count);
}
}
}
@@ -149,10 +167,13 @@ struct Resources {
/// The overlay's faces: pulled toward the eye and depth-tested without writing depth.
overlay_tri: wgpu::RenderPipeline,
line: wgpu::RenderPipeline,
/// Lines over everything: no depth test.
top_line: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
bind: wgpu::BindGroup,
linear_out: bool,
scene: Layer,
symbols: Layer,
overlay: Layer,
}
@@ -367,10 +388,19 @@ impl Resources {
depth(wgpu::CompareFunction::LessEqual, false),
Some(wgpu::BlendState::ALPHA_BLENDING),
),
top_line: pipeline(
"fc_top_lines",
"vs_line",
"fs_line",
line_layout(),
depth(wgpu::CompareFunction::Always, false),
Some(wgpu::BlendState::ALPHA_BLENDING),
),
uniform,
bind,
linear_out: t.format.is_srgb(),
scene: Layer::default(),
symbols: Layer::default(),
overlay: Layer::default(),
}
}
@@ -440,6 +470,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
) -> Vec<wgpu::CommandBuffer> {
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
res.scene.take(device, queue, self.key, &self.slot);
res.symbols.take(device, queue, self.symbols_key, &self.symbols_slot);
res.overlay.take(device, queue, self.overlay_key, &self.overlay_slot);
queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, res.linear_out));
Vec::new()
@@ -454,11 +485,16 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
pass.set_viewport(vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32, 0.0, 1.0);
pass.set_bind_group(0, &res.bind, &[]);
// Faces first, the overlay's over the scene's, then every line, so the mesh edges stay on
// highlighted faces (as the CPU renderer draws them).
// highlighted faces, then the lines over everything (as the CPU renderer draws them).
res.scene.paint_tris(pass, &res.tri);
res.symbols.paint_tris(pass, &res.tri);
res.overlay.paint_tris(pass, &res.overlay_tri);
res.scene.paint_lines(pass, &res.line);
res.overlay.paint_lines(pass, &res.line);
for layer in [&res.scene, &res.symbols, &res.overlay] {
paint_line_batches(&layer.lines, pass, &res.line);
}
for layer in [&res.scene, &res.symbols, &res.overlay] {
paint_line_batches(&layer.top_lines, pass, &res.top_line);
}
}
}
@@ -524,11 +560,13 @@ mod tests {
tris: vec![SceneTri { p: [Vec3::ZERO, Vec3::X, Vec3::Y], normal: Vec3::Z, color: Rgb(1, 2, 3) }],
lines: vec![SceneLine { a: Vec3::ZERO, b: Vec3::Z, color: Rgb(4, 5, 6), width: 1.5 }],
points: vec![ScenePoint { p: Vec3::X, color: Rgb(7, 8, 9), size: 4.0 }],
on_top: vec![SceneLine { a: Vec3::ZERO, b: Vec3::X, color: Rgb(1, 1, 1), width: 2.0 }],
..Default::default()
};
let g = GpuScene::from_scene(&scene, 2.0);
assert_eq!(g.tris.len(), 3 * TRI_SIZE);
assert_eq!(g.lines.len(), 2 * LINE_SIZE);
assert_eq!(g.top_lines.len(), LINE_SIZE);
assert_eq!(&g.tris[24..28], &[1, 2, 3, 255]);
// Widths are in physical pixels.
assert_eq!(f32::from_le_bytes([g.lines[28], g.lines[29], g.lines[30], g.lines[31]]), 3.0);
@@ -536,6 +574,8 @@ mod tests {
let cb = ViewportCallback {
key: 0,
slot: SceneSlot::default(),
symbols_key: 0,
symbols_slot: SceneSlot::default(),
overlay_key: 0,
overlay_slot: SceneSlot::default(),
view_proj: [[0.0; 4]; 4],
+135 -20
View File
@@ -55,7 +55,7 @@ use std::sync::mpsc::{Receiver, Sender};
use fieldcraft_engine::catalogue::{self, Tab, Workspace};
use fieldcraft_engine::{EngineError, Session};
use fieldcraft_render::glyphs::Glyphs;
use fieldcraft_render::glyphs::{self, Definition, Drawn, Glyphs};
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView, Vec3};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -64,6 +64,9 @@ pub use control::ControlRequest;
pub use files::{Filter, Services};
pub use scene::Style;
/// The viewport height symbols are sized for before the viewport is first drawn (points).
const DEFAULT_VIEW_HEIGHT: f64 = 800.0;
/// The version shown in Help and over the control channel.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -82,6 +85,9 @@ pub struct UiState {
pub show_loads: bool,
/// Reference point, coupling and rigid wall symbols.
pub show_interactions: bool,
/// The setup symbols' size on screen, in points (held to
/// [`fieldcraft_render::glyphs::SIZE_RANGE`]).
pub symbol_size: f64,
pub perspective: bool,
pub dark: bool,
/// The browser (left).
@@ -111,6 +117,7 @@ impl Default for UiState {
show_mesh: true,
show_loads: true,
show_interactions: true,
symbol_size: glyphs::DEFAULT_SIZE,
perspective: false,
dark: true,
show_tree: true,
@@ -252,8 +259,12 @@ pub struct Viewport {
/// The selection highlight for the GPU, and the overlay key last handed to it.
pub overlay_slot: gpu::SceneSlot,
pub overlay_uploaded: Option<u64>,
/// CPU fallback: the texture and what it shows (scene and overlay keys, camera, size).
cpu: Option<(egui::TextureHandle, (u64, u64), Camera, [usize; 2])>,
/// The setup's symbols for the GPU, and the symbols key last handed to it.
pub symbols_slot: gpu::SceneSlot,
pub symbols_uploaded: Option<u64>,
/// CPU fallback: the texture and what it shows (scene, symbols and overlay keys, camera,
/// size).
cpu: Option<(egui::TextureHandle, (u64, u64, u64), Camera, [usize; 2])>,
/// CPU fallback: the scene key the renderer last refused.
cpu_failed: Option<u64>,
/// GPU errors reported by the device (see [`gpu::GpuErrors`]), shown in the status bar.
@@ -340,8 +351,11 @@ pub struct ModelApp {
bodies: (Option<Arc<fieldcraft_geometry::Design>>, Vec<(selection::BodyNames, fieldcraft_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, its key and the box around its symbols (they reach beyond the model).
/// The built scene, its key and the box around its datums (they reach beyond the model).
scene: (Option<u64>, Scene, Aabb3),
/// The setup's symbols, a layer of their own drawn at the camera's scale, its key and what
/// it drew.
symbols: (Option<u64>, Scene, Drawn),
/// The theme last applied to the context.
styled: Option<bool>,
/// Input injected by the control channel, fed to egui one pointer event per frame.
@@ -432,6 +446,7 @@ impl ModelApp {
bodies: (None, Vec::new()),
prepared: (None, results::Prepared::default()),
scene: (None, Scene::default(), Aabb3::EMPTY),
symbols: (None, Scene::default(), Drawn::default()),
styled: None,
synthetic: Vec::new(),
synthetic_mods: egui::Modifiers::default(),
@@ -844,9 +859,9 @@ impl ModelApp {
&self.display.1
}
/// Definitions whose symbols are a sample (`Clamp: 400 of 12000 nodes`).
/// Definitions whose symbols are a sample (`Clamp: 400 of 12000 nodes`), as drawn last.
pub fn glyph_notes(&self) -> &[String] {
&self.glyphs.notes
&self.symbols.2.notes
}
/// The key of the current scene: the model revision, every setting the scene depends on and
@@ -898,27 +913,74 @@ impl ModelApp {
Some(p) => results::scene(p, style, show, &t),
None => scene::build(&self.display.1, style, show, &t),
};
// Symbols follow the drawn nodes: the deformed shape of a results plot.
let drawn = self.plot.1.as_ref().map_or(&self.display.1, |p| &p.display);
let mut symbols = scene::symbols(&mut s, &self.glyphs, |n| drawn.node_position(n), show, self.plot.1.is_some(), &t);
let mut datums = Aabb3::EMPTY;
if show.geometry && self.plot.1.is_none() {
symbols.union(scene::datums(&mut s, &self.session.model, &self.display.1.bounds, &t));
datums = scene::datums(&mut s, &self.session.model, &self.display.1.bounds, &t);
scene::seeds(&mut s, &self.session.model, &self.pick.names, &self.display.1, &t);
}
self.scene = (Some(key), s, symbols);
self.scene = (Some(key), s, datums);
}
self.scene.1.radius = scene::radius(&self.drawn_bounds(), &self.cam);
// The clipping radius holds the symbols too (framing leaves them out: their size in the
// model follows the zoom).
let mut bounds = self.drawn_bounds();
bounds.union(self.symbols.2.bounds);
self.scene.1.radius = scene::radius(&bounds, &self.cam);
(key, &self.scene.1)
}
/// What the scene built last draws: the model, a results plot's (deformed) shape and the
/// setup's symbols.
/// The definition selected in the browser, when it has symbols.
fn highlighted(&self) -> Option<(Definition, &str)> {
let (def, name) = self.selected.as_ref()?;
let def = def.symbols()?;
Some((def, name.as_str()))
}
/// The setup's symbols for the viewport as drawn last frame (its height in points).
pub fn symbols(&mut self) -> (u64, &Scene) {
let height = self.viewport.rect.map_or(DEFAULT_VIEW_HEIGHT, |r| f64::from(r.height()));
self.symbols_for(height)
}
/// The setup's symbols at the size setting on screen, for a view `height` points (or
/// pixels) high with the current camera: a layer of their own, rebuilt when the model, what
/// is shown, the plot, the highlighted definition or the size changes, and while zooming
/// only when the symbol unit moves to another quarter of a doubling (the mesh's scene is
/// never rebuilt for it). Their nodes follow the drawn shape (a results plot's deformed one).
pub fn symbols_for(&mut self, height: f64) -> (u64, &Scene) {
self.display();
self.plot();
let unit = self.cam.length_per_pixel(height).and_then(|l| glyphs::symbol_unit(l, self.ui.symbol_size)).and_then(glyphs::quantise_unit);
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.show_loads, self.ui.show_interactions, self.ui.dark, plot, unit.map(|u| u.0), self.highlighted()).hash(&mut h);
let key = h.finish();
if self.symbols.0 != Some(key) {
let t = theme::Tokens::get(self.ui.dark);
let drawn = self.plot.1.as_ref().map_or(&self.display.1, |p| &p.display);
let mut s = Scene::default();
let unit = unit.map_or(f64::NAN, |u| u.1);
let d =
scene::symbols(&mut s, &self.glyphs, |n| drawn.node_position(n), self.ui.show(), self.plot.1.is_some(), unit, self.highlighted(), &t);
self.symbols = (Some(key), s, d);
}
(key, &self.symbols.1)
}
/// What the scene built last draws, as framing sees it: the model, a results plot's
/// (deformed) shape, the datums and the reference points.
fn drawn_bounds(&self) -> Aabb3 {
let mut bounds = self.display.1.bounds;
if let Some(p) = &self.plot.1 {
bounds.union(p.display.bounds);
}
bounds.union(self.scene.2);
// Reference points are places in the model, framed with it (their symbols are not).
for p in self.session.model.reference_points().values() {
let p = Vec3::from(p.xyz);
if p.is_finite() {
bounds.add(p);
}
}
bounds
}
@@ -927,8 +989,9 @@ impl ModelApp {
pub fn render_png(&mut self, w: usize, h: usize) -> Result<Vec<u8>, fieldcraft_render::RenderError> {
let cam = self.cam;
let (_, overlay) = self.overlay();
let (_, scene) = self.scene();
fieldcraft_render::render_layers_png(scene, &[&overlay], &cam, w, h)
self.symbols_for(h as f64);
self.scene();
fieldcraft_render::render_layers_png(&self.scene.1, &[&self.symbols.1, &overlay], &cam, w, h)
}
/// The geometry tags behind the drawn mesh, read again when the model changed.
@@ -1776,24 +1839,76 @@ mod tests {
let mut app = ModelApp::new(Session::default());
app.run_script(&fieldcraft_engine::sample::cantilever()).unwrap();
let t = theme::Tokens::get(app.ui.dark);
let count = |app: &mut ModelApp, c: egui::Color32| app.scene().1.tris.iter().filter(|x| x.color == theme::rgb(c)).count();
assert!(count(&mut app, t.glyph_constraint) > 0, "the clamp's cones");
assert!(app.scene().1.lines.iter().any(|l| l.color == theme::rgb(t.glyph_load)), "the push's arrows");
let count = |app: &mut ModelApp, c: egui::Color32| app.symbols().1.lines.iter().filter(|x| x.color == theme::rgb(c)).count();
assert!(count(&mut app, t.glyph_constraint) > 0, "the clamp's triangles");
assert!(count(&mut app, t.glyph_load) > 0, "the push's arrows");
// Wireframes, in a layer of their own.
assert!(app.symbols().1.tris.is_empty());
assert!(!app.scene().1.lines.iter().any(|l| l.color == theme::rgb(t.glyph_constraint)));
// The symbols reach beyond the model: the clipping radius holds them.
let radius = app.scene().1.radius;
app.ui.show_loads = false;
assert_eq!(count(&mut app, t.glyph_constraint), 0);
assert!(app.scene().1.radius <= radius);
app.ui.show_loads = true;
// Selecting the clamp in the browser highlights its symbols.
app.selected = Some((browser::Def::BoundaryCondition, "Clamp".into()));
assert_eq!(count(&mut app, t.glyph_constraint), 0);
assert!(count(&mut app, t.pick_selected) > 0);
assert!(count(&mut app, t.glyph_load) > 0);
app.selected = None;
// Over a results plot they are muted.
app.run("solve.static", json!({})).unwrap();
app.run("results.deformed", json!({"step": "Load"})).unwrap();
assert_eq!(count(&mut app, t.glyph_constraint), 0);
let muted = t.glyph_colors().over_results(theme::rgb(t.viewport_bottom));
assert!(app.scene().1.tris.iter().any(|x| x.color == muted.constraint));
assert!(app.symbols().1.lines.iter().any(|x| x.color == muted.constraint));
assert!(app.glyph_notes().is_empty());
}
/// Zooming resizes the symbols (a layer of their own) and leaves the mesh's scene alone; the
/// symbols measure the size setting on screen at any zoom.
#[test]
fn zooming_rebuilds_the_symbols_only_and_keeps_their_size_on_screen() {
let mut app = ModelApp::new(Session::default());
app.run_script(&fieldcraft_engine::sample::cantilever()).unwrap();
app.set_view(StandardView::Front);
let h = 600.0;
// The length of the longest constraint line on screen: a triangle's side, about its
// length (a symbol unit) long.
let measure = |app: &mut ModelApp| {
let cam = app.cam;
let lines = app.symbols_for(h).1.lines.clone();
let t = theme::Tokens::get(app.ui.dark);
let at = |p: Vec3| cam.to_screen(p, 800.0, h, 1e6).unwrap();
lines
.iter()
.filter(|l| l.color == theme::rgb(t.glyph_constraint))
.map(|l| {
let (a, b) = (at(l.a), at(l.b));
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
})
.fold(0.0, f64::max)
};
let scene = app.scene_key();
let (symbols, _) = app.symbols_for(h);
let mut sizes = Vec::new();
for distance in [40.0, 150.0, 900.0, 5000.0] {
app.cam.distance = distance;
sizes.push(measure(&mut app));
}
assert_eq!(app.scene_key(), scene, "the camera is not in the scene's key");
assert_ne!(app.symbols_for(h).0, symbols, "zooming resizes the symbols");
let (lo, hi) = sizes.iter().fold((f64::MAX, 0.0f64), |(lo, hi), s| (lo.min(*s), hi.max(*s)));
assert!(lo > 10.0 && hi / lo < 1.2, "{sizes:?}");
// The size setting scales them.
let before = measure(&mut app);
app.ui.symbol_size = 2.0 * glyphs::DEFAULT_SIZE;
let after = measure(&mut app);
assert!((after / before - 2.0).abs() < 0.2, "{before} -> {after}");
assert_eq!(app.scene_key(), scene);
}
#[test]
fn solving_shows_the_result_on_the_results_tab() {
let mut app = ModelApp::new(Session::default());
+19 -7
View File
@@ -4,10 +4,10 @@
//! 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. The analysis setup's symbols ([`symbols`]) go over the model,
//! muted over a results plot.
//! every line lies on the element faces. The analysis setup's symbols ([`symbols`]) are a layer of
//! their own over the model, a constant size on screen, muted over a results plot.
use fieldcraft_render::glyphs::{GlyphShow, Glyphs};
use fieldcraft_render::glyphs::{Definition, Drawn, GlyphShow, GlyphView, Glyphs};
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, SceneLine, ScenePoint, SceneTri, Vec3};
use serde::{Deserialize, Serialize};
@@ -62,12 +62,24 @@ impl Show {
pub const DEFAULT: Show = Show { geometry: true, mesh: true, nodes: false, loads: true, interactions: true };
}
/// Draw the analysis setup's symbols into `s` at the nodes' positions `pos`, muted over a
/// results plot; returns the box around them.
pub fn symbols(s: &mut Scene, glyphs: &Glyphs, pos: impl Fn(u32) -> Option<Vec3>, show: Show, results: bool, t: &Tokens) -> Aabb3 {
/// Draw the analysis setup's symbols into `s` at the nodes' positions `pos` and symbol unit
/// `unit` (model length, [`fieldcraft_render::glyphs::symbol_unit`]), muted over a results plot,
/// the `highlight`ed definition's in the selection colour.
#[allow(clippy::too_many_arguments)]
pub fn symbols(
s: &mut Scene,
glyphs: &Glyphs,
pos: impl Fn(u32) -> Option<Vec3>,
show: Show,
results: bool,
unit: f64,
highlight: Option<(Definition, &str)>,
t: &Tokens,
) -> Drawn {
let colors = t.glyph_colors();
let colors = if results { colors.over_results(rgb(t.viewport_bottom)) } else { colors };
glyphs.draw(pos, GlyphShow { loads: show.loads, interactions: show.interactions }, &colors, s)
let view = GlyphView { show: GlyphShow { loads: show.loads, interactions: show.interactions }, colors: &colors, unit, highlight };
glyphs.draw(pos, &view, s)
}
/// Draw the model's datum planes and axes around `bounds` into `s`; returns the box around them.
+20 -4
View File
@@ -80,6 +80,8 @@ pub struct Tokens {
pub glyph_load: Color32,
/// Reference points and couplings.
pub glyph_interaction: Color32,
/// Contact and tie surface symbols.
pub glyph_contact: Color32,
/// Temperatures, heat fluxes, convection and heat sources.
pub glyph_thermal: Color32,
/// Datum planes and axes.
@@ -160,6 +162,7 @@ impl Tokens {
glyph_constraint: Color32::from_rgb(0x00, 0x8a, 0xa8),
glyph_load: Color32::from_rgb(0xd0, 0x24, 0x24),
glyph_interaction: Color32::from_rgb(0x9a, 0x30, 0xc0),
glyph_contact: Color32::from_rgb(0x6a, 0x8a, 0x00),
glyph_thermal: Color32::from_rgb(0xe8, 0x4c, 0x00),
glyph_datum: Color32::from_rgb(0x1e, 0x8c, 0x6e),
pick_selected: Color32::from_rgb(0xd6, 0x00, 0x7f),
@@ -223,6 +226,7 @@ impl Tokens {
t.glyph_constraint = Color32::from_rgb(0x3c, 0xdc, 0xf0);
t.glyph_load = Color32::from_rgb(0xff, 0x5a, 0x4c);
t.glyph_interaction = Color32::from_rgb(0xd2, 0x82, 0xff);
t.glyph_contact = Color32::from_rgb(0xa0, 0xe6, 0x28);
t.glyph_thermal = Color32::from_rgb(0xff, 0x8c, 0x00);
t.glyph_datum = Color32::from_rgb(0x5a, 0xd8, 0xaa);
t.pick_selected = Color32::from_rgb(0xff, 0x3f, 0xb4);
@@ -235,12 +239,15 @@ impl Tokens {
self.contour.map(rgb)
}
/// The colours of the analysis setup's symbols; their names in the overlay text colour.
/// The colours of the analysis setup's symbols; their names in the overlay text colour, the
/// selected definition's in the selection colour.
pub fn glyph_colors(&self) -> GlyphColors {
GlyphColors {
constraint: rgb(self.glyph_constraint),
load: rgb(self.glyph_load),
interaction: rgb(self.glyph_interaction),
contact: rgb(self.glyph_contact),
highlight: rgb(self.pick_selected),
thermal: rgb(self.glyph_thermal),
label: rgb(self.overlay_text),
datum: rgb(self.glyph_datum),
@@ -368,7 +375,7 @@ mod tests {
};
for dark in [true, false] {
let t = Tokens::get(dark);
let symbols = [t.glyph_constraint, t.glyph_load, t.glyph_interaction, t.glyph_thermal];
let symbols = [t.glyph_constraint, t.glyph_load, t.glyph_interaction, t.glyph_contact, t.glyph_thermal];
for (i, s) in symbols.iter().enumerate() {
assert!((luma(*s) - luma(t.viewport_bottom)).abs() > 400 || far(*s, t.viewport_bottom) > 250, "{s:?} dark={dark}");
for k in ElementKind::ALL {
@@ -392,8 +399,17 @@ mod tests {
let t = Tokens::get(dark);
assert!(far(t.pick_selected, t.pick_hover) > 90, "dark={dark}");
for c in [t.pick_selected, t.pick_hover] {
let others =
[t.geometry, t.node, t.glyph_constraint, t.glyph_load, t.glyph_interaction, t.glyph_thermal, t.viewport_bottom, t.mesh_edge];
let others = [
t.geometry,
t.node,
t.glyph_constraint,
t.glyph_load,
t.glyph_interaction,
t.glyph_contact,
t.glyph_thermal,
t.viewport_bottom,
t.mesh_edge,
];
for o in others.into_iter().chain(ElementKind::ALL.map(|k| t.element(k))) {
assert!(far(c, o) > 100, "{c:?} vs {o:?} dark={dark}");
}
+19 -3
View File
@@ -363,6 +363,14 @@ fn gradient(painter: &egui::Painter, rect: Rect, top: Color32, bottom: Color32)
fn paint_gpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: Rect) {
let ppp = ui.ctx().pixels_per_point();
// The symbols first: the scene's clipping radius holds them.
let symbols_key = app.symbols().0;
if app.viewport.symbols_uploaded != Some(symbols_key)
&& let Ok(mut slot) = app.viewport.symbols_slot.lock()
{
*slot = Some(GpuScene::from_scene(&app.symbols.1, ppp));
app.viewport.symbols_uploaded = Some(symbols_key);
}
let key = app.scene().0;
let radius = app.scene.1.radius;
if app.viewport.uploaded != Some(key) {
@@ -385,6 +393,8 @@ fn paint_gpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: R
let cb = ViewportCallback {
key,
slot: app.viewport.slot.clone(),
symbols_key,
symbols_slot: app.viewport.symbols_slot.clone(),
overlay_key,
overlay_slot: app.viewport.overlay_slot.clone(),
view_proj: cam.view_proj(aspect, radius).to_f32(),
@@ -405,11 +415,12 @@ fn paint_cpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: R
let size = [w, h];
let cam = app.cam;
let (overlay_key, overlay) = app.overlay();
let key = (app.scene().0, overlay_key);
let symbols_key = app.symbols().0;
let key = (app.scene().0, symbols_key, overlay_key);
let fresh = app.viewport.cpu.as_ref().is_some_and(|(_, k, c, s)| *k == key && *c == cam && *s == size);
// A scene the renderer refused is reported once and not retried until it changes.
if !fresh && app.viewport.cpu_failed != Some(key.0) {
let c = match fieldcraft_render::render_layers(&app.scene.1, &[&overlay], &cam, w, h) {
let c = match fieldcraft_render::render_layers(&app.scene.1, &[&app.symbols.1, &overlay], &cam, w, h) {
Ok(c) => c,
Err(e) => {
app.viewport.cpu = None;
@@ -450,7 +461,9 @@ fn cpu_size(w: usize, h: usize, max_side: usize) -> (usize, usize) {
/// The scene's labels (reference point names) beside their points, with the interface's font.
/// The CPU renderer draws them into its image.
fn labels(app: &mut ModelApp, painter: &egui::Painter, t: &Tokens) {
let labels: Vec<(Vec3, String)> = app.scene().1.labels.iter().map(|l| (l.p, l.text.clone())).collect();
app.symbols();
app.scene();
let labels: Vec<(Vec3, String)> = app.scene.1.labels.iter().chain(&app.symbols.1.labels).map(|l| (l.p, l.text.clone())).collect();
let offset = fieldcraft_render::LABEL_OFFSET as f32;
for (p, text) in labels {
if let Some(at) = to_screen(app, p) {
@@ -627,6 +640,9 @@ fn nav_bar(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
ui.checkbox(&mut app.ui.show_nodes, "Nodes");
ui.checkbox(&mut app.ui.show_loads, "Loads & BCs");
ui.checkbox(&mut app.ui.show_interactions, "Interactions");
let (lo, hi) = fieldcraft_render::glyphs::SIZE_RANGE;
ui.add(egui::Slider::new(&mut app.ui.symbol_size, lo..=hi).step_by(1.0).suffix(" pt").text("Symbol size"))
.on_hover_text("The size of boundary condition, load and interaction symbols on screen, at any zoom");
ui.separator();
ui.checkbox(&mut app.ui.show_tree, "Browser");
ui.checkbox(&mut app.ui.show_timeline, "Timeline");