forked from 1b-agent/fieldcraft-upstream
ui: the viewport draws the analysis setup's symbols, with toggles
The setup's symbols (fieldcraft_render::glyphs) are built with the display data and drawn over the model in every style, at the drawn nodes (a results plot's deformed shape), muted over results; the clipping radius holds them. Theme tokens give their colours in light and dark. The browser's Loads & BCs and Interactions rows get eye toggles (ui.set show_loads, show_interactions), also in the view menu. The GPU viewport draws reference point names with the interface's font; the bottom left says which definitions are sampled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
68a9553f53
commit
5665cc3d8c
8 files changed
+183
-21
No files matched your search
@@ -1,7 +1,8 @@
|
||||
//! The browser (left): the model as a tree. Geometry and Mesh with eye toggles, then the model's
|
||||
//! definitions (sets, materials, sections, steps, loads and boundary conditions, interactions)
|
||||
//! with counts, and the solved steps' results. Clicking a definition selects it (its properties
|
||||
//! show on the right), a double click opens its edit form, and clicking a result step shows it.
|
||||
//! with counts (Loads & BCs and Interactions with eye toggles for their viewport symbols), and
|
||||
//! the solved steps' results. Clicking a definition selects it (its properties show on the
|
||||
//! right), a double click opens its edit form, and clicking a result step shows it.
|
||||
//!
|
||||
//! Row drawing follows SolidCraft's `crates/ui-egui/src/browser.rs` (MIT OR Apache-2.0), see
|
||||
//! ATTRIBUTION.md.
|
||||
@@ -305,6 +306,15 @@ pub fn browser(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewport setting a definition group's eye shows and hides: the setup's symbols.
|
||||
fn eye_flag<'u>(state: &'u mut crate::UiState, group: &str) -> Option<&'u mut bool> {
|
||||
match group {
|
||||
"loads" => Some(&mut state.show_loads),
|
||||
"interactions" => Some(&mut state.show_interactions),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
|
||||
let t = Tokens::get(app.ui.dark);
|
||||
let open = |app: &ModelApp, key: &str| app.tree_open.contains(key);
|
||||
@@ -438,6 +448,7 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
|
||||
for (key, label, kinds) in groups {
|
||||
let items: Vec<(Def, String)> = kinds.iter().flat_map(|k| k.names(&app.session.model).into_iter().map(move |n| (*k, n))).collect();
|
||||
let icon = kinds.first().map(|k| k.icon());
|
||||
let eye = eye_flag(&mut app.ui, key).map(|v| *v);
|
||||
let r = draw_row(
|
||||
app,
|
||||
ui,
|
||||
@@ -445,6 +456,7 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
|
||||
&Row {
|
||||
depth: 1,
|
||||
fold: Some(open(app, key)),
|
||||
eye,
|
||||
icon,
|
||||
label,
|
||||
note: Some(format!("{}", items.len())),
|
||||
@@ -452,7 +464,11 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
if r.fold || r.clicked {
|
||||
if r.eye
|
||||
&& let Some(v) = eye_flag(&mut app.ui, key)
|
||||
{
|
||||
*v = !*v;
|
||||
} else if r.fold || r.clicked {
|
||||
acts.push(Act::Fold(key.into()));
|
||||
}
|
||||
if open(app, key) {
|
||||
|
||||
@@ -47,6 +47,7 @@ use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use fieldcraft_engine::Session;
|
||||
use fieldcraft_engine::catalogue::{self, Tab, Workspace};
|
||||
use fieldcraft_render::glyphs::Glyphs;
|
||||
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, StandardView};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
@@ -69,6 +70,10 @@ pub struct UiState {
|
||||
pub show_geometry: bool,
|
||||
/// Elements.
|
||||
pub show_mesh: bool,
|
||||
/// Boundary condition and load symbols.
|
||||
pub show_loads: bool,
|
||||
/// Reference point, coupling and rigid wall symbols.
|
||||
pub show_interactions: bool,
|
||||
pub perspective: bool,
|
||||
pub dark: bool,
|
||||
/// The browser (left).
|
||||
@@ -92,6 +97,8 @@ impl Default for UiState {
|
||||
show_nodes: false,
|
||||
show_geometry: true,
|
||||
show_mesh: true,
|
||||
show_loads: true,
|
||||
show_interactions: true,
|
||||
perspective: false,
|
||||
dark: true,
|
||||
show_tree: true,
|
||||
@@ -119,7 +126,13 @@ impl UiState {
|
||||
|
||||
/// What the viewport shows.
|
||||
pub fn show(&self) -> scene::Show {
|
||||
scene::Show { geometry: self.show_geometry, mesh: self.show_mesh, nodes: self.show_nodes }
|
||||
scene::Show {
|
||||
geometry: self.show_geometry,
|
||||
mesh: self.show_mesh,
|
||||
nodes: self.show_nodes,
|
||||
loads: self.show_loads,
|
||||
interactions: self.show_interactions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +231,8 @@ pub struct ModelApp {
|
||||
/// Bumped whenever the model may have changed.
|
||||
revision: u64,
|
||||
display: (Option<u64>, DisplayMesh),
|
||||
/// The analysis setup's symbols, built with the display data.
|
||||
glyphs: Glyphs,
|
||||
/// The results plot and what it was built for.
|
||||
plot: (Option<PlotKey>, Option<Arc<results::Plot>>),
|
||||
/// The node probed last (a click on a results plot).
|
||||
@@ -230,8 +245,8 @@ pub struct ModelApp {
|
||||
bodies: (Option<Arc<fieldcraft_geometry::Design>>, Vec<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 and its key.
|
||||
scene: (Option<u64>, Scene),
|
||||
/// The built scene, its key and the box around its symbols (they reach beyond the model).
|
||||
scene: (Option<u64>, Scene, Aabb3),
|
||||
/// The theme last applied to the context.
|
||||
styled: Option<bool>,
|
||||
/// Input injected by the control channel, fed to egui one pointer event per frame.
|
||||
@@ -295,13 +310,14 @@ impl ModelApp {
|
||||
gpu_info: None,
|
||||
revision: 0,
|
||||
display: (None, DisplayMesh::default()),
|
||||
glyphs: Glyphs::default(),
|
||||
plot: (None, None),
|
||||
probe: None,
|
||||
reactions: None,
|
||||
results_shown: false,
|
||||
bodies: (None, Vec::new()),
|
||||
prepared: (None, results::Prepared::default()),
|
||||
scene: (None, Scene::default()),
|
||||
scene: (None, Scene::default(), Aabb3::EMPTY),
|
||||
styled: None,
|
||||
synthetic: Vec::new(),
|
||||
synthetic_mods: egui::Modifiers::default(),
|
||||
@@ -567,6 +583,7 @@ impl ModelApp {
|
||||
}
|
||||
let was_empty = self.display.1.bounds.is_empty();
|
||||
self.display = (Some(self.revision), d);
|
||||
self.glyphs = Glyphs::from_model(&self.session.model);
|
||||
if was_empty && !self.display.1.bounds.is_empty() {
|
||||
self.cam.fit(&self.display.1.bounds);
|
||||
}
|
||||
@@ -574,6 +591,11 @@ impl ModelApp {
|
||||
&self.display.1
|
||||
}
|
||||
|
||||
/// Definitions whose symbols are a sample (`Clamp: 400 of 12000 nodes`).
|
||||
pub fn glyph_notes(&self) -> &[String] {
|
||||
&self.glyphs.notes
|
||||
}
|
||||
|
||||
/// The key of the current scene: the model revision, every setting the scene depends on and
|
||||
/// the results plot shown.
|
||||
pub fn scene_key(&self) -> u64 {
|
||||
@@ -619,17 +641,20 @@ impl ModelApp {
|
||||
if self.scene.0 != Some(key) {
|
||||
let t = theme::Tokens::get(self.ui.dark);
|
||||
let (style, show) = (self.ui.style, self.ui.show());
|
||||
let s = match &self.plot.1 {
|
||||
let mut s = match &self.plot.1 {
|
||||
Some(p) => results::scene(p, style, show, &t),
|
||||
None => scene::build(&self.display.1, style, show, &t),
|
||||
};
|
||||
self.scene = (Some(key), s);
|
||||
// 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 symbols = scene::symbols(&mut s, &self.glyphs, |n| drawn.node_position(n), show, self.plot.1.is_some(), &t);
|
||||
self.scene = (Some(key), s, symbols);
|
||||
}
|
||||
let mut bounds = self.display.1.bounds;
|
||||
if let Some(p) = &self.plot.1 {
|
||||
bounds.add(p.display.bounds.min);
|
||||
bounds.add(p.display.bounds.max);
|
||||
bounds.union(p.display.bounds);
|
||||
}
|
||||
bounds.union(self.scene.2);
|
||||
self.scene.1.radius = scene::radius(&bounds, &self.cam);
|
||||
(key, &self.scene.1)
|
||||
}
|
||||
@@ -993,6 +1018,29 @@ mod tests {
|
||||
results::tests::node_at(&app.session, xyz)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_symbols_show_with_their_toggles_and_fade_over_results() {
|
||||
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");
|
||||
// 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;
|
||||
// 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.glyph_notes().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solving_shows_the_result_on_the_results_tab() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
@@ -1012,6 +1060,8 @@ mod tests {
|
||||
assert!(app.scene().1.tris.len() > faces);
|
||||
app.run("results.clear", json!({})).unwrap();
|
||||
assert!(app.plot().is_none());
|
||||
// The model's faces again (without the setup's symbols).
|
||||
app.ui.show_loads = false;
|
||||
assert_eq!(app.scene().1.tris.len(), faces);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ impl Plot {
|
||||
|
||||
/// The node's position in the plot, if it is drawn.
|
||||
pub fn node_position(&self, label: u32) -> Option<Vec3> {
|
||||
self.display.nodes.binary_search_by_key(&label, |n| n.label).ok().and_then(|i| self.display.nodes.get(i)).map(|n| n.p)
|
||||
self.display.node_position(label)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
//!
|
||||
//! Geometry (the open design, imported surfaces) draws as faces with their boundaries while
|
||||
//! the model has no element faces; once it is meshed, as boundaries only, so the mesh is not
|
||||
//! hidden behind coincident faces.
|
||||
//! hidden behind coincident faces. The analysis setup's symbols ([`symbols`]) go over the model,
|
||||
//! muted over a results plot.
|
||||
|
||||
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, SceneLine, ScenePoint, SceneTri};
|
||||
use fieldcraft_render::glyphs::{GlyphShow, Glyphs};
|
||||
use fieldcraft_render::{Aabb3, Camera, DisplayMesh, Scene, SceneLine, ScenePoint, SceneTri, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::theme::{Tokens, rgb};
|
||||
@@ -48,11 +50,23 @@ pub struct Show {
|
||||
/// Elements.
|
||||
pub mesh: bool,
|
||||
pub nodes: bool,
|
||||
/// Boundary conditions and loads.
|
||||
pub loads: bool,
|
||||
/// Reference points, couplings and rigid walls.
|
||||
pub interactions: bool,
|
||||
}
|
||||
|
||||
impl Show {
|
||||
/// Geometry and mesh, without nodes.
|
||||
pub const DEFAULT: Show = Show { geometry: true, mesh: true, nodes: false };
|
||||
/// Geometry, mesh and the analysis setup, without nodes.
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
/// The scene for `display` drawn in `style`.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use egui::{Color32, Stroke};
|
||||
use fieldcraft_model::ElementKind;
|
||||
use fieldcraft_render::Rgb;
|
||||
use fieldcraft_render::glyphs::GlyphColors;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tokens {
|
||||
@@ -70,6 +71,15 @@ pub struct Tokens {
|
||||
pub contour: [Color32; 5],
|
||||
/// Faces with no result at a node.
|
||||
pub no_result: Color32,
|
||||
// ---- analysis setup symbols ----
|
||||
/// Boundary conditions and rigid walls.
|
||||
pub glyph_constraint: Color32,
|
||||
/// Forces, moments, pressures, body loads and initial velocities.
|
||||
pub glyph_load: Color32,
|
||||
/// Reference points and couplings.
|
||||
pub glyph_interaction: Color32,
|
||||
/// Temperatures, heat fluxes, convection and heat sources.
|
||||
pub glyph_thermal: Color32,
|
||||
}
|
||||
|
||||
/// The contour colour scale: blue, cyan, green, yellow, red.
|
||||
@@ -129,6 +139,10 @@ impl Tokens {
|
||||
geometry_edge: Color32::from_rgb(40, 44, 52),
|
||||
contour: CONTOUR,
|
||||
no_result: Color32::from_rgb(0x9a, 0xa0, 0xaa),
|
||||
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_thermal: Color32::from_rgb(0xe8, 0x4c, 0x00),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +189,10 @@ impl Tokens {
|
||||
t.geometry = Color32::from_rgb(152, 156, 162);
|
||||
t.geometry_edge = Color32::from_rgb(16, 16, 18);
|
||||
t.no_result = Color32::from_rgb(0x70, 0x76, 0x80);
|
||||
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_thermal = Color32::from_rgb(0xff, 0x8c, 0x00);
|
||||
t
|
||||
}
|
||||
|
||||
@@ -183,6 +201,17 @@ impl Tokens {
|
||||
self.contour.map(rgb)
|
||||
}
|
||||
|
||||
/// The colours of the analysis setup's symbols; their names in the overlay text colour.
|
||||
pub fn glyph_colors(&self) -> GlyphColors {
|
||||
GlyphColors {
|
||||
constraint: rgb(self.glyph_constraint),
|
||||
load: rgb(self.glyph_load),
|
||||
interaction: rgb(self.glyph_interaction),
|
||||
thermal: rgb(self.glyph_thermal),
|
||||
label: rgb(self.overlay_text),
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour elements of this kind are drawn in.
|
||||
pub fn element(&self, kind: ElementKind) -> Color32 {
|
||||
match kind {
|
||||
@@ -294,6 +323,29 @@ mod tests {
|
||||
run(&ctx);
|
||||
}
|
||||
|
||||
/// Setup symbols differ from every element colour and from each other, and stand out from
|
||||
/// the viewport background.
|
||||
#[test]
|
||||
fn symbol_colours_stand_out() {
|
||||
let luma = |c: Color32| i32::from(c.r()) * 3 + i32::from(c.g()) * 6 + i32::from(c.b());
|
||||
let far = |a: Color32, b: Color32| {
|
||||
(i32::from(a.r()) - i32::from(b.r())).abs() + (i32::from(a.g()) - i32::from(b.g())).abs() + (i32::from(a.b()) - i32::from(b.b())).abs()
|
||||
};
|
||||
for dark in [true, false] {
|
||||
let t = Tokens::get(dark);
|
||||
let symbols = [t.glyph_constraint, t.glyph_load, t.glyph_interaction, 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 {
|
||||
assert!(far(*s, t.element(k)) > 90, "{s:?} vs {k:?} dark={dark}");
|
||||
}
|
||||
for o in symbols.iter().skip(i + 1) {
|
||||
assert!(far(*s, *o) > 90, "{s:?} vs {o:?} dark={dark}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every element kind has a colour, and edges stand out from the faces they lie on.
|
||||
#[test]
|
||||
fn element_colours_contrast_with_edges() {
|
||||
|
||||
@@ -33,9 +33,11 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) {
|
||||
gradient(&painter, rect, t.viewport_top, t.viewport_bottom);
|
||||
if app.viewport.gpu.is_some() {
|
||||
paint_gpu(app, ui, &painter, rect);
|
||||
labels(app, &painter, &t);
|
||||
} else {
|
||||
paint_cpu(app, ui, &painter, rect);
|
||||
}
|
||||
sampled_note(app, &painter, rect, &t);
|
||||
if app.display().bounds.is_empty() && app.busy().is_none() {
|
||||
start_screen(app, ui, rect, &t);
|
||||
}
|
||||
@@ -176,6 +178,32 @@ fn paint_cpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: R
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
let offset = fieldcraft_render::LABEL_OFFSET as f32;
|
||||
for (p, text) in labels {
|
||||
if let Some(at) = to_screen(app, p) {
|
||||
let galley = painter.layout_no_wrap(text, egui::FontId::proportional(12.0), t.overlay_text);
|
||||
let r = Rect::from_min_size(at + vec2(offset, -offset - galley.size().y), galley.size()).expand2(vec2(4.0, 1.0));
|
||||
painter.rect_filled(r, 3.0, t.overlay);
|
||||
painter.galley(r.min + vec2(4.0, 1.0), galley, t.overlay_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom left: the definitions whose symbols are a sample of their nodes or faces.
|
||||
fn sampled_note(app: &ModelApp, painter: &egui::Painter, rect: Rect, t: &Tokens) {
|
||||
let show = app.ui.show();
|
||||
let notes = &app.glyph_notes();
|
||||
if notes.is_empty() || !(show.loads || show.interactions) {
|
||||
return;
|
||||
}
|
||||
let text = format!("Symbols sampled: {}", notes.join("; "));
|
||||
painter.text(rect.left_bottom() + vec2(10.0, -10.0), egui::Align2::LEFT_BOTTOM, text, egui::FontId::proportional(11.0), t.text_dim);
|
||||
}
|
||||
|
||||
/// The orientation gizmo's arm length and tip radius (points).
|
||||
const AXIS_LENGTH: f32 = 38.0;
|
||||
const AXIS_RADIUS: f32 = 11.0;
|
||||
@@ -321,6 +349,8 @@ fn nav_bar(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
|
||||
ui.checkbox(&mut app.ui.show_geometry, "Geometry");
|
||||
ui.checkbox(&mut app.ui.show_mesh, "Mesh");
|
||||
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");
|
||||
ui.separator();
|
||||
ui.checkbox(&mut app.ui.show_tree, "Browser");
|
||||
ui.checkbox(&mut app.ui.show_timeline, "Timeline");
|
||||
|
||||
Reference in new issue
Block a user