forked from 1b-agent/fieldcraft-upstream
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
32c67d1988
commit
9e269a9f45
8 files changed
+1686
-59
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¶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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
-39
@@ -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
@@ -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, ¶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<_>>(), 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
@@ -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() {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user