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

Generated
+1
View File
@@ -1285,6 +1285,7 @@ dependencies = [
"fieldcraft-render",
"fieldcraft-solver",
"log",
"png",
"serde",
"serde_json",
"thiserror 2.0.21",
+1 -1
View File
@@ -52,7 +52,7 @@ pressure) is a degree of freedom of the existing DOF framework, not a separate s
| M23 Wave propagation and ultrasound | Elastic, acoustic and coupled waves from the explicit solver's 1D bar today to guided and ultrasonic waves, by two methods. Finite differences first, for fast 2D (then 3D) ultrasound on regular grids: staggered-grid velocity–stress FDTD (Virieux) and the elastodynamic finite integration technique for solids, the rotated staggered grid for anisotropic media, a k-space pseudospectral scheme for weakly heterogeneous tissue and water, all on grids voxelised from the face-tagged geometry by the LBM's exact voxeliser, with heterogeneous, layered and fluid–solid media, CPML boundaries, threads and the GPU backend; staircased curved boundaries scatter spuriously, so the FD grid is checked against the finite elements on curved parts. Finite elements for curved geometry and coupling with structures: spectral elements (high-order Gauss–Lobatto–Legendre hexes with diagonal mass) on the hex meshes, and a mesh check in elements per wavelength with the dispersion error it implies; absorbing boundaries (convolutional PML, absorbing layers with increasing damping) and infinite media; tone-burst sources (Hann-windowed cycles), point, line and surface excitation, and piezoelectric transducers (M19); anisotropic media (composites, crystals) and viscoelastic, power-law-in-frequency attenuation; fluid–solid interfaces and fluid-loaded plates; ultrasound in water and tissue, nonlinear acoustics (Westervelt, KZK) for focused ultrasound. Guided waves: dispersion curves by semi-analytical finite elements (SAFE) for plates, pipes, rails and layered and anisotropic laminates — Lamb, Rayleigh, Love and SH modes — with phase and group velocities and mode shapes; mode-selective excitation. Output: A-scans and B-scans at sensors, wavefield snapshots, 2D FFT (frequency–wavenumber) for mode identification, and time–frequency diagrams of any signal — short-time Fourier spectrograms, continuous wavelet transforms (Morlet), reassigned spectrograms and smoothed pseudo Wigner–Ville — with the SAFE or Rayleigh–Lamb group-velocity curves overlaid as arrival times, and group velocities extracted from the ridges. GPU paths (M12's backend) for long runs. Verified by Lamb's problem (point load on a half-space), Rayleigh-wave speed, Rayleigh–Lamb dispersion, Love waves in a layer over a half-space, reflection and transmission at interfaces, and published guided-wave measurements | planned |
| Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 26 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 9 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 9 pending cases with citations (shells, thermal, static plasticity, Taylor bar, CFD); `mesh.block` mapped blocks and 2D patches build their meshes | started |
| Continuous: performance | CPU first, deterministic: results never depend on the thread count (fixed partitions, sums in a fixed order; checked by hashing results on 1, 2 and 7 threads). Supernodal left-looking Cholesky (11–15× faster than the column solver on one thread), parallel element matrices, stresses and Lanczos products, explicit element updates and LBM cell blocks on rayon threads (done — `fieldcraft-parallel`, `threads` on the solve commands, `FIELDCRAFT_THREADS`); parallel forward/back substitution and explicit tet4 assembly; GPU paths later, within a stated tolerance of the CPU | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done), command schema as the public API | started |
| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve, and an examples gallery of ready-to-solve samples, each checked by a test (docs/examples.md): done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done; caps labels in Inter SemiBold; the README's analysis video recorded through MCP by `tools/demo-video`: done; support, load, reference-point and coupling symbols in the viewport and headless renders, with their browser toggles: done; symbols as wireframes, one mark per constrained degree of freedom, a constant size on screen with a size setting, highlighted with their selected definition, concentrated loads drawn over the part: done; selection by picking in the viewport — geometry faces, edges, vertices and bodies, mesh nodes, element faces and elements, box selection, hover, a Target row in command forms, `ui.select`/`ui.selection` and MCP `select`: done; a camera parameter for screenshots, a ribbon that follows the command being run: in progress; the browser build — the same app in a web page with browser file pickers, downloads, drops and autosave to browser storage, deployed to GitHub Pages from each release, docs/web.md: done), command schema as the public API | started |
## Next
+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");
+2 -2
View File
@@ -42,8 +42,8 @@ commands (files, undo, `model.info`) leave the ribbon where it is.
| Method | Params | Notes |
|---|---|---|
| `ui.inspect` | — | view settings (`ui`), camera, viewport rect (points), renderer (`gpu`/`cpu`) and adapter, open command form, status bar, node and element counts, the results display (`results`: what the `results.*` commands reply: view, `stale`, contour range, modes), the probed node (`probe`), `solve` (the title bar's Solve: `enabled`, and `unavailable` as `ui.toolbar`'s entries give it, listing everything `solve.run` misses), `busy` (the background job: a file opening, a solve, a mesher run from its form by a click on OK), `prompt` (`{"unsaved": path}`, `{"design": path}` (a design of another part opened over a meshed or defined model), `"recover"`, `"samples"`), `recent` (recent files), `selected` (the browser's selected definition), `browser` (each top-level browser node's setup state: `[{node, state, tip, add}]`, `state` `done`, `next`, `blocked` (⚠: its command cannot run yet; `tip` says what it needs and where), `todo`, `optional` or null for nodes that are no setup stage, `add` its ghost rows `{stage, label, command, status}`; a ghost row's region is `layout`'s `browser/add/<stage>`), `nav` (the left-drag mode), `layout` (each region of the window drawn last frame as `[x, y, w, h]` in points: `app_bar`, `ribbon`, `workspace_switcher`, `document_tab`, `app_bar:<action>`, `browser`, `viewport`, `nav_bar`, `axis_view`, `selection_bar`, `legend` (while results are shown), `context`, `command_line`, `timeline`, `toolbar_dropdown`, `palette`), version |
| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, rigid walls), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `nav_preset` (the mouse navigation: `fieldcraft`, `fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`; an unknown id acts as `fieldcraft`) and `reverse_zoom` (both remembered per user), `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `<tab>/<panel>` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings |
| `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | every view frames the model as drawn (the deformed shape and the setup's symbols included) in the part of the viewport the overlays (`layout`'s `legend`, `selection_bar`, `axis_view`, `nav_bar`) leave free; `fit` keeps the direction of view. Opening another model, sample or design frames it in the home view (the same design opened again after an edit keeps the camera), and results that come up with part of the model out of sight are framed. Returns the camera |
| `ui.set` | any view setting: `style` (`shaded_edges`, `shaded`, `wireframe`), `show_nodes`, `show_geometry`, `show_mesh`, `show_loads` (boundary condition and load symbols), `show_interactions` (reference points, couplings, contact and tie surfaces, rigid walls), `symbol_size` (the setup symbols' size on screen in points, 6–48, at any zoom), `perspective`, `dark`, `show_tree` (the browser), `show_timeline`, `nav_preset` (the mouse navigation: `fieldcraft`, `fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`; an unknown id acts as `fieldcraft`) and `reverse_zoom` (both remembered per user), `workspace` (`prepare`, `model`, `analysis`, `results`), `tab` (the toolbar tab: `geometry`, `mesh`, `properties`, `assembly`, `steps`, `loads`, `interactions`, `solve`, `results`, `tools`; a tab of another workspace brings that workspace), `palette` (the command search open), `menu` (the open menu: `file`, `help`, `workspace`, `view`, `more`, or a panel's drop-down as `<tab>/<panel>` such as `mesh/Tet`; `null` closes it) | an unknown setting or value is an error; returns the settings |
| `ui.view` | `view`: `front`, `back`, `left`, `right`, `top`, `bottom`, `iso`, `home` (iso and fit), `fit` | every view frames the model as drawn (the deformed shape included; the setup's symbols keep their size on screen and are not framed) in the part of the viewport the overlays (`layout`'s `legend`, `selection_bar`, `axis_view`, `nav_bar`) leave free; `fit` keeps the direction of view. Opening another model, sample or design frames it in the home view (the same design opened again after an edit keeps the camera), and results that come up with part of the model out of sight are framed. Returns the camera |
| `ui.camera` | `yaw`, `pitch` (degrees), `distance?` (model units) | turns the camera to any orientation about its target: `yaw` about Z (0 looks along +Y, the front view; −45 is the iso view's, −90 the right view's), `pitch` the elevation above the XY plane; `distance` moves the eye (the zoom of an orthographic view). Angles must be finite and the distance above 0: the yaw is wrapped, the pitch clamped to ±90 and the distance to 0.001–10⁹; anything else is an error. A sequence of calls orbits smoothly. Returns the camera (`ui.inspect`'s, angles in radians) |
| `ui.toolbar` | — | the ribbon: `workspace`, `workspaces`, `tab`, `tabs` (the workspace's), `menu` (the open menu), `panels` (each panel label's `menu` key and `rect`), and every catalogue entry (docs/features.md) with `id`, `label`, `tab`, `panel`, `command` (null while coming soon), `more` (its related commands), `coming_soon` (the milestone, or null), `enabled` (false while coming soon or while its command misses what it needs), `unavailable` (null, or what its command misses: `reason`, the tooltip's lines such as "Needs a material — Properties › Material", and `missing`, each with `requirement`, `what`, `provider` (the command that provides it) and `place` (its toolbar entry)), `fit` — `full` (a button), `collapsed` (its panel is one button), `more` (its panel is in the » menu) or `menu` (in its panel's drop-down) — and `rect` (screen points, last frame, on the shown tab; for `menu` entries the row while the drop-down is open) |
| `ui.start` | `command`: a command or toolbar entry id | like a click on the toolbar, a drop-down row or a search result: a command without parameters runs, one with parameters opens its form in the context panel; an entry that is coming soon is an error naming its milestone. Never opens a native file picker: file entries (open, save as, write deck) open their command's form |