diff --git a/ROADMAP.md b/ROADMAP.md index 02c8ad2..1f6b2c3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: 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; a camera parameter for screenshots, a ribbon that follows the command being run: in progress), browser build, 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: 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), browser build, command schema as the public API | started | ## Next diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs index 0217143..78c4194 100644 --- a/crates/ui-egui/src/capture.rs +++ b/crates/ui-egui/src/capture.rs @@ -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; diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index bc2ca03..2fc3c8b 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -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::(&d.text).unwrap_or(Value::Null); + if let Some(target) = selection::target(spec.id, ¶ms) { + 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); } } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 3c6b2db..fffc087 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -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, + tris: Vec, + lines: Vec, +} + +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, - tris: Vec, - lines: Vec, + scene: Layer, + overlay: Layer, } const SHADER: &str = r#" @@ -166,6 +217,18 @@ fn biased_z(p: vec3, c: vec4, 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, @location(1) n: vec3, @location(2) c: vec4) -> TOut { + var o: TOut; + let clip = u.vp * vec4(p, 1.0); + o.pos = vec4(clip.xy, biased_z(p, clip, 0.5), clip.w); + o.n = n; + o.c = c; + return o; +} + struct LOut { @builtin(position) pos: vec4, @location(0) c: vec4, @@ -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 { let Some(res) = resources.get_mut::() 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); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc85131..f3b3a9e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, + /// 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, + /// 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, Arc), + /// The names of the drawn geometry surfaces, in the order they were added to the display. + names: Vec, + /// The selection's highlight and what it was built for (kept while only the hover changes). + selected_overlay: (Option, Arc), + /// The highlight overlay (the selection's and the hover's) and what it was built for. + overlay: (Option, Arc), + /// 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, Camera, u64, selection::Filter, Option)>, + /// The model revision the selection's mesh labels were last checked at. + checked: Option, +} + /// 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, - /// 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, + /// 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, /// GPU errors reported by the device (see [`gpu::GpuErrors`]), shown in the status bar. @@ -241,8 +278,8 @@ pub struct ModelApp { pub reactions: Option, /// 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>, Vec), + /// Tessellated design bodies with their names, kept while the same design is open. + bodies: (Option>, Vec<(selection::BodyNames, fieldcraft_geometry::SurfaceMesh)>), /// The frame-independent part of the results plot (scale, contour) and what it was built for. prepared: (Option, 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, /// 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, 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 { + 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(&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 { + 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 { + 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, 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 { + // 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) { + 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) { + 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 { + 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, ¶ms) else { return Err(format!("{command} takes no selection")) }; + let filled = match target { + selection::Target::Members => selection::with_members(¶ms, &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(¶ms, 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![&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![&selection::Item::Node(1)]); + } + #[test] fn display_tolerance_follows_the_size() { let b = |max: [f64; 3]| Aabb3 { min: Vec3::ZERO, max: max.into() }; diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs new file mode 100644 index 0000000..1f26cd3 --- /dev/null +++ b/crates/ui-egui/src/selection.rs @@ -0,0 +1,1050 @@ +//! Selection by picking in the viewport. +//! +//! A click picks what is under the pointer through the selection filter: a geometry face, edge, +//! vertex or body, or a mesh node, element face or element. A box drag picks every visible node, +//! element face or element inside it. What is picked is kept in the model's persistent terms +//! ([`Item`]): geometry by name ([`GeometryRef`], body and face name), so a selection survives a +//! remesh, and the mesh by label. A [`Selection`]'s members are a set definition +//! ([`Selection::params`]), which is how command forms and `model.set.create` take it. +//! +//! Picking runs on the CPU against the display data the viewport draws (the GPU draws the same +//! triangles, so both renderers show what a pick finds): a ray through the pointer's pixel finds +//! the first face it hits with the watertight test of [`fieldcraft_render::pick`]; nodes, vertices +//! and edges are the nearest on screen within a few pixels whose own ray reaches them before any +//! face. Every choice is the nearest, ties going to the lower index, so a pick is deterministic. +//! +//! Geometry is named through the mesh's geometry tags ([`fieldcraft_model::Classification`]) once +//! the model is meshed, and through the drawn bodies' face names before. Edges and vertices have +//! names only on a mesh that carries them (block and planar meshes); a design's edges have no +//! persistent names yet. + +use std::collections::{BTreeMap, BTreeSet}; + +use fieldcraft_model::{ElementEdge, ElementFace, Entity, GeometryRef, Model, Set, check_name}; +use fieldcraft_render::pick::{DepthMap, Projector, segment_distance}; +use fieldcraft_render::{DisplayMesh, Rgb, Scene, SceneLine, ScenePoint, SceneTri, Vec3}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// Most items a selection holds; a box or a list beyond it is cut there (and says so). +pub const MAX_SELECTION: usize = 100_000; +/// How far (pixels) from a node or vertex, and from an edge, a click still picks it. +const POINT_RADIUS: f64 = 8.0; +const EDGE_RADIUS: f64 = 6.0; +/// The nearest candidates on screen whose visibility is checked with a ray of their own. +const VISIBILITY_CHECKS: usize = 16; +/// Width of a highlighted edge and size of a highlighted node (points). +const EDGE_WIDTH: f64 = 4.0; +const NODE_SIZE: f64 = 9.0; +/// Width of the outline around highlighted faces (points). +const OUTLINE_WIDTH: f64 = 2.5; + +/// What a pick selects. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Filter { + /// Geometry faces. + #[default] + Faces, + Edges, + Vertices, + Bodies, + /// Mesh nodes. + Nodes, + ElementFaces, + Elements, +} + +impl Filter { + pub const ALL: [Filter; 7] = + [Filter::Faces, Filter::Edges, Filter::Vertices, Filter::Bodies, Filter::Nodes, Filter::ElementFaces, Filter::Elements]; + + pub fn label(self) -> &'static str { + match self { + Filter::Faces => "Faces", + Filter::Edges => "Edges", + Filter::Vertices => "Vertices", + Filter::Bodies => "Bodies", + Filter::Nodes => "Nodes", + Filter::ElementFaces => "Element faces", + Filter::Elements => "Elements", + } + } + + /// Geometry (named) rather than mesh (labelled) entities. + pub fn geometry(self) -> bool { + matches!(self, Filter::Faces | Filter::Edges | Filter::Vertices | Filter::Bodies) + } + + /// Whether a box drag selects with this filter. + pub fn boxes(self) -> bool { + matches!(self, Filter::Nodes | Filter::ElementFaces | Filter::Elements) + } +} + +/// One selected entity, in the model's persistent terms. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Item { + Geometry(GeometryRef), + Node(u32), + Element(u32), + Face(ElementFace), +} + +impl Item { + /// The item in words: `face F2:top of body Body1`, `node 5`, `element 3 face 2`. + pub fn describe(&self) -> String { + match self { + Item::Geometry(g) => g.to_string(), + Item::Node(n) => format!("node {n}"), + Item::Element(e) => format!("element {e}"), + Item::Face(f) => format!("element {} face {}", f.element, f.face), + } + } + + /// The item as JSON: a geometry reference (`{"body", "face"}`), `{"node"}`, `{"element"}` or + /// `{"element", "face"}`. + pub fn to_json(&self) -> Value { + match self { + Item::Geometry(g) => json!(g), + Item::Node(n) => json!({"node": n}), + Item::Element(e) => json!({"element": e}), + Item::Face(f) => json!(f), + } + } +} + +/// How picked items change the selection. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Mode { + /// The picked items become the selection (a plain click). + #[default] + Replace, + /// Added (Shift). + Add, + Remove, + /// Added, or removed when already selected (Cmd/Ctrl). + Toggle, +} + +/// The selected items, sorted and without repeats, at most [`MAX_SELECTION`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Selection { + items: BTreeSet, + /// Items were left out to stay within [`MAX_SELECTION`]. + capped: bool, +} + +impl Selection { + pub fn items(&self) -> &BTreeSet { + &self.items + } + + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub fn capped(&self) -> bool { + self.capped + } + + pub fn clear(&mut self) { + self.items.clear(); + self.capped = false; + } + + /// Change the selection by `items` as `mode` says; anything past [`MAX_SELECTION`] is left + /// out and marks the selection capped. Returns whether the items changed. + pub fn apply(&mut self, items: impl IntoIterator, mode: Mode) -> bool { + let mut changed = false; + if mode == Mode::Replace && !self.is_empty() { + // A replacement counts as a change, even by the same items. + changed = true; + self.clear(); + } + for item in items { + changed |= match mode { + Mode::Remove => self.items.remove(&item), + Mode::Toggle if self.items.remove(&item) => true, + _ if self.items.contains(&item) => false, + _ if self.items.len() >= MAX_SELECTION => { + self.capped = true; + false + } + _ => self.items.insert(item), + }; + } + changed + } + + /// Keep only the items `keep` accepts. + pub fn retain(&mut self, keep: impl FnMut(&Item) -> bool) { + self.items.retain(keep); + } + + /// The selection as a set definition's members (`model.set.create`'s parameters without the + /// name): `geometry`, `nodes`, `elements` and `faces`, each only when it has members. + pub fn params(&self) -> Value { + let mut set = Set::default(); + for item in &self.items { + match item { + Item::Geometry(g) => set.geometry.push(g.clone()), + Item::Node(n) => set.nodes.push(*n), + Item::Element(e) => set.elements.push(*e), + Item::Face(f) => set.faces.push(*f), + } + } + // A set's JSON form leaves out its empty lists. + serde_json::to_value(set).unwrap_or_else(|_| json!({})) + } + + /// What is selected in a few words: `2 faces, 15 nodes`. + pub fn summary(&self) -> String { + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for item in &self.items { + let kind = match item { + Item::Geometry(g) => g.entity.kind(), + Item::Node(_) => "node", + Item::Element(_) => "element", + Item::Face(_) => "element face", + }; + *counts.entry(kind).or_default() += 1; + } + if counts.is_empty() { + return "nothing selected".into(); + } + let plural = |kind: &str, n: usize| match (kind, n) { + (k, 1) => k.to_string(), + ("body", _) => "bodies".into(), + ("vertex", _) => "vertices".into(), + (k, _) => format!("{k}s"), + }; + counts.iter().map(|(k, n)| format!("{n} {}", plural(k, *n))).collect::>().join(", ") + } +} + +/// The members a selection is made of: a set definition's (`geometry`, `nodes`, `elements`, +/// `faces`; element edges cannot be picked yet). +const MEMBERS: [&str; 4] = ["geometry", "nodes", "elements", "faces"]; + +/// Read set members (`geometry`, `nodes`, `elements`, `faces`, as [`Selection::params`] writes +/// them) as items, checked as `model.set.create` checks a set. Each list holds at most +/// [`MAX_SELECTION`] entries. +pub fn items_from_params(p: &Value) -> Result, String> { + let mut members = serde_json::Map::new(); + for key in MEMBERS { + match p.get(key) { + None | Some(Value::Null) => {} + Some(Value::Array(a)) if a.len() <= MAX_SELECTION => { + members.insert(key.into(), Value::Array(a.clone())); + } + Some(_) => return Err(format!("`{key}` must be a list of at most {MAX_SELECTION} entries")), + } + } + if members.is_empty() { + return Ok(Vec::new()); + } + let set: Set = serde_json::from_value(Value::Object(members)).map_err(|e| e.to_string())?; + // Empty lists are no members; anything else is checked as a set's members are. + if set.is_empty() { + return Ok(Vec::new()); + } + let set = set.normalized().map_err(|e| e.to_string())?; + let mut items: Vec = set.geometry.into_iter().map(Item::Geometry).collect(); + items.extend(set.nodes.into_iter().map(Item::Node)); + items.extend(set.elements.into_iter().map(Item::Element)); + items.extend(set.faces.into_iter().map(Item::Face)); + Ok(items) +} + +/// A drawn geometry surface's names: its body and, by face tag, its faces. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BodyNames { + pub name: String, + pub faces: Vec, +} + +impl BodyNames { + /// The reference to face `face` of this body, when it has a name. + fn face(&self, face: u32) -> Option { + GeometryRef::face(self.name.clone(), self.faces.get(face as usize)?.clone()).ok() + } +} + +/// Where the mesh's drawn primitives lie on the geometry: built once per model revision from the +/// geometry tags. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Index { + /// Per element face triangle: the geometry face it lies on (a boundary face's tag, or the + /// face a shell fills). + face_tag: Vec>, + /// Per element face triangle: the entity its element fills (a body for a solid). + region_tag: Vec>, + /// Per line element: the entity it fills (an edge). + line_tag: Vec>, + /// Element edges on geometry edges: their corner nodes ([`edge_key`]) → tag. + edge_tag: BTreeMap<(u32, u32), u32>, + /// Nodes on geometry vertices, and the vertex's tag. + vertices: Vec<(u32, u32)>, +} + +/// An edge's key, whichever way round its corner nodes come. +fn edge_key(a: u32, b: u32) -> (u32, u32) { + (a.min(b), a.max(b)) +} + +impl Index { + pub fn new(model: &Model, display: &DisplayMesh) -> Index { + let c = model.classification(); + let dimension = |tag: u32| c.entity(tag).map(|e| e.entity.dimension()); + let tags = |element: u32| c.elements().get(&element); + let region = |element: u32| tags(element).and_then(|t| t.region); + let face_tag = display + .tris + .iter() + .map(|t| tags(t.element).and_then(|g| g.faces.get(&t.face).copied().or(g.region.filter(|r| dimension(*r) == Some(2))))) + .collect(); + let region_tag = display.tris.iter().map(|t| region(t.element)).collect(); + let line_tag = display.lines.iter().map(|l| region(l.element).filter(|r| dimension(*r) == Some(1))).collect(); + let mut edge_tag = BTreeMap::new(); + for (&element, t) in c.elements() { + for (&edge, &tag) in &t.edges { + if let Some(nodes) = model.edge_nodes(ElementEdge { element, edge }) + && let (Some(&a), Some(&b)) = (nodes.first(), nodes.get(1)) + { + edge_tag.entry(edge_key(a, b)).or_insert(tag); + } + } + } + let vertices = c.nodes().iter().filter(|(_, t)| dimension(**t) == Some(0)).map(|(n, t)| (*n, *t)).collect(); + Index { face_tag, region_tag, line_tag, edge_tag, vertices } + } +} + +/// What picking looks at: the drawn display data and the names behind it. +pub struct Picker<'a> { + /// What the viewport draws: the model's display data, or a results plot's. + pub display: &'a DisplayMesh, + pub names: &'a [BodyNames], + pub model: &'a Model, + pub index: &'a Index, + /// Element faces are drawn (and picked). + pub elements: bool, + /// Geometry surfaces are drawn (and picked). + pub geometry: bool, +} + +/// The first drawn face along a ray. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Surface { + /// An element face triangle ([`DisplayMesh::tris`]) and the distance. + Element(usize, f64), + /// A geometry surface triangle ([`DisplayMesh::surface_tris`]). + Geometry(usize, f64), +} + +impl Surface { + fn t(self) -> f64 { + match self { + Surface::Element(_, t) | Surface::Geometry(_, t) => t, + } + } +} + +impl Picker<'_> { + fn entity(&self, tag: u32) -> Option<&GeometryRef> { + self.model.classification().entity(tag) + } + + /// The first drawn face a ray hits; an element face wins a tie with a geometry face. + fn surface(&self, o: Vec3, d: Vec3) -> Option { + let e = self.elements.then(|| self.display.hit_element_face(o, d)).flatten().map(|h| Surface::Element(h.index, h.t)); + let g = self.geometry.then(|| self.display.hit_surface(o, d)).flatten().map(|h| Surface::Geometry(h.index, h.t)); + match (e, g) { + (Some(e), Some(g)) => Some(if g.t() < e.t() { g } else { e }), + (e, g) => e.or(g), + } + } + + /// The drawn faces' triangles (for a depth map). + fn drawn(&self) -> impl Iterator + '_ { + let e = self.display.tris.iter().filter(|_| self.elements).map(|t| t.p); + e.chain(self.display.surface_tris.iter().filter(|_| self.geometry).map(|t| t.p)) + } + + /// How far a point may lie behind the first face along its ray and still be on it. + fn slack(&self) -> f64 { + let d = self.display.bounds.diagonal(); + if d.is_finite() { (d * 1e-4).max(1e-9) } else { 1e-9 } + } + + /// Whether `p` is in front: the ray through its pixel reaches it before any drawn face. + fn visible(&self, proj: &Projector, p: Vec3) -> bool { + let Some([x, y, _]) = proj.screen(p) else { return false }; + let (o, d) = proj.ray(x, y); + let along = (p - o).dot(d); + self.surface(o, d).is_none_or(|s| along <= s.t() + self.slack()) + } + + /// The candidate nearest on screen to `at` within `radius` that is visible: candidates are + /// (index, screen distance, the point to check); the first visible of the nearest few wins. + fn nearest_visible(&self, proj: &Projector, mut near: Vec<(usize, f64, Vec3)>, radius: f64) -> Option { + near.retain(|c| c.1 <= radius); + near.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0))); + near.into_iter().take(VISIBILITY_CHECKS).find(|c| self.visible(proj, c.2)).map(|c| c.0) + } + + /// The geometry face of an element face triangle or a geometry triangle. + fn geometry_face(&self, s: Surface) -> Option { + match s { + Surface::Element(i, _) => self.entity((*self.index.face_tag.get(i)?)?).cloned(), + Surface::Geometry(i, _) => { + let t = self.display.surface_tris.get(i)?; + self.names.get(t.body as usize)?.face(t.face) + } + } + } + + /// The body an element face triangle or a geometry triangle belongs to. + fn body(&self, s: Surface) -> Option { + let name = match s { + Surface::Element(i, _) => { + let tag = self.index.region_tag.get(i).copied().flatten().or(self.index.face_tag.get(i).copied().flatten())?; + self.entity(tag)?.body.clone() + } + Surface::Geometry(i, _) => self.names.get(self.display.surface_tris.get(i)?.body as usize)?.name.clone(), + }; + GeometryRef::body(name).ok() + } + + /// What is under pixel `at` with `filter`. + pub fn pick(&self, proj: &Projector, at: [f64; 2], filter: Filter) -> Option { + if !(at[0].is_finite() && at[1].is_finite()) { + return None; + } + let (o, d) = proj.ray(at[0], at[1]); + let screen = |p: Vec3| proj.screen(p).map(|s| ((s[0] - at[0]).powi(2) + (s[1] - at[1]).powi(2)).sqrt()); + match filter { + Filter::Faces => self.geometry_face(self.surface(o, d)?).map(Item::Geometry), + Filter::Bodies => self.body(self.surface(o, d)?).map(Item::Geometry), + Filter::ElementFaces => match self.surface(o, d)? { + Surface::Element(i, _) => { + let t = self.display.tris.get(i)?; + Some(Item::Face(ElementFace { element: t.element, face: t.seen_face(d) })) + } + Surface::Geometry(..) => None, + }, + Filter::Elements => { + let lines = self.display.lines.iter().enumerate().filter_map(|(i, l)| { + let (a, b) = (proj.screen(l.a)?, proj.screen(l.b)?); + let (distance, k) = segment_distance(at, [a[0], a[1]], [b[0], b[1]]); + Some((i, distance, l.a + (l.b - l.a) * k)) + }); + if let Some(l) = self.nearest_visible(proj, lines.collect(), EDGE_RADIUS).and_then(|i| self.display.lines.get(i)) { + return Some(Item::Element(l.element)); + } + match self.surface(o, d)? { + Surface::Element(i, _) => self.display.tris.get(i).map(|t| Item::Element(t.element)), + Surface::Geometry(..) => None, + } + } + Filter::Nodes => { + let near = self.display.nodes.iter().enumerate().filter_map(|(i, n)| Some((i, screen(n.p)?, n.p))).collect(); + self.nearest_visible(proj, near, POINT_RADIUS).and_then(|i| self.display.nodes.get(i)).map(|n| Item::Node(n.label)) + } + Filter::Vertices => { + let tagged = &self.index.vertices; + let near = tagged.iter().enumerate().filter_map(|(i, (n, _))| { + let p = self.display.node_position(*n)?; + Some((i, screen(p)?, p)) + }); + let (_, tag) = tagged.get(self.nearest_visible(proj, near.collect(), POINT_RADIUS)?)?; + self.entity(*tag).cloned().map(Item::Geometry) + } + Filter::Edges => { + // Element edges and line elements on geometry edges. + let mut tagged: Vec<(Vec3, Vec3, u32)> = self + .display + .edges + .iter() + .filter_map(|e| Some((e.p[0], e.p[1], *self.index.edge_tag.get(&edge_key(e.nodes[0], e.nodes[1]))?))) + .collect(); + tagged.extend(self.display.lines.iter().zip(&self.index.line_tag).filter_map(|(l, t)| Some((l.a, l.b, (*t)?)))); + let near = tagged.iter().enumerate().filter_map(|(i, (a, b, _))| { + let (sa, sb) = (proj.screen(*a)?, proj.screen(*b)?); + // The edge's point nearest the pointer on screen is the one checked. + let (distance, k) = segment_distance(at, [sa[0], sa[1]], [sb[0], sb[1]]); + Some((i, distance, *a + (*b - *a) * k)) + }); + let (_, _, tag) = tagged.get(self.nearest_visible(proj, near.collect(), EDGE_RADIUS)?)?; + self.entity(*tag).cloned().map(Item::Geometry) + } + } + } + + /// The visible nodes, element faces or elements (with `filter`) inside the screen box + /// `rect` ([x0, y0, x1, y1], any two opposite corners), sorted; other filters select + /// nothing. ([`Selection::apply`] keeps at most [`MAX_SELECTION`].) + pub fn pick_box(&self, proj: &Projector, rect: [f64; 4], filter: Filter) -> Vec { + if !filter.boxes() || !rect.iter().all(|v| v.is_finite()) { + return Vec::new(); + } + let (x0, x1, y0, y1) = (rect[0].min(rect[2]), rect[0].max(rect[2]), rect[1].min(rect[3]), rect[1].max(rect[3])); + let inside = |p: Vec3| proj.screen(p).is_some_and(|s| (x0..=x1).contains(&s[0]) && (y0..=y1).contains(&s[1])); + let Some(depth) = DepthMap::new(proj, self.drawn()) else { return Vec::new() }; + let mut out = BTreeSet::new(); + let mut add = |item: Item| { + out.insert(item); + }; + match filter { + Filter::Nodes => { + for n in self.display.nodes.iter().filter(|n| inside(n.p) && depth.visible(n.p)) { + add(Item::Node(n.label)); + } + } + Filter::ElementFaces | Filter::Elements => { + let faces = filter == Filter::ElementFaces; + for t in self.display.tris.iter().filter(|_| self.elements) { + let centre = (t.p[0] + t.p[1] + t.p[2]) * (1.0 / 3.0); + if t.p.iter().all(|p| inside(*p)) && depth.visible(centre) { + add(if faces { + let dir = proj.screen(centre).map_or(Vec3::ZERO, |s| proj.ray(s[0], s[1]).1); + Item::Face(ElementFace { element: t.element, face: t.seen_face(dir) }) + } else { + Item::Element(t.element) + }); + } + } + if !faces { + for l in &self.display.lines { + if inside(l.a) && inside(l.b) && depth.visible((l.a + l.b) * 0.5) { + add(Item::Element(l.element)); + } + } + } + } + _ => {} + } + out.into_iter().collect() + } + + /// The selection and the hovered item drawn over the scene: their faces in `selected` (or + /// `hover`) colour, their edges as wide lines and their nodes as large points. + pub fn overlay(&self, selection: &Selection, hover: Option<&Item>, selected: Rgb, hover_color: Rgb) -> Scene { + let mut s = Scene::default(); + self.mark(&Marks::of(self, selection.items().iter()), selected, &mut s); + if let Some(h) = hover { + self.mark(&Marks::of(self, std::iter::once(h)), hover_color, &mut s); + } + s + } + + fn mark(&self, m: &Marks, color: Rgb, s: &mut Scene) { + if m.is_empty() { + return; + } + let first = s.tris.len(); + let flag = |flags: &[bool], tag: Option| tag.is_some_and(|t| flags.get(t as usize).copied().unwrap_or(false)); + let on = |tag: Option| flag(&m.tags, tag); + let in_body = |tag: Option| flag(&m.in_bodies, tag); + if self.elements { + for (i, t) in self.display.tris.iter().enumerate() { + let (face, region) = (self.index.face_tag.get(i).copied().flatten(), self.index.region_tag.get(i).copied().flatten()); + // A shell's triangle draws both of its sides. + let shell_back = t.kind.dimension() == 2 && m.faces.contains(&ElementFace { element: t.element, face: 2 }); + if m.elements.contains(&t.element) + || m.faces.contains(&ElementFace { element: t.element, face: t.face }) + || shell_back + || on(face) + || in_body(region) + || in_body(face) + { + s.tris.push(SceneTri { p: t.p, normal: t.normal, color }); + } + } + for (l, tag) in self.display.lines.iter().zip(&self.index.line_tag) { + if m.elements.contains(&l.element) || on(*tag) { + s.lines.push(SceneLine { a: l.a, b: l.b, color, width: EDGE_WIDTH }); + } + } + } + if self.geometry && !m.refs.is_empty() { + // Which faces of each drawn body are marked, worked out once per face. + let marked: Vec> = self + .names + .iter() + .map(|body| { + (0..body.faces.len()) + .map(|f| { + let face = body.face(u32::try_from(f).unwrap_or(u32::MAX)); + m.refs.iter().any(|r| match &r.entity { + Entity::Body => r.body == body.name, + _ => face.as_ref().is_some_and(|g| r.selects(g)), + }) + }) + .collect() + }) + .collect(); + let whole = |b: usize| self.names.get(b).is_some_and(|n| m.bodies.contains(&n.name)); + for t in &self.display.surface_tris { + let b = t.body as usize; + if whole(b) || marked.get(b).and_then(|f| f.get(t.face as usize)).copied().unwrap_or(false) { + s.tris.push(SceneTri { p: t.p, normal: t.normal, color }); + } + } + } + outline(s, first, color); + if m.edges { + for e in &self.display.edges { + if on(self.index.edge_tag.get(&edge_key(e.nodes[0], e.nodes[1])).copied()) { + s.lines.push(SceneLine { a: e.p[0], b: e.p[1], color, width: EDGE_WIDTH }); + } + } + } + let vertices = self.index.vertices.iter().filter(|_| m.vertices).filter(|(_, t)| on(Some(*t))).map(|(n, _)| *n); + for n in m.nodes.iter().copied().chain(vertices) { + if let Some(p) = self.display.node_position(n) { + s.points.push(ScenePoint { p, color, size: NODE_SIZE }); + } + } + } +} + +/// The outline of the triangles `s.tris[first..]`: their sides no other of them shares, drawn as +/// lines so a highlight reads at a glance. +fn outline(s: &mut Scene, first: usize, color: Rgb) { + let key = |p: Vec3| [p.x, p.y, p.z].map(f64::to_bits); + let mut sides: Vec<([[u64; 3]; 2], Vec3, Vec3)> = Vec::new(); + for t in s.tris.get(first..).unwrap_or(&[]) { + for (a, b) in [(t.p[0], t.p[1]), (t.p[1], t.p[2]), (t.p[2], t.p[0])] { + let (ka, kb) = (key(a), key(b)); + sides.push((if ka < kb { [ka, kb] } else { [kb, ka] }, a, b)); + } + } + sides.sort_by_key(|x| x.0); + for run in sides.chunk_by(|x, y| x.0 == y.0) { + if let [(_, a, b)] = run { + s.lines.push(SceneLine { a: *a, b: *b, color, width: OUTLINE_WIDTH }); + } + } +} + +/// What a group of items marks, for drawing. +#[derive(Default)] +struct Marks { + /// Geometry tags selected (by index). + tags: Vec, + /// Geometry tags of an entity of a body selected whole. + in_bodies: Vec, + /// Bodies selected whole. + bodies: BTreeSet, + /// The geometry references (for the drawn geometry surfaces). + refs: Vec, + /// Whether an edge or a vertex is among the references. + edges: bool, + vertices: bool, + nodes: BTreeSet, + elements: BTreeSet, + faces: BTreeSet, +} + +impl Marks { + fn of<'a>(p: &Picker, items: impl Iterator) -> Marks { + let c = p.model.classification(); + let n = c.entities().len(); + let mut m = Marks { tags: vec![false; n], in_bodies: vec![false; n], ..Marks::default() }; + for item in items { + match item { + Item::Geometry(g) => { + match g.entity { + Entity::Body => { + m.bodies.insert(g.body.clone()); + } + Entity::Edge(_) => m.edges = true, + Entity::Vertex(_) => m.vertices = true, + Entity::Face(_) => {} + } + for t in c.tags_selected_by(g) { + if let Some(slot) = m.tags.get_mut(t as usize) { + *slot = true; + } + } + m.refs.push(g.clone()); + } + Item::Node(n) => { + m.nodes.insert(*n); + } + Item::Element(e) => { + m.elements.insert(*e); + } + Item::Face(f) => { + m.faces.insert(*f); + } + } + } + if !m.bodies.is_empty() { + for (slot, e) in m.in_bodies.iter_mut().zip(c.entities()) { + *slot = m.bodies.contains(&e.body); + } + } + m + } + + fn is_empty(&self) -> bool { + self.refs.is_empty() && self.nodes.is_empty() && self.elements.is_empty() && self.faces.is_empty() + } +} + +/// Where a command form puts the selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Target { + /// The members themselves (a set's definition). + Members, + /// A set made from the selection, named in `target: {"set"}` (boundary conditions, forces). + TargetSet, + /// A set made from the selection, named in `set` (sections, couplings, surface and body + /// loads). + Set, +} + +/// Where command `id`'s form takes the selection, if it does. `params` (the form's current +/// parameters) tells a force (target) from a pressure (set). +pub fn target(id: &str, params: &Value) -> Option { + match id { + "model.set.create" | "model.set.edit" => Some(Target::Members), + "model.boundary_condition.create" | "model.boundary_condition.edit" => Some(Target::TargetSet), + "model.section.create" | "model.section.edit" | "model.coupling.create" | "model.coupling.edit" => Some(Target::Set), + "model.load.create" | "model.load.edit" => { + let kind = params.get("type").and_then(Value::as_str); + Some(if matches!(kind, None | Some("force" | "moment")) { Target::TargetSet } else { Target::Set }) + } + _ => None, + } +} + +/// `params` with the selection's members put in (replacing any members it had). +pub fn with_members(params: &Value, selection: &Selection) -> Value { + let mut out = params.as_object().cloned().unwrap_or_default(); + for key in ["geometry", "nodes", "elements", "faces", "edges"] { + out.remove(key); + } + if let Value::Object(members) = selection.params() { + out.extend(members); + } + Value::Object(out) +} + +/// `params` with the set `set` as the target. +pub fn with_set(params: &Value, target: Target, set: &str) -> Value { + let mut out = params.as_object().cloned().unwrap_or_default(); + match target { + Target::TargetSet => { + out.insert("target".into(), json!({"set": set})); + } + Target::Set => { + out.insert("set".into(), json!(set)); + } + Target::Members => {} + } + Value::Object(out) +} + +/// A set name not taken in `model`: ``, then `-2`, `-3`, …; `Set` when +/// `base` is not a valid name. +pub fn fresh_set_name(model: &Model, base: &str) -> String { + let base = if check_name("set", base).is_ok() { base } else { "Set" }; + std::iter::once(base.to_string()) + .chain((2..100_000).map(|k| format!("{base}-{k}"))) + .find(|n| check_name("set", n).is_ok() && model.set(n).is_none()) + .unwrap_or_else(|| "Set".into()) +} + +#[cfg(test)] +mod tests { + use fieldcraft_model::ElementKind; + use fieldcraft_render::{Camera, StandardView}; + + use super::*; + + /// `nx × ny × nz` unit hex8 cells, the faces at x = nx tagged as face `end` of body `Bar`, the + /// solids as the body, the edge along x = nx, z = nz as edge `top` and the corner node at + /// (nx, 0, 0) as vertex `v`. + fn bar(nx: u32, ny: u32, nz: u32) -> Model { + let mut m = Model::default(); + let id = |i: u32, j: u32, k: u32| 1 + i + j * (nx + 1) + k * (nx + 1) * (ny + 1); + for k in 0..=nz { + for j in 0..=ny { + for i in 0..=nx { + m.set_node(id(i, j, k), [i as f64, j as f64, k as f64]).unwrap(); + } + } + } + let body = m.geometry_tag(&GeometryRef::body("Bar").unwrap()).unwrap(); + let end = m.geometry_tag(&GeometryRef::face("Bar", "end").unwrap()).unwrap(); + let top = m.geometry_tag(&GeometryRef::new("Bar", Entity::Edge("top".into())).unwrap()).unwrap(); + let v = m.geometry_tag(&GeometryRef::new("Bar", Entity::Vertex("v".into())).unwrap()).unwrap(); + let mut label = 1; + for k in 0..nz { + for j in 0..ny { + for i in 0..nx { + let n = vec![ + id(i, j, k), + id(i + 1, j, k), + id(i + 1, j + 1, k), + id(i, j + 1, k), + id(i, j, k + 1), + id(i + 1, j, k + 1), + id(i + 1, j + 1, k + 1), + id(i, j + 1, k + 1), + ]; + m.set_element(label, ElementKind::Hex8, n).unwrap(); + m.tag_element(label, body).unwrap(); + if i + 1 == nx { + // Hex8 face 4: corners 1, 2, 6, 5 (x = i + 1). + m.tag_element_face(label, 4, end).unwrap(); + if k + 1 == nz { + // Hex8 edge 6 joins corners 5 and 6 (x = i + 1, z = k + 1). + m.tag_element_edge(label, 6, top).unwrap(); + } + } + label += 1; + } + } + } + m.tag_node(id(nx, 0, 0), v).unwrap(); + m + } + + struct Scene3 { + model: Model, + display: DisplayMesh, + index: Index, + } + + impl Scene3 { + fn new(model: Model) -> Scene3 { + let display = DisplayMesh::from_model(&model); + let index = Index::new(&model, &display); + Scene3 { model, display, index } + } + fn picker(&self) -> Picker<'_> { + Picker { display: &self.display, names: &[], model: &self.model, index: &self.index, elements: true, geometry: false } + } + } + + /// A front-on view of the bar's end (looking along −x from +x), 400 × 300 pixels. + fn end_view(d: &DisplayMesh) -> Projector { + let mut cam = Camera::default(); + cam.set_view(StandardView::Right); + cam.fit(&d.bounds); + Projector::new(&cam, 400.0, 300.0, d.bounds.diagonal()) + } + + fn pixel(p: &Projector, at: Vec3) -> [f64; 2] { + let s = p.screen(at).unwrap(); + [s[0], s[1]] + } + + #[test] + fn a_click_on_the_end_picks_its_face_body_element_face_element_and_node() { + let s = Scene3::new(bar(4, 2, 2)); + let p = s.picker(); + let proj = end_view(&s.display); + // The centre of the end cell at y ∈ [0, 1], z ∈ [1, 2]: element 1 + 3 + 0·4 + 1·8 = 12. + let at = pixel(&proj, Vec3::new(4.0, 0.5, 1.5)); + assert_eq!(p.pick(&proj, at, Filter::Faces), Some(Item::Geometry(GeometryRef::face("Bar", "end").unwrap()))); + assert_eq!(p.pick(&proj, at, Filter::Bodies), Some(Item::Geometry(GeometryRef::body("Bar").unwrap()))); + assert_eq!(p.pick(&proj, at, Filter::ElementFaces), Some(Item::Face(ElementFace { element: 12, face: 4 }))); + assert_eq!(p.pick(&proj, at, Filter::Elements), Some(Item::Element(12))); + // Near the corner (4, 1, 2), its node; far from every node, nothing. + let corner = pixel(&proj, Vec3::new(4.0, 1.0, 2.0)); + let n = p.pick(&proj, [corner[0] + 3.0, corner[1] - 2.0], Filter::Nodes); + assert_eq!(n, Some(Item::Node(1 + 4 + 5 + 2 * 15))); + assert_eq!(p.pick(&proj, at, Filter::Nodes), None); + // The tagged vertex and edge. + let v = pixel(&proj, Vec3::new(4.0, 0.0, 0.0)); + assert_eq!(p.pick(&proj, v, Filter::Vertices), Some(Item::Geometry(GeometryRef::new("Bar", Entity::Vertex("v".into())).unwrap()))); + let e = pixel(&proj, Vec3::new(4.0, 1.5, 2.0)); + assert_eq!( + p.pick(&proj, [e[0], e[1] + 2.0], Filter::Edges), + Some(Item::Geometry(GeometryRef::new("Bar", Entity::Edge("top".into())).unwrap())) + ); + // Off the model, and hostile pixels. + assert_eq!(p.pick(&proj, [2.0, 2.0], Filter::Faces), None); + assert_eq!(p.pick(&proj, [f64::NAN, 1.0], Filter::Nodes), None); + assert_eq!(p.pick(&proj, [1e300, -1e300], Filter::Edges), None); + } + + /// Nodes behind the end face are not picked through it, even right under the pointer. + #[test] + fn hidden_nodes_are_not_picked() { + let s = Scene3::new(bar(4, 2, 2)); + let p = s.picker(); + let mut cam = Camera::default(); + cam.set_view(StandardView::Right); + cam.fit(&s.display.bounds); + // Orthographic along −x: the node (3, 1, 1) projects onto (4, 1, 1), which is in front. + let proj = Projector::new(&cam, 400.0, 300.0, s.display.bounds.diagonal()); + let at = pixel(&proj, Vec3::new(4.0, 1.0, 1.0)); + let front = 1 + 4 + 5 + 15; + assert_eq!(p.pick(&proj, at, Filter::Nodes), Some(Item::Node(front))); + let items = p.pick_box(&proj, [0.0, 0.0, 400.0, 300.0], Filter::Nodes); + assert_eq!(items.len(), 3 * 3, "the end's nodes only: {items:?}"); + assert!(items.contains(&Item::Node(front))); + // Element faces and elements in the box: the end's eight cells. + let faces = p.pick_box(&proj, [0.0, 0.0, 400.0, 300.0], Filter::ElementFaces); + assert_eq!(faces.len(), 4); + assert!(faces.iter().all(|f| matches!(f, Item::Face(ElementFace { face: 4, .. })))); + let elements = p.pick_box(&proj, [400.0, 300.0, 0.0, 0.0], Filter::Elements); + assert_eq!(elements, vec![Item::Element(4), Item::Element(8), Item::Element(12), Item::Element(16)]); + // Filters without boxes, empty and hostile boxes. + assert_eq!(p.pick_box(&proj, [0.0, 0.0, 400.0, 300.0], Filter::Faces), vec![]); + assert_eq!(p.pick_box(&proj, [5.0, 5.0, 6.0, 6.0], Filter::Nodes), vec![]); + assert_eq!(p.pick_box(&proj, [f64::NAN, 0.0, 1.0, 1.0], Filter::Nodes), vec![]); + } + + #[test] + fn an_empty_scene_picks_nothing() { + let s = Scene3::new(Model::default()); + let p = s.picker(); + let proj = Projector::new(&Camera::default(), 400.0, 300.0, 1.0); + for f in Filter::ALL { + assert_eq!(p.pick(&proj, [200.0, 150.0], f), None); + assert_eq!(p.pick_box(&proj, [0.0, 0.0, 400.0, 300.0], f), vec![]); + } + assert!(p.overlay(&Selection::default(), None, Rgb(1, 2, 3), Rgb(4, 5, 6)).tris.is_empty()); + } + + /// Geometry surfaces (no mesh) are named by their bodies' face names. + #[test] + fn drawn_geometry_names_its_faces() { + let mut display = DisplayMesh::default(); + let square = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]; + display.add_surface(&square, &[[0, 1, 2], [0, 2, 3]], &[0, 1]); + let names = [BodyNames { name: "Plate".into(), faces: vec!["F1:a".into(), "F1:b".into()] }]; + let model = Model::default(); + let index = Index::new(&model, &display); + let p = Picker { display: &display, names: &names, model: &model, index: &index, elements: false, geometry: true }; + let mut cam = Camera::default(); + cam.set_view(StandardView::Top); + cam.fit(&display.bounds); + let proj = Projector::new(&cam, 400.0, 300.0, 2.0); + let at = pixel(&proj, Vec3::new(0.8, 0.2, 0.0)); + let face = GeometryRef::face("Plate", "F1:a").unwrap(); + assert_eq!(p.pick(&proj, at, Filter::Faces), Some(Item::Geometry(face.clone()))); + assert_eq!(p.pick(&proj, at, Filter::Bodies), Some(Item::Geometry(GeometryRef::body("Plate").unwrap()))); + assert_eq!(p.pick(&proj, at, Filter::ElementFaces), None); + // The face is highlighted: its one triangle in the selection colour. + let mut sel = Selection::default(); + sel.apply([Item::Geometry(face)], Mode::Replace); + let o = p.overlay(&sel, None, Rgb(9, 9, 9), Rgb(0, 0, 0)); + assert_eq!(o.tris.len(), 1); + assert_eq!(o.tris[0].color, Rgb(9, 9, 9)); + } + + #[test] + fn the_overlay_marks_what_is_selected_and_hovered() { + let s = Scene3::new(bar(4, 2, 2)); + let p = s.picker(); + let (sel_c, hov_c) = (Rgb(200, 0, 0), Rgb(0, 200, 0)); + let mut sel = Selection::default(); + sel.apply([Item::Geometry(GeometryRef::face("Bar", "end").unwrap())], Mode::Replace); + let o = p.overlay(&sel, None, sel_c, hov_c); + // Four quads at the end, two triangles each. + assert_eq!(o.tris.len(), 8); + assert!(o.tris.iter().all(|t| t.p.iter().all(|q| q.x == 4.0))); + // Hovering an element adds its skin triangles in the hover colour, drawn after. + let o = p.overlay(&sel, Some(&Item::Element(1)), sel_c, hov_c); + assert_eq!(o.tris.iter().filter(|t| t.color == hov_c).count(), 6); + // Nodes, the edge and the vertex. + sel.apply( + [ + Item::Node(1), + Item::Geometry(GeometryRef::new("Bar", Entity::Edge("top".into())).unwrap()), + Item::Geometry(GeometryRef::new("Bar", Entity::Vertex("v".into())).unwrap()), + ], + Mode::Replace, + ); + let o = p.overlay(&sel, None, sel_c, hov_c); + assert_eq!((o.tris.len(), o.lines.len(), o.points.len()), (0, 2, 2)); + // A whole body: every skin triangle. + sel.apply([Item::Geometry(GeometryRef::body("Bar").unwrap())], Mode::Replace); + assert_eq!(p.overlay(&sel, None, sel_c, hov_c).tris.len(), s.display.tris.len()); + } + + #[test] + fn selections_add_remove_toggle_and_cap() { + let mut s = Selection::default(); + s.apply([Item::Node(3), Item::Node(1), Item::Node(3)], Mode::Replace); + assert_eq!(s.len(), 2); + s.apply([Item::Element(7)], Mode::Add); + s.apply([Item::Node(1), Item::Node(9)], Mode::Toggle); + assert_eq!(s.items().iter().cloned().collect::>(), vec![Item::Node(3), Item::Node(9), Item::Element(7)]); + s.apply([Item::Node(3)], Mode::Remove); + assert_eq!(s.summary(), "1 element, 1 node"); + s.apply((1..=MAX_SELECTION as u32 + 10).map(Item::Node), Mode::Replace); + assert!(s.len() == MAX_SELECTION && s.capped()); + s.clear(); + assert!(s.is_empty() && !s.capped() && s.summary() == "nothing selected"); + } + + /// The selection becomes a set's members, and reads back from them. + #[test] + fn the_selection_maps_to_set_members_and_command_parameters() { + let mut s = Selection::default(); + let face = GeometryRef::face("Body1", "F2:top").unwrap(); + s.apply([Item::Geometry(face.clone()), Item::Node(5), Item::Element(2), Item::Face(ElementFace { element: 2, face: 3 })], Mode::Replace); + let p = s.params(); + assert_eq!( + p, + json!({"geometry": [{"body": "Body1", "face": "F2:top"}], "nodes": [5], "elements": [2], "faces": [{"element": 2, "face": 3}]}) + ); + assert_eq!(items_from_params(&p).unwrap().into_iter().collect::>(), *s.items()); + assert_eq!(s.summary(), "1 element, 1 element face, 1 face, 1 node"); + // A set's form: the members replace the old ones, the name stays. + let form = with_members(&json!({"name": "end", "nodes": [1, 2], "edges": [{"element": 1, "edge": 1}]}), &s); + assert_eq!(form["name"], "end"); + assert_eq!(form["nodes"], json!([5])); + assert!(form.get("edges").is_none()); + // Definitions that take a set. + assert_eq!(target("model.set.create", &json!({})), Some(Target::Members)); + assert_eq!(target("model.boundary_condition.create", &json!({})), Some(Target::TargetSet)); + assert_eq!(target("model.load.create", &json!({"type": "force"})), Some(Target::TargetSet)); + assert_eq!(target("model.load.create", &json!({"type": "pressure"})), Some(Target::Set)); + assert_eq!(target("model.section.edit", &json!({})), Some(Target::Set)); + assert_eq!(target("model.coupling.create", &json!({})), Some(Target::Set)); + assert_eq!(target("mesh.node.add", &json!({})), None); + assert_eq!(with_set(&json!({"name": "fix"}), Target::TargetSet, "fix-set"), json!({"name": "fix", "target": {"set": "fix-set"}})); + assert_eq!(with_set(&json!({"name": "web"}), Target::Set, "web-set"), json!({"name": "web", "set": "web-set"})); + let mut m = Model::default(); + assert_eq!(fresh_set_name(&m, "fix-set"), "fix-set"); + m.add_set("fix-set", fieldcraft_model::Set { nodes: vec![1], ..Default::default() }).unwrap(); + assert_eq!(fresh_set_name(&m, "fix-set"), "fix-set-2"); + assert_eq!(fresh_set_name(&m, " \u{7}"), "Set"); + assert_eq!(fresh_set_name(&m, "my clamp-set"), "Set"); + } + + #[test] + fn hostile_members_are_refused() { + for bad in [ + json!({"nodes": [0]}), + json!({"nodes": "x"}), + json!({"nodes": [-1]}), + json!({"elements": [1.5]}), + json!({"faces": [{"element": 1, "face": 0}]}), + json!({"faces": [{"element": 0, "face": 1}]}), + json!({"geometry": [{"body": ""}]}), + json!({"geometry": [{"body": "B", "face": "f", "edge": "e"}]}), + json!({"nodes": vec![1; MAX_SELECTION + 1]}), + ] { + assert!(items_from_params(&bad).is_err(), "{bad}"); + } + assert_eq!(items_from_params(&json!({})).unwrap(), vec![]); + assert_eq!(items_from_params(&json!(null)).unwrap(), vec![]); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 20240d2..ca6df42 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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() { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index fe884d2..dbcc5ab 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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 { 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)) }