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 <noreply@anthropic.com>
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

+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");