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