ui-egui: select faces, edges, vertices, bodies, nodes and elements by picking

A click in the viewport picks what is under the pointer through the
selection filter (the selection bar, top centre): geometry faces, edges,
vertices and bodies, or mesh nodes, element faces and elements. Shift
adds, Cmd/Ctrl adds or removes, a click on nothing clears, Escape
clears; with Box on, a left drag selects the visible nodes, element
faces or elements inside the box. What a click would pick is
highlighted under the pointer with its name beside it.

The selection is kept in the model's persistent terms: geometry by name
(GeometryRef, named through the mesh's geometry tags or the drawn
bodies' face names), so it survives a remesh, and the mesh by label
(labels the model loses drop out). It reads as a set's members.

Picking runs on the CPU against the triangles both renderers draw. The
selection and the hover are drawn as an overlay in the theme's new
pick_selected and pick_hover colours (light and dark), outlined; the GPU
draws it as a second small layer, so a hover change uploads only that.

Command forms that apply to part of the model (sets, boundary
conditions, loads, sections, couplings) get a Target row whose Use
selection fills the members, or makes a set of the selection and names
it. A right click offers Create set from selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 08:16:26 -04:00
1 parent 32c67d1988
commit 9e269a9f45
8 files changed
+1686 -59

No files matched your search

+2
View File
@@ -84,6 +84,7 @@ impl ModelApp {
let toolbar = std::mem::take(&mut self.toolbar_rects);
let layout = std::mem::take(&mut self.layout);
let panels = std::mem::take(&mut self.panel_rects);
self.capturing = true;
crate::theme::apply(&off.ctx, self.ui.dark);
// A capture is one still frame: windows and menus appear without fading in.
off.ctx.global_style_mut(|s| s.animation_time = 0.0);
@@ -114,6 +115,7 @@ impl ModelApp {
}
std::mem::swap(&mut self.icons, &mut off.icons);
std::mem::swap(&mut self.viewport, &mut off.viewport);
self.capturing = false;
self.toolbar_rects = toolbar;
self.layout = layout;
self.panel_rects = panels;
+27 -1
View File
@@ -6,10 +6,10 @@
use egui::{RichText, vec2};
use serde_json::{Value, json};
use crate::ModelApp;
use crate::browser::{self, Def};
use crate::frame::{Level, heading};
use crate::theme::{Tokens, label_font};
use crate::{ModelApp, selection};
/// The panel's width.
pub const WIDTH: f32 = 310.0;
@@ -59,7 +59,11 @@ pub fn panel(app: &mut ModelApp, ui: &mut egui::Ui) {
}
/// The open command's parameters as JSON, with the command's parameter documentation; OK runs it.
/// A command that applies to part of the model (a set, a boundary condition, a load, a section, a
/// coupling) has a Target row that takes the viewport's selection.
fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
let selected = (!app.pick.selection.is_empty()).then(|| app.pick.selection.summary());
let mut use_selection = false;
let Some(d) = app.dialog.as_mut() else { return };
let Some(spec) = fieldcraft_engine::find_command(d.command) else {
app.dialog = None;
@@ -74,6 +78,23 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
});
ui.add_space(4.0);
ui.label(RichText::new(spec.id).small().color(t.text_dim));
let params = serde_json::from_str::<Value>(&d.text).unwrap_or(Value::Null);
if let Some(target) = selection::target(spec.id, &params) {
heading(ui, t, Level::Section, |ui| {
ui.label(RichText::new("Target").size(12.0));
});
ui.horizontal(|ui| {
let tip = match target {
selection::Target::Members => "The selection becomes the set's members (geometry by name, the mesh by label)",
selection::Target::Set | selection::Target::TargetSet => "Makes a set of the selection (model.set.create) and names it here",
};
use_selection = ui.add_enabled(selected.is_some(), egui::Button::new("Use selection")).on_hover_text(tip).clicked();
match &selected {
Some(text) => ui.label(RichText::new(text).color(t.pick_selected)),
None => ui.label(RichText::new("Pick in the viewport, or type the names below").color(t.text_dim)),
};
});
}
heading(ui, t, Level::Section, |ui| {
ui.label(RichText::new("Parameters").size(12.0));
});
@@ -98,6 +119,11 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) {
app.dialog = None;
} else if run {
let _ = app.run_dialog();
} else if use_selection
&& let Err(e) = app.use_selection_in_dialog()
&& let Some(d) = app.dialog.as_mut()
{
d.error = Some(e);
}
}
+88 -39
View File
@@ -1,7 +1,9 @@
//! 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.
//! 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.
//!
//! 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
@@ -79,6 +81,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 overlay (the selection highlight).
pub overlay_key: u64,
pub overlay_slot: SceneSlot,
/// Column-major view-projection.
pub view_proj: [[f32; 4]; 4],
/// Direction toward the eye (for lighting).
@@ -94,15 +99,61 @@ struct Batch {
count: u32,
}
/// Uploaded geometry: triangle and line batches, and the key they were uploaded for.
#[derive(Default)]
struct Layer {
key: Option<u64>,
tris: Vec<Batch>,
lines: Vec<Batch>,
}
impl Layer {
fn take(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, key: u64, slot: &SceneSlot) {
if self.key == Some(key) {
return;
}
if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) {
// The old buffers go first, so the new ones don't have to fit beside them.
self.tris.clear();
self.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.key = Some(key);
}
}
fn paint_tris(&self, pass: &mut wgpu::RenderPass<'static>, tri: &wgpu::RenderPipeline) {
if !self.tris.is_empty() {
pass.set_pipeline(tri);
for b in &self.tris {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..b.count, 0..1);
}
}
}
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);
}
}
}
}
struct Resources {
tri: wgpu::RenderPipeline,
/// The overlay's faces: pulled toward the eye and depth-tested without writing depth.
overlay_tri: wgpu::RenderPipeline,
line: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
bind: wgpu::BindGroup,
linear_out: bool,
key: Option<u64>,
tris: Vec<Batch>,
lines: Vec<Batch>,
scene: Layer,
overlay: Layer,
}
const SHADER: &str = r#"
@@ -166,6 +217,18 @@ fn biased_z(p: vec3<f32>, c: vec4<f32>, s: f32) -> f32 {
return c.z - u.screen.z * s * c.w;
}
/// An overlay face: as `vs_tri`, half a depth bias toward the eye, so it covers the scene face it
/// repeats (or the contour bands that split it).
@vertex
fn vs_overlay_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
let clip = u.vp * vec4<f32>(p, 1.0);
o.pos = vec4<f32>(clip.xy, biased_z(p, clip, 0.5), clip.w);
o.n = n;
o.c = c;
return o;
}
struct LOut {
@builtin(position) pos: vec4<f32>,
@location(0) c: vec4<f32>,
@@ -295,6 +358,7 @@ impl Resources {
};
Resources {
tri: pipeline("fc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None),
overlay_tri: pipeline("fc_overlay_tris", "vs_overlay_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), None),
line: pipeline(
"fc_lines",
"vs_line",
@@ -306,24 +370,8 @@ impl Resources {
uniform,
bind,
linear_out: t.format.is_srgb(),
key: None,
tris: Vec::new(),
lines: Vec::new(),
}
}
fn take(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, key: u64, slot: &SceneSlot) {
if self.key == Some(key) {
return;
}
if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) {
// The old buffers go first, so the new ones don't have to fit beside them.
self.tris.clear();
self.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.key = Some(key);
scene: Layer::default(),
overlay: Layer::default(),
}
}
}
@@ -391,7 +439,8 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(res) = resources.get_mut::<Resources>() else { return Vec::new() };
res.take(device, queue, self.key, &self.slot);
res.scene.take(device, queue, self.key, &self.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()
}
@@ -404,20 +453,12 @@ 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, &[]);
if !res.tris.is_empty() {
pass.set_pipeline(&res.tri);
for b in &res.tris {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..b.count, 0..1);
}
}
if !res.lines.is_empty() {
pass.set_pipeline(&res.line);
for b in &res.lines {
pass.set_vertex_buffer(0, b.buffer.slice(..));
pass.draw(0..6, 0..b.count);
}
}
// 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).
res.scene.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);
}
}
@@ -492,8 +533,16 @@ mod tests {
// 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);
assert_eq!(f32::from_le_bytes([g.lines[60], g.lines[61], g.lines[62], g.lines[63]]), 8.0);
let cb =
ViewportCallback { key: 0, slot: SceneSlot::default(), view_proj: [[0.0; 4]; 4], back: [0.0; 3], eye: [0.0; 4], size_px: [1.0, 1.0] };
let cb = ViewportCallback {
key: 0,
slot: SceneSlot::default(),
overlay_key: 0,
overlay_slot: SceneSlot::default(),
view_proj: [[0.0; 4]; 4],
back: [0.0; 3],
eye: [0.0; 4],
size_px: [1.0, 1.0],
};
assert_eq!(uniform_bytes(&cb, true).len() as u64, UNIFORM_SIZE);
}
}
+313 -9
View File
@@ -10,7 +10,8 @@
//! labelled drop-downs list the whole catalogue (coming-soon entries greyed with their
//! milestone);
//! - the browser on the left ([`browser`]): the model's geometry, mesh and definitions;
//! - the viewport ([`viewport`]) with its navigation bar and orientation gizmo;
//! - the viewport ([`viewport`]) with its navigation bar, orientation gizmo and selection bar
//! (picking faces, edges, vertices, bodies, nodes and elements: [`selection`]);
//! - the context panel on the right ([`context`]), only while there is context: the open command
//! form, the results controls, the selected definition's properties;
//! - the command line and status ([`palette`]), and the journal timeline at the bottom
@@ -35,6 +36,7 @@ pub mod palette;
pub mod results;
pub mod results_panel;
pub mod scene;
pub mod selection;
pub mod theme;
pub mod timeline;
pub mod titlebar;
@@ -162,6 +164,38 @@ pub struct Status {
pub error: bool,
}
/// Selection by picking in the viewport ([`selection`]): the filter, what is selected and what
/// is under the pointer, and what they are drawn with.
#[derive(Default)]
pub struct PickState {
pub filter: selection::Filter,
pub selection: selection::Selection,
/// What a click would pick under the pointer.
pub hover: Option<selection::Item>,
/// A left drag draws a selection box (nodes, element faces, elements) instead of orbiting.
pub box_mode: bool,
/// The box being dragged (window points).
pub dragging: Option<(egui::Pos2, egui::Pos2)>,
/// Where the pointer was when the hover was worked out (window points).
pub hover_at: Option<egui::Pos2>,
/// Bumped whenever the selection changes, and whenever the hover does.
generation: u64,
hover_generation: u64,
/// The geometry tags behind the drawn mesh and the model revision they were read at.
index: (Option<u64>, Arc<selection::Index>),
/// The names of the drawn geometry surfaces, in the order they were added to the display.
names: Vec<selection::BodyNames>,
/// The selection's highlight and what it was built for (kept while only the hover changes).
selected_overlay: (Option<u64>, Arc<Scene>),
/// The highlight overlay (the selection's and the hover's) and what it was built for.
overlay: (Option<u64>, Arc<Scene>),
/// What the hover was last worked out for: the pointer, the camera, the scene, the filter
/// and the viewport.
pub(crate) hover_key: Option<(Option<egui::Pos2>, Camera, u64, selection::Filter, Option<egui::Rect>)>,
/// The model revision the selection's mesh labels were last checked at.
checked: Option<u64>,
}
/// The viewport's renderer state.
#[derive(Default)]
pub struct Viewport {
@@ -172,8 +206,11 @@ pub struct Viewport {
pub slot: gpu::SceneSlot,
/// The scene key last handed to the GPU.
pub uploaded: Option<u64>,
/// CPU fallback: the texture and what it shows (scene key, camera, size).
cpu: Option<(egui::TextureHandle, u64, Camera, [usize; 2])>,
/// 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])>,
/// 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.
@@ -241,8 +278,8 @@ pub struct ModelApp {
pub reactions: Option<Value>,
/// Whether results were shown last frame (the Results tab comes up when they appear).
results_shown: bool,
/// Tessellated design bodies, kept while the same design is open.
bodies: (Option<Arc<fieldcraft_geometry::Design>>, Vec<fieldcraft_geometry::SurfaceMesh>),
/// Tessellated design bodies with their names, kept while the same design is open.
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).
@@ -280,6 +317,11 @@ pub struct ModelApp {
pub tree_open: std::collections::BTreeSet<String>,
/// The definition selected in the browser: (kind, name), its properties on the right.
pub selected: Option<(browser::Def, String)>,
/// What is picked in the viewport.
pub pick: PickState,
/// Set while [`ModelApp::capture`] draws the window offscreen: no pointer there is the
/// user's, so the hover stays as it is.
capturing: bool,
/// What a left drag in the viewport does.
pub nav: NavMode,
/// Where the window's regions were drawn last frame (`ui.inspect`'s `layout`).
@@ -338,6 +380,8 @@ impl ModelApp {
command_line: String::new(),
tree_open: ["mesh", "results"].into_iter().map(str::to_string).collect(),
selected: None,
pick: PickState::default(),
capturing: false,
nav: NavMode::default(),
layout: std::collections::BTreeMap::new(),
panel_rects: Vec::new(),
@@ -570,7 +614,9 @@ impl ModelApp {
.flat_map(|d| &d.bodies)
.filter_map(|body| {
let tolerance = body.measure().map(|m| display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() }));
tolerance.and_then(|t| body.tessellate(t)).map_err(|e| failed.push(format!("{}: {e}", body.name))).ok()
let mesh = tolerance.and_then(|t| body.tessellate(t)).map_err(|e| failed.push(format!("{}: {e}", body.name))).ok()?;
let names = selection::BodyNames { name: body.name.clone(), faces: body.faces().iter().map(|f| f.name.clone()).collect() };
Some((names, mesh))
})
.collect();
self.bodies = (design, meshes);
@@ -578,8 +624,15 @@ impl ModelApp {
self.set_status(format!("Some bodies cannot be drawn: {}", failed.join("; ")), true);
}
}
for mesh in self.bodies.1.iter().chain(self.session.surfaces.iter().map(|s| &s.mesh)) {
self.pick.names.clear();
for (names, mesh) in &self.bodies.1 {
d.add_surface(&mesh.positions, &mesh.triangles, &mesh.tri_face);
self.pick.names.push(names.clone());
}
// An imported surface's faces are named by their tags.
for s in &self.session.surfaces {
d.add_surface(&s.mesh.positions, &s.mesh.triangles, &s.mesh.tri_face);
self.pick.names.push(selection::BodyNames { name: s.name.clone(), faces: (0..s.faces).map(|f| f.to_string()).collect() });
}
let was_empty = self.display.1.bounds.is_empty();
self.display = (Some(self.revision), d);
@@ -659,11 +712,215 @@ impl ModelApp {
(key, &self.scene.1)
}
/// A CPU render of the viewport's scene with the current camera, as PNG bytes.
/// A CPU render of the viewport's scene with the current camera and the selection, as PNG
/// bytes.
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_png(scene, &cam, w, h)
fieldcraft_render::render_layers_png(scene, Some(&overlay), &cam, w, h)
}
/// The geometry tags behind the drawn mesh, read again when the model changed.
fn pick_index(&mut self) -> Arc<selection::Index> {
self.display();
if self.pick.index.0 != Some(self.revision) {
self.pick.index = (Some(self.revision), Arc::new(selection::Index::new(&self.session.model, &self.display.1)));
}
self.pick.index.1.clone()
}
/// Run `f` with the picker of what the viewport draws: a results plot's elements, or the
/// model's elements and its geometry where no element faces cover it (as [`scene::build`]).
pub fn with_picker<R>(&mut self, f: impl FnOnce(&selection::Picker) -> R) -> R {
let index = self.pick_index();
let plot = self.plot();
let (show_mesh, show_geometry) = (self.ui.show_mesh, self.ui.show_geometry);
let display = plot.as_ref().map_or(&self.display.1, |p| &p.display);
let mesh_faces = show_mesh && !display.tris.is_empty();
let picker = selection::Picker {
display,
names: &self.pick.names,
model: &self.session.model,
index: &index,
elements: show_mesh,
geometry: show_geometry && !mesh_faces,
};
f(&picker)
}
/// The viewport's projection as drawn last frame (`None` before it was drawn).
pub fn projector(&mut self) -> Option<fieldcraft_render::pick::Projector> {
let rect = self.viewport.rect?;
let radius = self.scene().1.radius;
Some(fieldcraft_render::pick::Projector::new(&self.cam, f64::from(rect.width()), f64::from(rect.height()), radius))
}
/// What a click at window point `at` would pick with the current filter.
pub fn pick_at(&mut self, at: egui::Pos2) -> Option<selection::Item> {
let rect = self.viewport.rect.filter(|r| r.contains(at))?;
let proj = self.projector()?;
let filter = self.pick.filter;
let local = [f64::from(at.x - rect.left()), f64::from(at.y - rect.top())];
self.with_picker(|p| p.pick(&proj, local, filter))
}
/// Change the selection by `items` (see [`selection::Mode`]); the status bar says what is
/// selected.
pub fn select(&mut self, items: impl IntoIterator<Item = selection::Item>, mode: selection::Mode) {
if self.pick.selection.apply(items, mode) {
self.pick.generation = self.pick.generation.wrapping_add(1);
}
let mut text = format!("Selected: {}", self.pick.selection.summary());
if self.pick.selection.capped() {
text.push_str(&format!(" (at most {} items)", selection::MAX_SELECTION));
}
self.set_status(text, false);
}
pub fn clear_selection(&mut self) {
if !self.pick.selection.is_empty() {
self.pick.selection.clear();
self.pick.generation = self.pick.generation.wrapping_add(1);
self.set_status("Selection cleared", false);
}
}
/// Pick at window point `at` and change the selection by it; a plain click on nothing clears
/// the selection. Returns what was picked.
pub fn select_at(&mut self, at: egui::Pos2, mode: selection::Mode) -> Option<selection::Item> {
// A click where the hover was just worked out picks what is highlighted.
let current =
self.pick.hover_key.as_ref().is_some_and(|k| k.0 == Some(at) && k.1 == self.cam && k.2 == self.scene_key() && k.3 == self.pick.filter);
let item = if current { self.pick.hover.clone() } else { self.pick_at(at) };
match &item {
Some(i) => self.select([i.clone()], mode),
None if mode == selection::Mode::Replace => self.clear_selection(),
None => {}
}
item
}
/// Select what is visible inside the box between window points `a` and `b` (nodes, element
/// faces or elements, with the filter); returns how many items it held.
pub fn select_box(&mut self, a: egui::Pos2, b: egui::Pos2, mode: selection::Mode) -> usize {
let (Some(rect), Some(proj)) = (self.viewport.rect, self.projector()) else { return 0 };
let filter = self.pick.filter;
let r = [a.x - rect.left(), a.y - rect.top(), b.x - rect.left(), b.y - rect.top()].map(f64::from);
let items = self.with_picker(|p| p.pick_box(&proj, r, filter));
let n = items.len();
self.select(items, mode);
n
}
/// Set what is under the pointer (`None` when nothing, or the pointer is elsewhere).
pub fn set_hover(&mut self, item: Option<selection::Item>) {
if self.pick.hover != item {
self.pick.hover = item;
self.pick.hover_generation = self.pick.hover_generation.wrapping_add(1);
}
}
/// The selection and the hover drawn over the scene in the theme's highlight colours,
/// rebuilt when either or the scene changes; and its key.
pub fn overlay(&mut self) -> (u64, Arc<Scene>) {
let hash = |v: &dyn Fn(&mut std::hash::DefaultHasher)| {
let mut h = std::hash::DefaultHasher::new();
v(&mut h);
h.finish()
};
let scene = self.scene_key();
let selected = hash(&|h| (scene, self.pick.generation).hash(h));
let key = hash(&|h| (selected, self.pick.hover_generation).hash(h));
if self.pick.overlay.0 == Some(key) {
return (key, self.pick.overlay.1.clone());
}
let t = theme::Tokens::get(self.ui.dark);
// The selection's part is kept while only the hover changes.
if self.pick.selected_overlay.0 != Some(selected) {
let sel = std::mem::take(&mut self.pick.selection);
let s = self.with_picker(|p| p.overlay(&sel, None, theme::rgb(t.pick_selected), theme::rgb(t.pick_hover)));
self.pick.selection = sel;
self.pick.selected_overlay = (Some(selected), Arc::new(s));
}
let mut s = (*self.pick.selected_overlay.1).clone();
if let Some(hover) = self.pick.hover.clone() {
let h = self
.with_picker(|p| p.overlay(&selection::Selection::default(), Some(&hover), theme::rgb(t.pick_selected), theme::rgb(t.pick_hover)));
s.tris.extend(h.tris);
s.lines.extend(h.lines);
s.points.extend(h.points);
}
self.pick.overlay = (Some(key), Arc::new(s));
(key, self.pick.overlay.1.clone())
}
/// Drop selected nodes, elements and element faces the model no longer has (after an edit,
/// an undo or a remesh); geometry stays selected by name.
fn check_selection(&mut self) {
if self.pick.checked == Some(self.revision) {
return;
}
self.pick.checked = Some(self.revision);
let model = &self.session.model;
let before = self.pick.selection.len();
self.pick.selection.retain(|item| match item {
selection::Item::Geometry(_) => true,
selection::Item::Node(n) => model.node(*n).is_some(),
selection::Item::Element(e) => model.element(*e).is_some(),
selection::Item::Face(f) => model.face_nodes(*f).is_some(),
});
if self.pick.selection.len() != before {
self.pick.generation = self.pick.generation.wrapping_add(1);
}
}
/// Open `model.set.create`'s form with the selection as the new set's members.
pub fn create_set_from_selection(&mut self) -> Result<(), String> {
let params = self.selection_set(None)?;
self.open_form("model.set.create", params);
Ok(())
}
/// `model.set.create`'s parameters for a set of the selection, named after `base` (`Set-1`
/// by default; the first name not taken).
fn selection_set(&self, base: Option<&str>) -> Result<Value, String> {
if self.pick.selection.is_empty() {
return Err("nothing is selected".into());
}
let name = selection::fresh_set_name(&self.session.model, base.unwrap_or("Set-1"));
Ok(selection::with_members(&json!({"name": name}), &self.pick.selection))
}
/// Put the selection into the open command form: a set's members, or a new set made from it
/// (`model.set.create`, named after the definition) as the target of a boundary condition,
/// load, section or coupling.
pub fn use_selection_in_dialog(&mut self) -> Result<(), String> {
let Some(d) = self.dialog.as_ref() else { return Err("no command form is open".into()) };
let command = d.command;
if self.pick.selection.is_empty() {
return Err("nothing is selected".into());
}
let params: Value = serde_json::from_str(&d.text).map_err(|e| format!("the parameters are not JSON: {e}"))?;
if !params.is_object() {
return Err("the parameters must be a JSON object".into());
}
let Some(target) = selection::target(command, &params) else { return Err(format!("{command} takes no selection")) };
let filled = match target {
selection::Target::Members => selection::with_members(&params, &self.pick.selection),
selection::Target::Set | selection::Target::TargetSet => {
let base = params.get("name").and_then(Value::as_str).map(|n| format!("{n}-set"));
let set = self.selection_set(base.as_deref())?;
let name = set.get("name").and_then(Value::as_str).unwrap_or_default().to_string();
self.run("model.set.create", set)?;
selection::with_set(&params, target, &name)
}
};
if let Some(open) = self.dialog.as_mut() {
open.text = serde_json::to_string_pretty(&filled).unwrap_or_else(|_| filled.to_string());
open.error = None;
}
Ok(())
}
/// Frame the whole model.
@@ -705,6 +962,7 @@ impl ModelApp {
self.ui.select_tab(Tab::Results);
}
self.results_shown = shown;
self.check_selection();
// A selected definition that is gone (undo, delete) is no longer selected.
if self.selected.as_ref().is_some_and(|(kind, name)| !kind.exists(&self.session.model, name)) {
self.selected = None;
@@ -722,6 +980,14 @@ impl ModelApp {
if ctx.memory(|m| m.focused().is_some()) {
return;
}
// Escape clears the viewport's selection first (a second one cancels an open form).
if !self.pick.selection.is_empty()
&& self.ui.menu.is_none()
&& !self.ui.palette
&& ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
{
self.clear_selection();
}
let shortcut = |modifiers, key| egui::KeyboardShortcut::new(modifiers, key);
let shift = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT;
// Shift first: the plain shortcut matches it too.
@@ -1260,6 +1526,44 @@ mod tests {
}
}
/// A face picked by name stays selected through a remesh and highlights the new mesh's faces;
/// the set made from it resolves on the new mesh.
#[test]
fn a_geometry_pick_survives_a_remesh() {
let block = |n: u32| {
let corners = [[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 10, 0], [0, 0, 10], [10, 0, 10], [10, 10, 10], [0, 10, 10]];
json!({"body": "Bar", "kind": "hex8", "corners": corners, "divisions": [n, n, n], "faces": {"4": "tip"}})
};
let mut app = ModelApp::new(Session::default());
app.run("mesh.block", block(2)).unwrap();
app.viewport.rect = Some(egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(800.0, 600.0)));
app.set_view(StandardView::Right);
app.fit_view();
let at = viewport::to_screen(&app, Vec3::new(10.0, 5.5, 4.5)).unwrap();
let tip = selection::Item::Geometry(fieldcraft_model::GeometryRef::face("Bar", "tip").unwrap());
assert_eq!(app.select_at(at, selection::Mode::Replace), Some(tip.clone()));
assert_eq!(app.overlay().1.tris.len(), 2 * 2 * 2);
// A click beside the model clears the selection; a Shift click adds back.
assert_eq!(app.select_at(egui::pos2(105.0, 55.0), selection::Mode::Replace), None);
assert!(app.pick.selection.is_empty());
app.select_at(at, selection::Mode::Add);
// Remeshed finer: the tip is still selected and highlights the new faces.
app.run("edit.undo", json!({})).unwrap();
app.run("mesh.block", block(4)).unwrap();
app.logic(&egui::Context::default());
assert_eq!(app.pick.selection.items().iter().collect::<Vec<_>>(), vec![&tip]);
assert_eq!(app.overlay().1.tris.len(), 4 * 4 * 2);
app.create_set_from_selection().unwrap();
assert_eq!(app.dialog.as_ref().map(|d| d.command), Some("model.set.create"));
app.run_dialog().unwrap();
assert_eq!(app.session.model.resolve_set("Set-1").unwrap().faces.len(), 16);
// Mesh labels that are gone leave the selection.
app.select([selection::Item::Node(1), selection::Item::Element(9999)], selection::Mode::Replace);
app.run("mesh.node.add", json!({"label": 100_000, "xyz": [50, 50, 50]})).unwrap();
app.logic(&egui::Context::default());
assert_eq!(app.pick.selection.items().iter().collect::<Vec<_>>(), vec![&selection::Item::Node(1)]);
}
#[test]
fn display_tolerance_follows_the_size() {
let b = |max: [f64; 3]| Aabb3 { min: Vec3::ZERO, max: max.into() };
File diff suppressed because it is too large. Load diff
+28
View File
@@ -82,6 +82,10 @@ pub struct Tokens {
pub glyph_interaction: Color32,
/// Temperatures, heat fluxes, convection and heat sources.
pub glyph_thermal: Color32,
// ---- selection ----
/// What is selected in the viewport, and what a click would pick under the pointer.
pub pick_selected: Color32,
pub pick_hover: Color32,
}
/// The contour colour scale: blue, cyan, green, yellow, red.
@@ -155,6 +159,8 @@ impl Tokens {
glyph_load: Color32::from_rgb(0xd0, 0x24, 0x24),
glyph_interaction: Color32::from_rgb(0x9a, 0x30, 0xc0),
glyph_thermal: Color32::from_rgb(0xe8, 0x4c, 0x00),
pick_selected: Color32::from_rgb(0xd6, 0x00, 0x7f),
pick_hover: Color32::from_rgb(0xf0, 0x7c, 0xc0),
}
}
@@ -215,6 +221,8 @@ impl Tokens {
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.pick_selected = Color32::from_rgb(0xff, 0x3f, 0xb4);
t.pick_hover = Color32::from_rgb(0xff, 0xa6, 0xdc);
t
}
@@ -368,6 +376,26 @@ mod tests {
}
}
/// The selection and hover colours differ from each other, from every element, geometry,
/// node and symbol colour, and from the background.
#[test]
fn selection_colours_stand_out() {
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);
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];
for o in others.into_iter().chain(ElementKind::ALL.map(|k| t.element(k))) {
assert!(far(c, o) > 100, "{c:?} 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() {
+177 -9
View File
@@ -5,12 +5,19 @@
//! drag (or Shift + left drag) pans, the wheel or a pinch zooms at the cursor, a double click or F
//! frames the model. The gizmo's axis tips turn the view to look along them. The bar and the gizmo
//! follow SolidCraft's `viewport.rs` (MIT OR Apache-2.0), see ATTRIBUTION.md.
//!
//! Selection ([`crate::selection`]): what a click would pick is highlighted under the pointer; a
//! click selects it (Shift adds, Cmd/Ctrl adds or removes, a click on nothing clears), Escape
//! clears. The selection bar (top centre) chooses what is picked and turns on box selection,
//! where a left drag selects the visible nodes, element faces or elements inside the box. A
//! right click offers a set made from the selection.
use egui::{Color32, Pos2, Rect, Sense, Stroke, Vec2, pos2, vec2};
use fieldcraft_render::{StandardView, Vec3};
use crate::gpu::{GpuScene, ViewportCallback};
use crate::icons::{self, Look};
use crate::selection::{Filter, Mode};
use crate::theme::Tokens;
use crate::{ModelApp, NavMode, Style};
@@ -22,12 +29,21 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) {
app.viewport.rect = Some(rect);
app.place("viewport", rect);
let response = ui.allocate_rect(rect, Sense::click_and_drag());
navigate(app, ui, &response, rect);
let boxing = app.pick.box_mode && app.pick.filter.boxes();
navigate(app, ui, &response, rect, boxing);
if boxing {
box_drag(app, ui, &response);
}
if response.clicked()
&& let Some(at) = response.interact_pointer_pos()
{
let _ = app.select_at(at, mode(ui.input(|i| i.modifiers)));
probe_at(app, at, rect);
}
if !app.capturing {
hover(app, &response);
}
context_menu(app, &response);
let t = Tokens::get(app.ui.dark);
let painter = ui.painter_at(rect);
gradient(&painter, rect, t.viewport_top, t.viewport_bottom);
@@ -38,8 +54,15 @@ pub fn show(app: &mut ModelApp, ui: &mut egui::Ui) {
paint_cpu(app, ui, &painter, rect);
}
sampled_note(app, &painter, rect, &t);
hover_label(app, &painter, &t);
if let Some((a, b)) = app.pick.dragging {
let r = Rect::from_two_pos(a, b);
painter.rect(r, 0.0, t.pick_selected.gamma_multiply(0.12), Stroke::new(1.0, t.pick_selected), egui::StrokeKind::Inside);
}
if app.display().bounds.is_empty() && app.busy().is_none() {
start_screen(app, ui, rect, &t);
} else {
selection_bar(app, ui, rect, &t);
}
crate::results_panel::show(app, ui, rect, &t);
axis_view(app, ui, rect, &t);
@@ -78,12 +101,147 @@ fn probe_at(app: &mut ModelApp, at: Pos2, rect: Rect) {
}
}
fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect: Rect) {
/// How a click with these modifier keys changes the selection: Cmd/Ctrl adds or removes, Shift
/// adds, a plain click replaces.
fn mode(m: egui::Modifiers) -> Mode {
if m.command || m.ctrl {
Mode::Toggle
} else if m.shift {
Mode::Add
} else {
Mode::Replace
}
}
/// Box selection: a left drag draws the box; on release, what is visible inside it is selected
/// (with the release's modifier keys).
fn box_drag(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response) {
let pointer = response.interact_pointer_pos();
if response.drag_started_by(egui::PointerButton::Primary) {
app.pick.dragging = pointer.map(|p| (p, p));
} else if let (Some((a, _)), Some(p)) = (app.pick.dragging, pointer)
&& response.dragged_by(egui::PointerButton::Primary)
{
app.pick.dragging = Some((a, p));
}
if response.drag_stopped_by(egui::PointerButton::Primary)
&& let Some((a, b)) = app.pick.dragging.take()
&& a.distance(b) > 3.0
{
app.select_box(a, b, mode(ui.input(|i| i.modifiers)));
}
}
/// What a click would pick under the pointer, worked out again when the pointer, the camera, the
/// scene or the filter change.
fn hover(app: &mut ModelApp, response: &egui::Response) {
let at = response.hover_pos().filter(|_| !response.dragged() && app.pick.dragging.is_none());
let key = (at, app.cam, app.scene_key(), app.pick.filter, app.viewport.rect);
if app.pick.hover_key == Some(key) {
return;
}
app.pick.hover_key = Some(key);
app.pick.hover_at = at;
let item = at.and_then(|p| app.pick_at(p));
app.set_hover(item);
}
/// The hovered item's name beside the pointer.
fn hover_label(app: &ModelApp, painter: &egui::Painter, t: &Tokens) {
let (Some(item), Some(at)) = (&app.pick.hover, app.pick.hover_at) else { return };
if !painter.clip_rect().contains(at) {
return;
}
let galley = painter.layout_no_wrap(item.describe(), egui::FontId::proportional(12.0), t.overlay_text);
let r = Rect::from_min_size(at + vec2(14.0, 14.0), galley.size()).expand2(vec2(5.0, 2.0));
painter.rect_filled(r, 3.0, t.overlay);
painter.galley(r.min + vec2(5.0, 2.0), galley, t.overlay_text);
}
/// The right-click menu: a set from the selection, clearing it, and the filter.
fn context_menu(app: &mut ModelApp, response: &egui::Response) {
response.context_menu(|ui| {
let has = !app.pick.selection.is_empty();
if ui
.add_enabled(has, egui::Button::new("Create set from selection…"))
.on_hover_text("model.set.create with the selection as members")
.clicked()
{
let _ = app.create_set_from_selection();
ui.close();
}
if ui.add_enabled(has, egui::Button::new("Clear selection")).on_hover_text("Esc").clicked() {
app.clear_selection();
ui.close();
}
ui.separator();
for f in Filter::ALL {
if ui.radio(app.pick.filter == f, format!("Select {}", f.label().to_lowercase())).clicked() {
app.pick.filter = f;
ui.close();
}
}
});
}
/// The selection bar, top centre: what a click picks, box selection, and what is selected.
fn selection_bar(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
let area = egui::Area::new(egui::Id::new("fc_selection_bar"))
.fixed_pos(pos2(rect.center().x, rect.top() + 10.0))
.pivot(egui::Align2::CENTER_TOP)
.constrain_to(rect)
.order(egui::Order::Middle);
let shown = area.show(ui.ctx(), |ui| {
egui::Frame::NONE.fill(t.overlay).stroke(Stroke::new(1.0, t.border)).corner_radius(6.0).inner_margin(egui::Margin::symmetric(8, 4)).show(
ui,
|ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Select").color(t.text_dim));
egui::ComboBox::from_id_salt("fc_selection_filter").selected_text(app.pick.filter.label()).width(110.0).show_ui(ui, |ui| {
for (i, f) in Filter::ALL.into_iter().enumerate() {
// Geometry, then the mesh.
if i > 0 && Filter::ALL.get(i - 1).is_some_and(|p| p.geometry() != f.geometry()) {
ui.separator();
}
if ui.selectable_label(app.pick.filter == f, f.label()).clicked() {
app.pick.filter = f;
}
}
});
let boxes = app.pick.filter.boxes();
let tip = if boxes {
"Box selection: a left drag selects the visible items inside the box (right or middle drag pans)"
} else {
"Box selection picks nodes, element faces and elements"
};
if ui.add_enabled(boxes, egui::Button::selectable(app.pick.box_mode && boxes, "Box")).on_hover_text(tip).clicked() {
app.pick.box_mode = !app.pick.box_mode;
}
if !app.pick.selection.is_empty() {
ui.separator();
let mut text = app.pick.selection.summary();
if app.pick.selection.capped() {
text.push_str(" (capped)");
}
ui.label(egui::RichText::new(text).color(t.pick_selected));
if ui.small_button("×").on_hover_text("Clear the selection (Esc)").clicked() {
app.clear_selection();
}
}
});
},
);
});
app.place("selection_bar", shown.response.rect);
}
fn navigate(app: &mut ModelApp, ui: &egui::Ui, response: &egui::Response, rect: Rect, boxing: bool) {
let (w, h) = (f64::from(rect.width()), f64::from(rect.height()));
let d = response.drag_delta();
let (dx, dy) = (f64::from(d.x), f64::from(d.y));
let shift = ui.input(|i| i.modifiers.shift);
let left = response.dragged_by(egui::PointerButton::Primary);
// In box selection a left drag draws the box.
let left = response.dragged_by(egui::PointerButton::Primary) && !boxing;
if left && !shift && app.nav == NavMode::Orbit {
app.cam.orbit(dx, dy);
} else if left && !shift && app.nav == NavMode::Zoom {
@@ -130,9 +288,18 @@ fn paint_gpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: R
let cam = app.cam;
let aspect = f64::from(rect.width()) / f64::from(rect.height()).max(1.0);
let eye = cam.eye();
let (overlay_key, overlay) = app.overlay();
if app.viewport.overlay_uploaded != Some(overlay_key)
&& let Ok(mut slot) = app.viewport.overlay_slot.lock()
{
*slot = Some(GpuScene::from_scene(&overlay, ppp));
app.viewport.overlay_uploaded = Some(overlay_key);
}
let cb = ViewportCallback {
key,
slot: app.viewport.slot.clone(),
overlay_key,
overlay_slot: app.viewport.overlay_slot.clone(),
view_proj: cam.view_proj(aspect, radius).to_f32(),
back: cam.back().to_f32(),
eye: [eye.x as f32, eye.y as f32, eye.z as f32, if cam.fov > 0.0 { 1.0 } else { 0.0 }],
@@ -149,15 +316,16 @@ fn paint_cpu(app: &mut ModelApp, ui: &egui::Ui, painter: &egui::Painter, rect: R
let (w, h) = fieldcraft_render::fit_size((rect.width() * ppp) as usize, (rect.height() * ppp) as usize);
let size = [w, h];
let cam = app.cam;
let key = app.scene().0;
let (overlay_key, overlay) = app.overlay();
let key = (app.scene().0, 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) {
let c = match fieldcraft_render::render(&app.scene.1, &cam, w, h) {
if !fresh && app.viewport.cpu_failed != Some(key.0) {
let c = match fieldcraft_render::render_layers(&app.scene.1, Some(&overlay), &cam, w, h) {
Ok(c) => c,
Err(e) => {
app.viewport.cpu = None;
app.viewport.cpu_failed = Some(key);
app.viewport.cpu_failed = Some(key.0);
app.set_status(format!("Viewport: {e}"), true);
return;
}
@@ -361,7 +529,7 @@ fn nav_bar(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
/// The screen point (points) of a world point in the viewport drawn last frame.
pub fn to_screen(app: &ModelApp, p: Vec3) -> Option<Pos2> {
let rect = app.viewport.rect?;
let radius = app.scene.1.radius.max(1.0);
let (x, y, _) = app.cam.to_screen(p, f64::from(rect.width()), f64::from(rect.height()), radius)?;
// The projection picking uses ([`ModelApp::projector`]).
let (x, y, _) = app.cam.to_screen(p, f64::from(rect.width()), f64::from(rect.height()), app.scene.1.radius)?;
Some(pos2(rect.left() + x as f32, rect.top() + y as f32))
}