Viewport picking: a BVH per body mesh and a GPU pick buffer for visible edges (#1776)

Thanks @oursland.
This commit is contained in:
Jackson Oursland authored and GitHub committed 2026-10-16 14:26:38 -04:00
1 parent a9413fe385
commit 8907da5e86
14 files changed
+1707 -50

No files matched your search

+1 -1
View File
@@ -136,7 +136,7 @@ pub(crate) fn offscreen_backends() -> wgpu::Backends {
/// A device of its own for an offscreen render or check (`None` without a GPU adapter), on
/// [`offscreen_backends`].
#[cfg(all(test, not(target_arch = "wasm32")))]
fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
pub(crate) fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
desc.backends = offscreen_backends();
let instance = wgpu::Instance::new(desc);
+2
View File
@@ -112,6 +112,8 @@ mod fillet_spline_handle_tests;
pub(crate) mod navigation;
pub mod palette;
pub mod params_dialog;
pub(crate) mod pick_buffer;
pub(crate) mod pick_index;
pub mod prefs;
#[cfg(test)]
mod preselect_tests;
+259
View File
@@ -0,0 +1,259 @@
//! The GPU pick buffer in the viewport ([`solidcraft_gpu::pick`]): which model edges are seen at
//! each pixel, so a pick over a large model tests only the edges near the cursor that are not
//! hidden, instead of every edge the cursor's ray passes near.
//!
//! The pass is drawn for a view once it holds still (the same view two frames running), and a
//! tile around the cursor is read back; the tile arrives a frame or two later, and until it
//! does (and while the view moves) the picker answers from the CPU indexes
//! ([`crate::pick_index`]). The tile only narrows the candidates: the picker's exact tests
//! still decide. Not used for Select Other (every target along the ray), in wireframe (nothing
//! hides an edge there), without a GPU, or for a model past [`MAX_TRIANGLES`].
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use egui::Pos2;
use solidcraft_engine::doc::ModelState;
use solidcraft_gpu::pick::{PickBuffer, PickOut, PickScene, PickView};
use crate::SolveApp;
use crate::viewport::Proj;
/// Most surface triangles the pass draws (beyond them the CPU indexes answer alone).
pub const MAX_TRIANGLES: usize = 6_000_000;
/// How near the cursor an edge's pixels are looked for (points): vertices pick within 7, edges 6.
const WINDOW: f32 = 8.0;
/// Frames kept coming after a tile is asked for, so its read-back lands.
const FRAMES: u8 = 4;
/// The pass's scene for one model state: the ids it draws stand for (body, edge).
struct Scene {
state: Arc<ModelState>,
key: u64,
scene: Arc<PickScene>,
/// Per id − 1: (body index in the state, edge index).
edges: Vec<(u32, u32)>,
/// One-point edges (no line to draw): always candidates.
points: Vec<(u32, u32)>,
}
#[derive(Default)]
pub(crate) struct State {
scene: Mutex<Option<Arc<Scene>>>,
out: PickOut,
/// A pick found no tile for its cursor: the view it was in and the pixel.
want: Mutex<Option<(u64, [u32; 2])>>,
/// The view of the last frame (the pass is drawn for a view that held still).
last_view: u64,
/// Frames still to request so read-backs land.
frames: u8,
}
/// The edges a tile names near the cursor, per body index of the world state.
pub(crate) struct NearEdges {
by_body: HashMap<usize, Vec<usize>>,
}
impl NearEdges {
/// Every segment of the named edges of body `index` of `mesh`, as (edge, first point), in
/// edge order.
pub(crate) fn segments(&self, index: usize, mesh: &solidcraft_engine::geom::Mesh) -> Vec<(usize, usize)> {
let mut v = Vec::new();
for &ei in self.by_body.get(&index).map_or(&[][..], |e| &e[..]) {
let n = mesh.edges.get(ei).map_or(0, |e| e.len().max(2) - 1);
v.extend((0..n).map(|si| (ei, si)));
}
v
}
}
/// What the pass's scene depends on: the world state, and which bodies show.
fn model_key(app: &SolveApp, st: &Arc<ModelState>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
(Arc::as_ptr(st) as usize).hash(&mut h);
app.session.revision.hash(&mut h);
app.session.visibility.hidden_bodies.hash(&mut h);
app.session.visibility.hidden_occurrences.hash(&mut h);
h.finish()
}
/// The view's pixels: the viewport's size in physical pixels.
fn view_size(app: &SolveApp, proj: &Proj) -> [u32; 2] {
let ppp = app.viewport.pixels_per_point();
[(proj.rect.width() * ppp).round().max(0.0) as u32, (proj.rect.height() * ppp).round().max(0.0) as u32]
}
/// How far edges are lifted toward the eye: the picker's slack for an edge behind a face.
fn lift(app: &SolveApp) -> f64 {
crate::viewport::scene_radius(app) * 0.02
}
/// Everything the drawn ids depend on.
fn view_key(app: &SolveApp, proj: &Proj, model: u64) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
model.hash(&mut h);
for col in proj.vp.to_f32() {
for x in col {
x.to_bits().hash(&mut h);
}
}
view_size(app, proj).hash(&mut h);
lift(app).to_bits().hash(&mut h);
h.finish()
}
fn usable(app: &SolveApp) -> bool {
app.viewport.gpu.is_some() && app.ui.visual_style != 2
}
/// The edges seen within reach of `pos`, when a tile of this view is in; otherwise `None`, and
/// a tile around `pos` is asked for.
pub(crate) fn edges_near(app: &SolveApp, proj: &Proj, pos: Pos2, st: &Arc<ModelState>) -> Option<NearEdges> {
if !usable(app) {
return None;
}
let model = model_key(app, st);
let scene = app.viewport.pick_buffer.scene.lock().ok()?.clone().filter(|s| s.key == model && Arc::ptr_eq(&s.state, st));
let key = view_key(app, proj, model);
let size = view_size(app, proj);
let ppp = app.viewport.pixels_per_point();
let (x, y) = ((pos.x - proj.rect.left()) * ppp, (pos.y - proj.rect.top()) * ppp);
if !(x >= 0.0 && y >= 0.0 && x < size[0] as f32 && y < size[1] as f32) {
return None;
}
let (x, y) = (x as i64, y as i64);
let r = (WINDOW * ppp).ceil() as i64;
let window = [x - r, y - r, x + r, y + r];
let ids = scene.as_ref().and_then(|_| app.viewport.pick_buffer.out.lock().ok()?.tile(key, size, window).map(|t| t.ids_in(window)));
let (Some(scene), Some(ids)) = (scene, ids) else {
if let Ok(mut w) = app.viewport.pick_buffer.want.lock() {
*w = Some((key, [x as u32, y as u32]));
}
return None;
};
let mut by_body: HashMap<usize, Vec<usize>> = HashMap::new();
for (b, e) in ids.iter().filter_map(|id| scene.edges.get((*id as usize).checked_sub(1)?)).chain(&scene.points) {
by_body.entry(*b as usize).or_default().push(*e as usize);
}
for v in by_body.values_mut() {
v.sort_unstable();
v.dedup();
}
Some(NearEdges { by_body })
}
/// The pass's scene for the world state: visible bodies' triangles, and their pickable edges.
/// `None` past [`MAX_TRIANGLES`] or without edges.
fn scene(app: &SolveApp, st: &Arc<ModelState>, key: u64) -> Option<Arc<Scene>> {
let f = |p: solidcraft_engine::geom::Vec3| [p.x as f32, p.y as f32, p.z as f32];
let mut scene = PickScene::default();
let (mut edges, mut points, mut triangles) = (Vec::new(), Vec::new(), 0usize);
for (bi, b) in st.bodies.iter().enumerate() {
if !app.session.body_visible(&b.name) {
continue;
}
let m = b.mesh();
triangles += m.triangles.len();
if triangles > MAX_TRIANGLES {
return None;
}
for t in &m.triangles {
if let Some([a, b, c]) = m.tri(t) {
scene.triangle([f(a), f(b), f(c)]);
}
}
for (ei, e) in m.edges.iter().enumerate() {
if !m.edge_pickable(ei) || edges.len() >= u32::MAX as usize - 1 {
continue;
}
if e.len() == 1 {
points.push((bi as u32, ei as u32));
continue;
}
edges.push((bi as u32, ei as u32));
let id = edges.len() as u32;
for w in e.windows(2) {
scene.segment(f(w[0]), f(w[1]), id);
}
}
}
(!scene.is_empty()).then(|| Arc::new(Scene { state: st.clone(), key, scene: Arc::new(scene), edges, points }))
}
/// In the frame's GPU paint: draw the pass for a view that held still when a pick asked for a
/// tile, and keep frames coming until the read-back lands.
pub(crate) fn paint(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, proj: &Proj) {
if !usable(app) {
return;
}
let st = app.session.world_state();
let model = model_key(app, &st);
let key = view_key(app, proj, model);
let state = &mut app.viewport.pick_buffer;
let still = std::mem::replace(&mut state.last_view, key) == key;
let want = state.want.lock().ok().and_then(|mut w| w.take()).filter(|(k, _)| *k == key && still);
if want.is_none() && state.frames == 0 {
return;
}
state.frames = if want.is_some() { FRAMES } else { state.frames.saturating_sub(1) };
let cached = state.scene.lock().ok().and_then(|s| s.clone()).filter(|s| s.key == model && Arc::ptr_eq(&s.state, &st));
let scene = match cached {
Some(s) => s,
None => {
let Some(s) = scene(app, &st, model) else { return };
if let Ok(mut slot) = app.viewport.pick_buffer.scene.lock() {
*slot = Some(s.clone());
}
s
}
};
let e = proj.cam.eye().to_f32();
let view = PickView {
key,
view_proj: proj.vp.to_f32(),
eye: [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }],
back: proj.cam.back().to_f32(),
lift: lift(app) as f32,
size: view_size(app, proj),
};
let cb =
PickCallback { scene_key: scene.key, scene: scene.scene.clone(), view, centre: want.map(|w| w.1), out: app.viewport.pick_buffer.out.clone() };
painter.add(egui_wgpu::Callback::new_paint_callback(proj.rect, cb));
ctx.request_repaint();
}
/// The pass as an egui paint callback: all its work is in `prepare` (it draws into its own
/// textures, not the frame).
struct PickCallback {
scene_key: u64,
scene: Arc<PickScene>,
view: PickView,
centre: Option<[u32; 2]>,
out: PickOut,
}
impl egui_wgpu::CallbackTrait for PickCallback {
fn prepare(
&self,
device: &egui_wgpu::wgpu::Device,
queue: &egui_wgpu::wgpu::Queue,
_screen: &egui_wgpu::ScreenDescriptor,
encoder: &mut egui_wgpu::wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<egui_wgpu::wgpu::CommandBuffer> {
if resources.get::<PickBuffer>().is_none() {
resources.insert(PickBuffer::new(device));
}
if let Some(pb) = resources.get_mut::<PickBuffer>() {
pb.prepare(device, queue, encoder, self.scene_key, &self.scene, &self.view, self.centre, &self.out);
}
Vec::new()
}
fn paint(&self, _info: egui::PaintCallbackInfo, _pass: &mut egui_wgpu::wgpu::RenderPass<'static>, _resources: &egui_wgpu::CallbackResources) {}
}
#[cfg(test)]
#[path = "pick_buffer_tests.rs"]
mod tests;
+141
View File
@@ -0,0 +1,141 @@
use egui::{Rect, pos2};
use serde_json::json;
use solidcraft_engine::geom::Vec3;
use solidcraft_gpu::pick::{PickTile, TILE, tile_around};
use super::*;
use crate::viewport::{Hit, pick, projection};
fn app() -> SolveApp {
let mut s = solidcraft_engine::Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
s.execute("solid.fillet", &json!({"edges": [[40, 15, 20]], "radius": 4})).unwrap();
s.execute("solid.cylinder", &json!({"radius": 8, "height": 35, "base": [10, 10, 0], "operation": "new_body"})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.ui.show_origin = false;
app.cam.target = Vec3::new(20.0, 15.0, 12.0);
app.cam.distance = 110.0;
// A GPU viewport (no device is needed to hand the picker a tile).
app.viewport.gpu = Some(crate::gpu::GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false });
app
}
const RECT: Rect = Rect { min: pos2(0.0, 0.0), max: pos2(640.0, 480.0) };
/// Installs the pass's scene, as `paint` makes it, and returns the view's key.
fn install_scene(app: &SolveApp, proj: &Proj) -> u64 {
let st = app.session.world_state();
let model = model_key(app, &st);
let s = scene(app, &st, model).unwrap();
*app.viewport.pick_buffer.scene.lock().unwrap() = Some(s);
view_key(app, proj, model)
}
/// A tile only narrows the edges: the edge it names near the cursor is picked as the CPU picks
/// it, an edge it does not name (hidden there) is not, and without a tile the CPU indexes answer
/// and a tile is asked for.
#[test]
fn a_tile_names_the_edges_the_picker_tests() {
let app = app();
let proj = projection(&app, RECT);
// A point on the box's top front edge (y = 0, z = 20).
let at = proj.to_screen(Vec3::new(20.0, 0.0, 20.0)).unwrap();
let cpu = pick(&app, &proj, at);
let Some(Hit::Edge { index, body, .. }) = cpu.iter().find(|h| matches!(h, Hit::Edge { .. })).cloned() else { panic!("{cpu:?}") };
assert!(app.viewport.pick_buffer.want.lock().unwrap().is_some(), "no tile yet: one is asked for");
let key = install_scene(&app, &proj);
let st = app.session.world_state();
let bi = st.bodies.iter().position(|b| b.name == body).unwrap();
let s = app.viewport.pick_buffer.scene.lock().unwrap().clone().unwrap();
let id = s.edges.iter().position(|&(b, e)| b as usize == bi && e as usize == index).unwrap() as u32 + 1;
let size = view_size(&app, &proj);
let (origin, tsize) = tile_around([at.x as u32, at.y as u32], size);
let tile = |ids: Vec<u32>| PickTile { key, origin, size: tsize, ids };
let n = (tsize[0] * tsize[1]) as usize;
// The edge's id at the cursor's pixel.
let mut ids = vec![0; n];
ids[((at.y as u32 - origin[1]) * tsize[0] + (at.x as u32 - origin[0])) as usize] = id;
app.viewport.pick_buffer.out.lock().unwrap().tiles = vec![tile(ids)];
assert_eq!(pick(&app, &proj, at), cpu, "the named edge is picked as the CPU picks it");
// A tile naming no edge there: no edge or vertex is picked.
app.viewport.pick_buffer.out.lock().unwrap().tiles = vec![tile(vec![0; n])];
let none = pick(&app, &proj, at);
assert!(!none.iter().any(|h| matches!(h, Hit::Edge { .. } | Hit::Vertex { .. })), "{none:?}");
assert!(none.iter().any(|h| matches!(h, Hit::Face { .. })), "faces are still picked: {none:?}");
// A tile of another view does not answer.
app.viewport.pick_buffer.out.lock().unwrap().tiles = vec![PickTile { key: key ^ 1, ..tile(vec![0; n]) }];
assert_eq!(pick(&app, &proj, at), cpu);
// Wireframe: nothing hides an edge, and the buffer is not used.
let mut wire = app;
wire.ui.visual_style = 2;
wire.viewport.pick_buffer.out.lock().unwrap().tiles = vec![tile(vec![0; n])];
assert!(edges_near(&wire, &proj, at, &wire.session.world_state()).is_none());
}
/// On a real device: the pass drawn for the view, and picks over a grid of the view helped by its
/// tiles answer as the CPU picks (an edge's pixels and the CPU's visibility test can disagree by
/// a pixel at a silhouette, so a few may differ). Skipped without a GPU adapter.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn picks_through_the_pass_match_the_cpu() {
let Some((_, device, queue)) = crate::gpu::offscreen_device() else {
assert!(std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "a GPU is required here");
eprintln!("skipping: no GPU adapter");
return;
};
let mut app = app();
for (perspective, yaw, pitch) in [(false, 0.6, 0.5), (true, 2.4, 0.3)] {
app.ui.perspective = perspective;
app.cam.yaw = yaw;
app.cam.pitch = pitch;
let proj = projection(&app, RECT);
let key = install_scene(&app, &proj);
let s = app.viewport.pick_buffer.scene.lock().unwrap().clone().unwrap();
let e = proj.cam.eye().to_f32();
let view = PickView {
key,
view_proj: proj.vp.to_f32(),
eye: [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }],
back: proj.cam.back().to_f32(),
lift: lift(&app) as f32,
size: view_size(&app, &proj),
};
let mut pb = PickBuffer::new(&device);
let out = app.viewport.pick_buffer.out.clone();
// Tiles over the whole view.
let mut wanted = Vec::new();
for ty in (0..view.size[1]).step_by(TILE as usize - 32) {
for tx in (0..view.size[0]).step_by(TILE as usize - 32) {
wanted.push([tx + TILE / 2, ty + TILE / 2]);
}
}
let mut tiles = Vec::new();
for c in wanted {
for _ in 0..6 {
let mut encoder = device.create_command_encoder(&Default::default());
pb.prepare(&device, &queue, &mut encoder, s.key, &s.scene, &view, Some(c), &out);
queue.submit([encoder.finish()]);
let _ = device.poll(egui_wgpu::wgpu::PollType::wait_indefinitely());
}
tiles.append(&mut out.lock().unwrap().tiles);
}
let (mut same, mut differ, mut edges) = (0, 0, 0);
for j in 0..46 {
for i in 0..62 {
let p = pos2(10.0 + i as f32 * 10.0 + (j % 3) as f32, 10.0 + j as f32 * 10.0);
let r = 8i64;
let w = [p.x as i64 - r, p.y as i64 - r, p.x as i64 + r, p.y as i64 + r];
let Some(t) = tiles.iter().find(|t| t.covers(key, view.size, w)).cloned() else { continue };
out.lock().unwrap().tiles = vec![t];
let through = pick(&app, &proj, p);
out.lock().unwrap().tiles.clear();
let cpu = pick(&app, &proj, p);
edges += usize::from(cpu.iter().any(|h| matches!(h, Hit::Edge { .. })));
if through == cpu { same += 1 } else { differ += 1 }
}
}
eprintln!("perspective {perspective}: {same} same, {differ} differ, {edges} with an edge");
assert!(edges > 100, "the grid reaches edges ({edges})");
assert!(differ * 100 <= same + differ, "{differ} of {} picks differ", same + differ);
}
}
+102
View File
@@ -0,0 +1,102 @@
//! Picking without scanning every triangle and edge: each body mesh's [`MeshIndex`] (its
//! triangles and edge segments in hierarchies), made the first time a pick reaches the body and
//! kept while the mesh is. The viewport's picker asks it for the nearest triangle along the
//! cursor's ray and for the edge segments near that ray, then runs its exact screen tests on
//! those alone.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use egui::{Pos2, vec2};
use solidcraft_engine::geom::{Mesh, MeshIndex, Vec3};
use crate::viewport::Proj;
/// Indexes by mesh address, each with its mesh held (so the address stays its own).
#[derive(Default)]
pub(crate) struct PickIndexes(Mutex<HashMap<usize, (Arc<Mesh>, Arc<MeshIndex>)>>);
impl PickIndexes {
/// The index of `mesh`, made now when there is none yet.
pub(crate) fn of(&self, mesh: &Arc<Mesh>) -> Arc<MeshIndex> {
let key = Arc::as_ptr(mesh) as usize;
let Ok(mut map) = self.0.lock() else { return Arc::new(MeshIndex::new(mesh)) };
if let Some((_, index)) = map.get(&key) {
return index.clone();
}
let index = Arc::new(MeshIndex::new(mesh));
map.insert(key, (mesh.clone(), index.clone()));
index
}
/// Drops the indexes of meshes `keep` no longer names (a new model state).
pub(crate) fn retain(&self, keep: impl Fn(usize) -> bool) {
if let Ok(mut map) = self.0.lock() {
map.retain(|k, _| keep(*k));
}
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.0.lock().map_or(0, |m| m.len())
}
}
/// How far from the ray through `pos` a point may lie and still come within `px` points of it on
/// screen: `a + b·t` at distance `t` along the ray (constant for an orthographic view, widening
/// with depth in perspective), with room to spare.
pub(crate) fn reach(proj: &Proj, pos: Pos2, px: f32) -> (f64, f64) {
let (o, d) = proj.ray(pos);
let (mut a, mut b) = (0.0f64, 0.0f64);
for step in [vec2(px, 0.0), vec2(0.0, px), vec2(-px, 0.0), vec2(0.0, -px)] {
let (o2, d2) = proj.ray(pos + step);
a = a.max(o2.dist(o));
b = b.max(d2.dist(d));
}
// A screen offset of `px` along a diagonal, and rounding: half again.
(a * 1.5, b * 1.5)
}
/// The edge segments of `index` that may come within reach of the ray, as (edge, first point),
/// in edge order and then along each edge: the order a scan of every edge would meet them in.
pub(crate) fn segments_near(index: &MeshIndex, mesh: &Mesh, o: Vec3, d: Vec3, (a, b): (f64, f64)) -> Vec<(usize, usize)> {
let mut v = Vec::new();
if scanning() {
for (ei, e) in mesh.edges.iter().enumerate() {
v.extend((0..e.len().max(2) - 1).map(|si| (ei, si)));
}
return v;
}
index.segments_near(o, d, a, b, |e, s| v.push((e, s)));
v.sort_unstable();
v
}
/// The nearest triangle of `mesh` along the ray, through its index.
pub(crate) fn raycast(index: &MeshIndex, mesh: &Mesh, o: Vec3, d: Vec3) -> Option<(f64, usize)> {
if scanning() {
return mesh.raycast(o, d);
}
index.raycast(mesh, o, d)
}
#[cfg(test)]
thread_local! {
/// Tests: pick by scanning every triangle and edge, as before the indexes, to compare.
pub(crate) static SCAN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
fn scanning() -> bool {
#[cfg(test)]
{
SCAN.with(|s| s.get())
}
#[cfg(not(test))]
{
false
}
}
#[cfg(test)]
#[path = "pick_index_tests.rs"]
mod tests;
+122
View File
@@ -0,0 +1,122 @@
use egui::{Rect, pos2, vec2};
use serde_json::json;
use crate::SolveApp;
use crate::viewport::{Hit, pick, pick_all, projection};
/// Overlapping bodies with flat and curved faces, filleted edges and seams.
fn assembly() -> SolveApp {
let mut s = solidcraft_engine::Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
s.execute("solid.fillet", &json!({"edges": [[40, 15, 20]], "radius": 4})).unwrap();
s.execute("solid.cylinder", &json!({"radius": 8, "height": 35, "base": [10, 10, 0], "operation": "new_body"})).unwrap();
s.execute("solid.sphere", &json!({"radius": 9, "center": [30, 25, 22]})).unwrap();
let mut app = SolveApp::new(s, crate::Services::default());
app.ui.show_origin = false;
app
}
/// Every cursor position on a grid over the view, for each camera: the indexed pick answers what
/// scanning every triangle and edge answers (the nearest of each kind, and Select Other's list).
#[test]
fn indexed_picks_match_the_full_scan() {
let mut app = assembly();
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(640.0, 480.0));
let mut compared = (0, 0, 0);
for (perspective, yaw, pitch) in [(false, 0.6, 0.5), (true, 0.6, 0.5), (true, 2.4, -0.3), (false, 0.0, 1.5)] {
app.ui.perspective = perspective;
app.cam.yaw = yaw;
app.cam.pitch = pitch;
app.cam.target = solidcraft_engine::geom::Vec3::new(20.0, 15.0, 12.0);
app.cam.distance = 110.0;
let proj = projection(&app, rect);
for j in 0..48 {
for i in 0..64 {
let p = pos2(5.0 + i as f32 * 10.0 + (j % 3) as f32, 5.0 + j as f32 * 10.0);
let fast = (pick(&app, &proj, p), pick_all(&app, &proj, p));
super::SCAN.with(|s| s.set(true));
let scan = (pick(&app, &proj, p), pick_all(&app, &proj, p));
super::SCAN.with(|s| s.set(false));
assert_eq!(fast, scan, "at {p:?} (perspective {perspective}, yaw {yaw}, pitch {pitch})");
compared.0 += usize::from(fast.0.iter().any(|h| matches!(h, Hit::Face { .. })));
compared.1 += usize::from(fast.0.iter().any(|h| matches!(h, Hit::Edge { .. })));
compared.2 += usize::from(fast.0.iter().any(|h| matches!(h, Hit::Vertex { .. })));
}
}
}
// The grid reaches every kind many times over.
assert!(compared.0 > 2000 && compared.1 > 100 && compared.2 > 10, "{compared:?}");
assert_eq!(app.viewport.pick_index.len(), 3, "one index per body mesh");
}
/// A new model state lets go of the indexes of meshes it no longer has.
#[test]
fn indexes_follow_the_model_state() {
let mut app = assembly();
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(640.0, 480.0));
app.cam.target = solidcraft_engine::geom::Vec3::new(20.0, 15.0, 12.0);
let proj = projection(&app, rect);
let centre = proj.to_screen(solidcraft_engine::geom::Vec3::new(20.0, 15.0, 10.0)).unwrap();
pick(&app, &proj, centre);
assert!(app.viewport.pick_index.len() >= 1);
app.session.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [-20, -20, 0], "operation": "new_body"})).unwrap();
pick(&app, &proj, centre);
assert!(app.viewport.pick_index.len() <= 4, "indexes of meshes gone with the old state are dropped");
app.session.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [-40, -20, 0], "operation": "new_body"})).unwrap();
pick(&app, &proj, centre);
assert!(app.viewport.pick_index.len() <= 5);
}
/// Timing, not a check (`cargo test -p solidcraft-ui-egui --lib pick_timing -- --ignored
/// --nocapture`): picks over a grid of the view, scanning every triangle and edge versus through
/// the indexes, on many curved bodies and on one body with many curved faces.
#[test]
#[ignore]
fn pick_timing_on_curved_models() {
let mut many = solidcraft_engine::Session::default();
for i in 0..12 {
for j in 0..12 {
let (x, y) = (f64::from(i) * 30.0, f64::from(j) * 30.0);
many.execute("solid.cylinder", &json!({"radius": 9, "height": 30, "base": [x, y, 0], "operation": "new_body"})).unwrap();
many.execute("solid.sphere", &json!({"radius": 8, "center": [x, y, 40], "operation": "new_body"})).unwrap();
}
}
let mut one = solidcraft_engine::Session::default();
one.execute("solid.box", &json!({"length": 340, "width": 340, "height": 10, "corner": [-15, -15, -5]})).unwrap();
for i in 0..8 {
for j in 0..8 {
let (x, y) = (f64::from(i) * 42.0, f64::from(j) * 42.0);
one.execute("solid.cylinder", &json!({"radius": 14, "height": 30, "base": [x, y, 0], "operation": "join"})).unwrap();
}
}
for (name, s) in [("many bodies", many), ("one body", one)] {
let mut app = SolveApp::new(s, crate::Services::default());
app.ui.show_origin = false;
app.cam.target = solidcraft_engine::geom::Vec3::new(160.0, 160.0, 15.0);
app.cam.distance = 900.0;
let st = app.session.world_state();
let (tris, segs) = st
.bodies
.iter()
.map(|b| b.mesh())
.fold((0, 0), |(t, s), m| (t + m.triangles.len(), s + m.edges.iter().map(|e| e.len().saturating_sub(1)).sum::<usize>()));
let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 900.0));
let proj = projection(&app, rect);
let points: Vec<_> = (0..20).flat_map(|j| (0..30).map(move |i| pos2(30.0 + i as f32 * 45.0, 30.0 + j as f32 * 42.0))).collect();
// Indexes made once, before timing.
for p in &points {
pick(&app, &proj, *p);
}
let time = |scan: bool| {
super::SCAN.with(|s| s.set(scan));
let t = std::time::Instant::now();
for p in &points {
std::hint::black_box(pick(&app, &proj, *p));
}
super::SCAN.with(|s| s.set(false));
t.elapsed().as_secs_f64() * 1000.0 / points.len() as f64
};
let (scan, indexed) = (time(true), time(false));
eprintln!("{name}: {} bodies, {tris} triangles, {segs} edge segments: scan {scan:.3} ms per pick, indexed {indexed:.3} ms", st.bodies.len());
}
}
+38 -16
View File
@@ -55,6 +55,11 @@ pub struct ViewportState {
lod_view: u64,
/// Each body mesh's box (the mesh held, so its address stays its own), from the last state.
mesh_bounds: std::sync::Mutex<std::collections::HashMap<usize, (std::sync::Arc<solidcraft_engine::geom::Mesh>, solidcraft_engine::geom::Aabb3)>>,
/// Each body mesh's picking index (made the first time a pick reaches the body), kept while
/// the mesh is part of the world state.
pub(crate) pick_index: crate::pick_index::PickIndexes,
/// The GPU pick buffer's scene, read-backs and requests.
pub(crate) pick_buffer: crate::pick_buffer::State,
/// The last click in the view was the middle button's (for the middle double-click).
pub(crate) middle_clicked: bool,
/// The last pick (its key and hits): reused while nothing it depends on changed.
@@ -1003,6 +1008,7 @@ fn body_bounds(
})
.collect::<Vec<_>>(),
);
app.viewport.pick_index.retain(|k| now.contains_key(&k));
if let Ok(mut k) = app.viewport.mesh_bounds.lock() {
*k = now;
}
@@ -1269,7 +1275,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
}
}
let m = b.mesh();
if let Some((t, ti)) = m.raycast(o, d) {
if let Some((t, ti)) = crate::pick_index::raycast(&app.viewport.pick_index.of(&m), &m, o, d) {
let fi = m.tri_face.get(ti).copied().unwrap_or(0) as usize;
let hit = Hit::Face { body: b.name.clone(), index: fi, point: o + d * t };
if bestf.as_ref().is_none_or(|(bt, _)| t < *bt) {
@@ -1284,7 +1290,11 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
// Wireframe draws no faces, so nothing hides an edge or a vertex.
let wireframe = app.ui.visual_style == 2;
let visible = |p: Vec3| wireframe || bestf.as_ref().is_none_or(|(t, _)| (p - o).dot(d) <= *t + slack);
// Vertices (edge end points) and edges.
// Vertices (edge end points) and edges, within 7 and 6 points of the cursor.
let reach = crate::pick_index::reach(proj, pos, 8.0);
// The edges seen near the cursor, when the GPU pick buffer has them for this view (Select
// Other wants hidden ones too).
let seen = if all { None } else { crate::pick_buffer::edges_near(app, proj, pos, &st) };
let mut bestv = Near::new(all);
let mut beste = Near::new(all);
for (i, b) in st.bodies.iter().enumerate() {
@@ -1298,26 +1308,37 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
continue;
}
let m = b.mesh();
for (ei, e) in m.edges.iter().enumerate() {
// Only the segments that can come near the cursor: of the edges the pick buffer sees
// there, else from the index. Either way in the order a scan of every edge would meet
// them, so ties go the same way.
let near = match &seen {
Some(seen) => seen.segments(i, &m),
None => crate::pick_index::segments_near(&app.viewport.pick_index.of(&m), &m, o, d, reach),
};
let mut last_edge = None;
for (ei, si) in near {
let Some(e) = m.edges.get(ei) else { continue };
if !m.edge_pickable(ei) {
continue;
}
for v in [e.first(), e.last()].into_iter().flatten() {
if let Some(sp) = proj.to_screen(*v) {
let dd = sp.distance(pos);
if dd < 7.0 && visible(*v) && bestv.wants(dd) {
bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v });
if last_edge != Some(ei) {
last_edge = Some(ei);
for v in [e.first(), e.last()].into_iter().flatten() {
if let Some(sp) = proj.to_screen(*v) {
let dd = sp.distance(pos);
if dd < 7.0 && visible(*v) && bestv.wants(dd) {
bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v });
}
}
}
}
for w in e.windows(2) {
let (Some(a), Some(c)) = (proj.to_screen(w[0]), proj.to_screen(w[1])) else { continue };
let dd = seg_dist(pos, a, c);
// Visible where the cursor is: the edge's point under the cursor, not an end or
// the middle, which can lie deeper than the face the cursor is on.
if dd < 6.0 && beste.wants(dd) && visible(ray_segment_closest(o, d, w[0], w[1])) {
beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) });
}
let (Some(p0), Some(p1)) = (e.get(si), e.get(si + 1)) else { continue };
let (Some(a), Some(c)) = (proj.to_screen(*p0), proj.to_screen(*p1)) else { continue };
let dd = seg_dist(pos, a, c);
// Visible where the cursor is: the edge's point under the cursor, not an end or
// the middle, which can lie deeper than the face the cursor is on.
if dd < 6.0 && beste.wants(dd) && visible(ray_segment_closest(o, d, *p0, *p1)) {
beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) });
}
}
}
@@ -2377,6 +2398,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
},
});
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
crate::pick_buffer::paint(app, ui.ctx(), &painter, &proj);
}
analysis_legend(app, &painter, rect);
crate::frame_rate::paint(app, ui.ctx(), &painter, rect);