diff --git a/src/app/mod.rs b/src/app/mod.rs index c06cddebb..c13a5bb1d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -735,6 +735,19 @@ pub(super) struct OpenCADStudio { /// drawings in a file manager produces exactly that (one process per file, /// all arriving at once), which makes this queue load-bearing, not polish. pub(super) pending_opens: std::collections::VecDeque, + /// One global interaction-index build at a time. Large drawings can each + /// hold millions of entries, so file-open bursts must not multiply peak + /// CPU and memory by the number of tabs. + active_interaction_index: Option<(u64, u64, usize)>, + /// Latest resident source requested by each waiting tab. Jobs run + /// serially behind `active_interaction_index`. + queued_interaction_indices: std::collections::VecDeque<( + u64, + u64, + usize, + std::sync::Arc>, + f32, + )>, // ── Unsaved-changes dialog ──────────────────────────────────────────── /// Set when the user tries to close a tab or quit while there are unsaved changes. @@ -1576,6 +1589,17 @@ pub enum Message { /// Timer pulse while a rollover hit-test is queued; fires when the /// cursor has been still long enough to safely run the pick. HoverDwellTick, + /// Large projected interaction index finished preparing off the UI thread. + InteractionIndexReady { + tab_id: u64, + epoch: u64, + source: usize, + wires: std::sync::Weak>, + index: std::sync::Arc< + crate::scene::pick::interaction_index::InteractionIndex, + >, + build_ms: f64, + }, WindowResized(f32, f32), /// Enter pressed globally — finalises the active command (no text-input involvement). CommandFinalize, @@ -2461,6 +2485,8 @@ impl OpenCADStudio { plot_prev: None, opening: None, pending_opens: std::collections::VecDeque::new(), + active_interaction_index: None, + queued_interaction_indices: std::collections::VecDeque::new(), pending_close: None, active_save_jobs: std::collections::HashMap::new(), save_job_serial: 0, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 51c790561..155950f12 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -616,7 +616,17 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].dirty = false; self.tabs[i].history = crate::app::document::HistoryState::default(); self.refresh_selected_grips(); - Task::batch([thumbs_task, self.drain_pending_open()]) + #[cfg(not(target_arch = "wasm32"))] + let interaction_task = { + let wires = self.tabs[i].scene.hit_test_wires(); + let screen_height = self.tabs[i].scene.selection.borrow().vp_size.1; + self.prepare_interaction_index_task(i, wires, screen_height) + .unwrap_or_else(Task::none) + }; + #[cfg(target_arch = "wasm32")] + let interaction_task = Task::none(); + let pending_open_task = self.drain_pending_open(); + Task::batch([thumbs_task, pending_open_task, interaction_task]) } pub(super) fn on_wblock_save_result_some(&mut self, block_name: String, path: std::path::PathBuf) -> Task { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 73bbdf96c..5d9da2565 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1661,6 +1661,72 @@ impl OpenCADStudio { Message::HoverDwellTick => self.on_hover_dwell_tick(), + Message::InteractionIndexReady { + tab_id, + epoch, + source, + wires, + index, + build_ms, + } => { + if self.active_interaction_index == Some((tab_id, epoch, source)) { + self.active_interaction_index = None; + } + let installed = self + .tabs + .iter() + .position(|tab| tab.id == tab_id) + .is_some_and(|i| { + self.tabs[i].scene.install_prepared_interaction_index( + epoch, source, wires, index, + ) + }); + if std::env::var_os("OCS_PERF").is_some() { + eprintln!( + "[perf] interaction-index-bg {:>7.1}ms installed={installed}", + build_ms, + ); + } + while let Some(( + queued_tab, + queued_epoch, + queued_source, + queued_wires, + screen_height, + )) = self.queued_interaction_indices.pop_front() + { + let Some(i) = self.tabs.iter().position(|tab| tab.id == queued_tab) else { + continue; + }; + let stale = self.tabs[i].scene.geometry_epoch != queued_epoch + || std::sync::Arc::as_ptr(&queued_wires) as usize != queued_source; + let (wires, screen_height) = if stale { + ( + self.tabs[i].scene.hit_test_wires(), + self.tabs[i].scene.selection.borrow().vp_size.1, + ) + } else { + (queued_wires, screen_height) + }; + if let Some(task) = self.prepare_interaction_index_task( + i, + wires, + screen_height, + ) { + return task; + } + } + if self.active_interaction_index.is_none() && !self.tabs.is_empty() { + let i = self.active_tab.min(self.tabs.len() - 1); + let wires = self.tabs[i].scene.hit_test_wires(); + let screen_height = self.tabs[i].scene.selection.borrow().vp_size.1; + self.prepare_interaction_index_task(i, wires, screen_height) + .unwrap_or_else(Task::none) + } else { + Task::none() + } + } + Message::VisibilityPick(idx) => { if let Some(popup) = self.visibility_popup.take() { self.apply_visibility_state(popup.insert_handle, idx); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 6c28ec0bc..af1f642ae 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -19,6 +19,7 @@ use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; use iced::{mouse, Point, Task}; +use std::sync::Arc; /// Pixel radius for grabbing a UCS-icon grip (origin dot or an axis tip). const UCS_GRIP_HIT_PX: f32 = 9.0; @@ -3484,8 +3485,17 @@ impl OpenCADStudio { .viewport_edit_frame(canvas_sz) .map(|(cam, _)| cam); let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); + if let Some(task) = + self.prepare_interaction_index_task(i, Arc::clone(&all_wires), bounds.height) + { + // Keep the dwell armed. While the index is pending later timer + // ticks return immediately; once installed, the next tick performs + // the exact pick at the latest cursor position. + return task; + } let hover_world = self.cursor_model_point(i, &edit_cam, p, bounds); - let hover_candidates = self.tabs[i].scene.interaction_pick_candidates_near( + let candidate_started = Instant::now(); + let hover_candidates = self.tabs[i].scene.interaction_hover_candidates_near( all_wires, hover_world, view_rot, @@ -3493,13 +3503,18 @@ impl OpenCADStudio { bounds, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, ); + let candidate_ms = candidate_started.elapsed().as_secs_f64() * 1000.0; + let candidate_count = hover_candidates.len(); + let handles_started = Instant::now(); let candidate_handles = self.tabs[i] .scene .interaction_candidate_handles(&hover_candidates); + let handles_ms = handles_started.elapsed().as_secs_f64() * 1000.0; // Mirror the click-selection pick order so the rollover // highlights every selectable object: wire → hatch → // block-internal hatch → shaded 3D solid body. - let hovered = scene::pick::hit_test::click_hit( + let wire_started = Instant::now(); + let mut hovered = scene::pick::hit_test::click_hit( p, &hover_candidates, view_rot, @@ -3507,9 +3522,14 @@ impl OpenCADStudio { bounds, self.tabs[i].scene.document.header.lineweight_display, ) - .and_then(|s| Scene::handle_from_wire_name(s)) - .or_else(|| { - scene::pick::hit_test::click_hit_hatch( + .and_then(Scene::handle_from_wire_name); + let wire_ms = wire_started.elapsed().as_secs_f64() * 1000.0; + let mut hatch_ms = 0.0; + let mut insert_ms = 0.0; + let mut solid_ms = 0.0; + if hovered.is_none() { + let started = Instant::now(); + hovered = scene::pick::hit_test::click_hit_hatch( p, &self.tabs[i] .scene @@ -3518,27 +3538,35 @@ impl OpenCADStudio { eye, bounds, candidate_handles.as_ref(), - ) - }) - .or_else(|| { - scene::pick::hit_test::click_hit_insert_hatch( + ); + hatch_ms = started.elapsed().as_secs_f64() * 1000.0; + } + if hovered.is_none() { + let started = Instant::now(); + hovered = scene::pick::hit_test::click_hit_insert_hatch( p, self.tabs[i].scene.insert_hatches_for_click().as_ref(), view_rot, eye, bounds, candidate_handles.as_ref(), - ) - }) - .or_else(|| { - self.tabs[i] + ); + insert_ms = started.elapsed().as_secs_f64() * 1000.0; + } + if hovered.is_none() { + let started = Instant::now(); + hovered = self.tabs[i] .scene - .solid_click_hit(p, view_rot, eye, bounds, candidate_handles.as_ref()) - }); + .solid_hover_hit(p, view_rot, eye, bounds, candidate_handles.as_ref()); + solid_ms = started.elapsed().as_secs_f64() * 1000.0; + } self.tabs[i].scene.set_hover_highlight(hovered); self.hover_dwell = None; let hover_ms = hover_started.elapsed().as_secs_f64() * 1000.0; if perf && hover_ms >= 5.0 { + eprintln!( + "[perf] hover-detail query={candidate_ms:.1} handles={handles_ms:.1} wire={wire_ms:.1} hatch={hatch_ms:.1} insert={insert_ms:.1} solid={solid_ms:.1} candidates={candidate_count}", + ); eprintln!( "[perf] hover-dwell {:>7.1}ms wires={} hit={}", hover_ms, @@ -3549,6 +3577,61 @@ impl OpenCADStudio { Task::none() } + pub(in crate::app) fn prepare_interaction_index_task( + &mut self, + i: usize, + wires: Arc>, + screen_height_px: f32, + ) -> Option> { + let (epoch, source) = self.tabs[i] + .scene + .interaction_index_build_key(&wires, screen_height_px)?; + let tab_id = self.tabs[i].id; + let key = (tab_id, epoch, source); + if let Some(active) = self.active_interaction_index { + if active != key { + if let Some(position) = self + .queued_interaction_indices + .iter() + .position(|(queued_tab, ..)| *queued_tab == tab_id) + { + self.queued_interaction_indices.remove(position); + } + self.queued_interaction_indices.push_back(( + tab_id, + epoch, + source, + wires, + screen_height_px, + )); + } + return Some(Task::none()); + } + self.tabs[i] + .scene + .mark_interaction_index_pending(epoch, source); + self.active_interaction_index = Some(key); + let weak = Arc::downgrade(&wires); + Some(Task::perform( + async move { + let started = std::time::Instant::now(); + let index = Arc::new( + crate::scene::pick::interaction_index::InteractionIndex::build(&wires), + ); + index.prepare_screen(); + Message::InteractionIndexReady { + tab_id, + epoch, + source, + wires: weak, + index, + build_ms: started.elapsed().as_secs_f64() * 1000.0, + } + }, + |message| message, + )) + } + pub(super) fn on_layout_switch(&mut self, name: String) -> Task { let i = self.active_tab; // A BEDIT block editor locks the active space; finish it with diff --git a/src/scene/mod.rs b/src/scene/mod.rs index fd5884a77..2dac4e645 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1032,6 +1032,10 @@ pub struct Scene { Arc, )>, >, + /// Latest large source requested for off-thread preparation. A matching + /// cache miss returns no candidates temporarily instead of scanning the + /// entire scene on the UI thread. + interaction_index_pending_key: std::cell::Cell>, /// Large resident interaction index kept as an immutable base across small /// entity edits. Geometry-journal handles form a tombstone/delta overlay; /// the exact index is rebuilt only after the overlay grows too large. @@ -1063,9 +1067,6 @@ pub struct Scene { Arc, )>, >, - /// Estimated hatch/mesh/Insert interaction work, cached per geometry epoch - /// so the index threshold itself stays O(1) on pointer-move events. - interaction_aux_work_cache: std::cell::Cell>, /// Index built from every SortEntitiesTable in the document. /// Maps block_handle → (entity_handle.value() → sort_handle.value()). /// Replaces the O(objects) linear scan inside `wires_for_block()` with an O(1) lookup. @@ -1357,10 +1358,10 @@ impl Scene { selection_generation: 0, wire_cache: RefCell::new(None), interaction_index_cache: RefCell::new(Vec::new()), + interaction_index_pending_key: std::cell::Cell::new(None), interaction_base_index_cache: RefCell::new(None), interaction_overlay_index_cache: RefCell::new(None), interaction_handle_index_cache: RefCell::new(None), - interaction_aux_work_cache: std::cell::Cell::new(None), sort_cache: RefCell::new(None), draw_depth_cache: RefCell::new(None), hatch_cache: RefCell::new(None), @@ -4791,107 +4792,137 @@ impl Scene { const INTERACTION_INDEX_MIN_WORK: usize = 20_000; fn interaction_index_worthwhile(&self, wires: &[WireModel]) -> bool { - if wires.len() >= Self::INTERACTION_INDEX_MIN_WIRES + wires.len() >= Self::INTERACTION_INDEX_MIN_WIRES || crate::scene::pick::interaction_index::InteractionIndex::estimated_work(wires) >= Self::INTERACTION_INDEX_MIN_WORK - { - return true; - } - let space_key = self.interaction_space_key(); - let aux_work = self - .interaction_aux_work_cache - .get() - .filter(|(epoch, space, _)| *epoch == self.geometry_epoch && *space == space_key) - .map(|(_, _, work)| work) - .unwrap_or_else(|| { - let hatch_work = self.hatches.iter().fold(0usize, |total, (&handle, hatch)| { - if self.hatch_visible_for_interaction(handle) { - total.saturating_add(hatch.boundary.len()) - } else { - total - } - }); - let insert_work = self - .insert_hatches_for_click() - .values() - .flatten() - .fold(0usize, |total, hatch| { - total.saturating_add(hatch.boundary.len()) - }); - let mesh_work = self - .interaction_meshes_arc() - .iter() - .fold(0usize, |total, set| { - total.saturating_add(set.lods.first().map_or(0, |mesh| mesh.verts.len())) - }); - let work = hatch_work - .saturating_add(mesh_work) - .saturating_add(insert_work); - self.interaction_aux_work_cache - .set(Some((self.geometry_epoch, space_key, work))); - work - }); - aux_work >= Self::INTERACTION_INDEX_MIN_WORK + || self.hatches.len() >= Self::INTERACTION_INDEX_MIN_WIRES + || self.meshes.len().saturating_add(self.block_meshes.len()) + >= Self::INTERACTION_INDEX_MIN_WIRES + || self + .hatches + .values() + .any(|hatch| hatch.boundary.len() >= Self::INTERACTION_INDEX_MIN_WORK) + || self + .meshes + .values() + .chain(self.block_meshes.values()) + .any(|set| { + set.lods + .first() + .is_some_and(|mesh| mesh.verts.len() >= Self::INTERACTION_INDEX_MIN_WORK) + }) } - fn interaction_index( + fn cached_interaction_index( &self, wires: &Arc>, - ) -> Arc { - const MAX_CACHED_SOURCES: usize = 4; + ) -> Option> { let source = Arc::as_ptr(wires) as usize; - { - let mut cache = self.interaction_index_cache.borrow_mut(); - cache.retain(|(epoch, _, weak, _)| { - *epoch == self.geometry_epoch && weak.strong_count() > 0 - }); - if let Some(position) = cache.iter().position(|(_, ptr, weak, _)| { - *ptr == source - && weak - .upgrade() - .is_some_and(|cached| Arc::ptr_eq(&cached, wires)) - }) { - let entry = cache.remove(position); - let index = Arc::clone(&entry.3); - cache.push(entry); - if self.current_layout == "Model" { - *self.interaction_base_index_cache.borrow_mut() = Some(( - self.geometry_epoch, - self.interaction_space_key(), - Arc::clone(&index), - )); - } - return index; - } - } - let started = iced::time::Instant::now(); - let index = Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(wires)); - let build_ms = started.elapsed().as_secs_f64() * 1000.0; - if std::env::var_os("OCS_PERF").is_some() && build_ms >= 50.0 { - eprintln!( - "[perf] interaction-index {:>7.1}ms wires={}", - build_ms, - wires.len(), - ); - } let mut cache = self.interaction_index_cache.borrow_mut(); + cache.retain(|(epoch, _, weak, _)| { + *epoch == self.geometry_epoch && weak.strong_count() > 0 + }); + if let Some(position) = cache.iter().position(|(_, ptr, weak, _)| { + *ptr == source + && weak + .upgrade() + .is_some_and(|cached| Arc::ptr_eq(&cached, wires)) + }) { + let entry = cache.remove(position); + let index = Arc::clone(&entry.3); + cache.push(entry); + if self.current_layout == "Model" { + *self.interaction_base_index_cache.borrow_mut() = Some(( + self.geometry_epoch, + self.interaction_space_key(), + Arc::clone(&index), + )); + } + return Some(index); + } + None + } + + fn cache_interaction_index( + &self, + epoch: u64, + wires: Arc>, + index: Arc, + ) { + const MAX_CACHED_SOURCES: usize = 4; + let source = Arc::as_ptr(&wires) as usize; + let mut cache = self.interaction_index_cache.borrow_mut(); + cache.retain(|(cached_epoch, ptr, weak, _)| { + *cached_epoch == self.geometry_epoch + && weak.strong_count() > 0 + && !(*cached_epoch == epoch && *ptr == source) + }); cache.push(( - self.geometry_epoch, + epoch, source, - Arc::downgrade(wires), + Arc::downgrade(&wires), Arc::clone(&index), )); if cache.len() > MAX_CACHED_SOURCES { cache.remove(0); } - if self.current_layout == "Model" { + if epoch == self.geometry_epoch && self.current_layout == "Model" { *self.interaction_base_index_cache.borrow_mut() = Some(( - self.geometry_epoch, + epoch, self.interaction_space_key(), - Arc::clone(&index), + index, )); } - index + } + + /// Return a stable build key when this source needs its large + /// interaction index. The app uses it to deduplicate background builds. + pub fn interaction_index_build_key( + &self, + wires: &Arc>, + screen_height_px: f32, + ) -> Option<(u64, usize)> { + let key = (self.geometry_epoch, Arc::as_ptr(wires) as usize); + if !self.interaction_source_is_resident(wires, screen_height_px.max(1.0)) + || !self.interaction_index_worthwhile(wires) + || self.interaction_overlay_base().is_some() + { + return None; + } + if self.cached_interaction_index(wires).is_some() { + if self.interaction_index_pending_key.get() == Some(key) { + self.interaction_index_pending_key.set(None); + } + return None; + } + Some(key) + } + + pub fn mark_interaction_index_pending(&self, epoch: u64, source: usize) { + self.interaction_index_pending_key + .set(Some((epoch, source))); + } + + /// Install a fully prepared background index only if its geometry/source + /// still belongs to this scene. + pub fn install_prepared_interaction_index( + &self, + epoch: u64, + source: usize, + wires: std::sync::Weak>, + index: Arc, + ) -> bool { + if self.interaction_index_pending_key.get() == Some((epoch, source)) { + self.interaction_index_pending_key.set(None); + } + let Some(wires) = wires.upgrade() else { + return false; + }; + if epoch != self.geometry_epoch || Arc::as_ptr(&wires) as usize != source { + return false; + } + self.cache_interaction_index(epoch, wires, index); + true } const INTERACTION_OVERLAY_MAX_HANDLES: usize = 2_048; @@ -5018,6 +5049,7 @@ impl Scene { &self, wires: Arc>, aabb: [f64; 4], + allow_pending_empty: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() { let perf = std::env::var_os("OCS_PERF").is_some(); @@ -5057,7 +5089,16 @@ impl Scene { } return result; } - self.interaction_index(&wires).query_xy(wires, aabb) + if let Some(index) = self.cached_interaction_index(&wires) { + index.query_xy(wires, aabb) + } else if allow_pending_empty + && self.interaction_index_pending_key.get() + == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) + { + crate::scene::pick::interaction_index::InteractionCandidates::pending(wires) + } else { + crate::scene::pick::interaction_index::InteractionCandidates::all(wires) + } } fn indexed_interaction_candidates_screen( @@ -5067,6 +5108,7 @@ impl Scene { view_rot: glam::Mat4, eye: glam::DVec3, bounds: iced::Rectangle, + allow_pending_empty: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() { let perf = std::env::var_os("OCS_PERF").is_some(); @@ -5117,8 +5159,16 @@ impl Scene { } return result; } - self.interaction_index(&wires) - .query_screen(wires, screen_rect, view_rot, eye, bounds) + if let Some(index) = self.cached_interaction_index(&wires) { + index.query_screen(wires, screen_rect, view_rot, eye, bounds) + } else if allow_pending_empty + && self.interaction_index_pending_key.get() + == Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize)) + { + crate::scene::pick::interaction_index::InteractionCandidates::pending(wires) + } else { + crate::scene::pick::interaction_index::InteractionCandidates::all(wires) + } } fn indexed_interaction_pick_radius( @@ -5131,7 +5181,8 @@ impl Scene { self.interaction_overlay_changed_index(base_epoch, &changes); base.pick_radius_px(changed.pick_radius_px(base_radius_px)) } else { - self.interaction_index(wires).pick_radius_px(base_radius_px) + self.cached_interaction_index(wires) + .map_or(base_radius_px, |index| index.pick_radius_px(base_radius_px)) } } @@ -5141,7 +5192,7 @@ impl Scene { screen_height_px: f32, ) -> bool { if self.current_layout == "Model" { - return true; + return Arc::ptr_eq(&self.entity_wires_arc(), wires); } match self.active_viewport { Some(viewport) => { @@ -5237,7 +5288,7 @@ impl Scene { radius_px: f32, ) -> crate::scene::pick::interaction_index::InteractionCandidates { self.interaction_candidates_near_impl( - wires, cursor, view_rot, eye, bounds, radius_px, false, + wires, cursor, view_rot, eye, bounds, radius_px, false, false, ) } @@ -5258,6 +5309,28 @@ impl Scene { bounds, radius_px, self.document.header.lineweight_display, + false, + ) + } + + pub fn interaction_hover_candidates_near( + &self, + wires: Arc>, + cursor: glam::DVec3, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + radius_px: f32, + ) -> crate::scene::pick::interaction_index::InteractionCandidates { + self.interaction_candidates_near_impl( + wires, + cursor, + view_rot, + eye, + bounds, + radius_px, + self.document.header.lineweight_display, + true, ) } @@ -5270,6 +5343,7 @@ impl Scene { bounds: iced::Rectangle, radius_px: f32, include_line_weight: bool, + allow_pending_empty: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if !self.interaction_source_is_resident(&wires, bounds.height) || !self.interaction_index_worthwhile(&wires) @@ -5306,6 +5380,7 @@ impl Scene { view_rot, eye, bounds, + allow_pending_empty, ); } let world_x_px = ((view_rot.x_axis.x * bounds.width * 0.5).powi(2) @@ -5325,7 +5400,7 @@ impl Scene { cursor.x + radius, cursor.y + radius, ]; - self.indexed_interaction_candidates_xy(wires, query) + self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty) } /// Shared rectangular broad phase for box/lasso/fence and command windows. @@ -5349,9 +5424,16 @@ impl Scene { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); } if flat_ortho { - self.indexed_interaction_candidates_xy(wires, aabb) + self.indexed_interaction_candidates_xy(wires, aabb, false) } else { - self.indexed_interaction_candidates_screen(wires, screen_rect, view_rot, eye, bounds) + self.indexed_interaction_candidates_screen( + wires, + screen_rect, + view_rot, + eye, + bounds, + false, + ) } } @@ -5363,6 +5445,12 @@ impl Scene { .iter() .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) .collect(); + if candidates.is_indexed() + && candidates.query_aabb().is_none() + && candidates.screen_query().is_none() + { + return Some(handles); + } if let Some(aabb) = candidates.query_aabb() { let index = self.entity_index(); handles.extend(index.tree.query_rect(aabb)); @@ -5386,7 +5474,7 @@ impl Scene { pub fn interaction_handles_in_world_aabb(&self, aabb: [f64; 4]) -> HashSet { let wires = self.hit_test_wires(); - let candidates = self.indexed_interaction_candidates_xy(wires, aabb); + let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false); let mut handles: HashSet = candidates .iter() .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) @@ -5543,6 +5631,46 @@ impl Scene { eye: glam::DVec3, bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, + ) -> Option { + self.solid_hit( + cursor, + view_rot, + eye, + bounds, + candidate_handles, + false, + ) + } + + /// Hover may use the coarsest cached solid LOD. Click selection keeps the + /// full-resolution mesh, while rollover only needs a stable parent handle + /// and must stay within an interactive frame budget. + pub fn solid_hover_hit( + &self, + cursor: iced::Point, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + candidate_handles: Option<&HashSet>, + ) -> Option { + self.solid_hit( + cursor, + view_rot, + eye, + bounds, + candidate_handles, + true, + ) + } + + fn solid_hit( + &self, + cursor: iced::Point, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + candidate_handles: Option<&HashSet>, + coarse_lod: bool, ) -> Option { // Reuse the renderer's expanded mesh set (top-level solids + per-INSERT // block instances), cached per geometry epoch — so hover no longer @@ -5581,10 +5709,24 @@ impl Scene { .then_some((handle, set)) })); } - let candidates = sets - .into_iter() - .filter_map(|(handle, set)| set.lods.first().map(|mesh| (handle, mesh))); - pick::hit_test::mesh_click_hit(cursor, candidates, view_rot, eye, bounds) + if coarse_lod { + return pick::hit_test::mesh_click_hit( + cursor, + sets.iter() + .filter_map(|(handle, set)| set.lods.last().map(|mesh| (*handle, mesh))), + view_rot, + eye, + bounds, + ); + } + pick::hit_test::mesh_click_hit( + cursor, + sets.iter() + .filter_map(|(handle, set)| set.lods.first().map(|mesh| (*handle, mesh))), + view_rot, + eye, + bounds, + ) } /// Parent INSERT handles whose block-internal solid meshes fall in a diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 4ea1b6aa7..87a98dba9 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -671,6 +671,24 @@ pub fn mesh_click_hit<'a>( let v = &mesh.verts; let idx = &mesh.indices; let lo = &mesh.verts_low; + // Indexed meshes reuse most vertices across several triangles. + // Project once per vertex instead of three matrix transforms per + // triangle; dense solid misses are otherwise the worst-case hover. + let projected: Vec<(Point, f32)> = v + .iter() + .enumerate() + .map(|(i, &vertex)| { + let ndc = + view_rot.project_point3((mesh_vert(vertex, lo, i) - eye).as_vec3()); + ( + Point::new( + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ), + ndc.z, + ) + }) + .collect(); let mut t = 0; while t + 2 < idx.len() { let tri = [idx[t] as usize, idx[t + 1] as usize, idx[t + 2] as usize]; @@ -678,12 +696,9 @@ pub fn mesh_click_hit<'a>( let mut sp = [Point::ORIGIN; 3]; let mut depth = 0.0f32; for (j, &k) in tri.iter().enumerate() { - let ndc = view_rot.project_point3((mesh_vert(v[k], lo, k) - eye).as_vec3()); - sp[j] = Point::new( - (ndc.x + 1.0) * 0.5 * bounds.width, - (1.0 - ndc.y) * 0.5 * bounds.height, - ); - depth += ndc.z; + let (point, z) = projected[k]; + sp[j] = point; + depth += z; } if point_in_polygon(cursor, &sp) { let d = depth / 3.0; diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 89c739b9a..70c027cbe 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -140,12 +140,12 @@ fn build_bvh2_node( return node_idx; } let axis = usize::from(bounds[3] - bounds[1] > bounds[2] - bounds[0]); - order.sort_unstable_by(|&a, &b| { + let mid = order.len() / 2; + order.select_nth_unstable_by(mid, |&a, &b| { let aa = entry_aabb2(&entries[a as usize]); let bb = entry_aabb2(&entries[b as usize]); (aa[axis] + aa[axis + 2]).total_cmp(&(bb[axis] + bb[axis + 2])) }); - let mid = order.len() / 2; let (left_order, right_order) = order.split_at_mut(mid); let left = build_bvh2_node(entries, left_order, base, nodes); let right = build_bvh2_node(entries, right_order, base + mid, nodes); @@ -253,12 +253,12 @@ fn build_bvh3_node( axis = candidate; } } - order.sort_unstable_by(|&a, &b| { + let mid = order.len() / 2; + order.select_nth_unstable_by(mid, |&a, &b| { let aa = entries[a as usize].aabb; let bb = entries[b as usize].aabb; (aa[axis] + aa[axis + 3]).total_cmp(&(bb[axis] + bb[axis + 3])) }); - let mid = order.len() / 2; let (left_order, right_order) = order.split_at_mut(mid); let left = build_bvh3_node(entries, left_order, base, nodes); let right = build_bvh3_node(entries, right_order, base + mid, nodes); @@ -520,6 +520,11 @@ impl SpatialSet { self.xy.query(&self.entries, aabb) } + fn prepare_screen(&self) { + self.xyz + .get_or_init(|| SpatialBvh3::build(&self.entries)); + } + fn query_screen( &self, screen_rect: [f32; 4], @@ -951,6 +956,52 @@ impl InteractionIndex { } } + /// Build every projected 3D broad phase before the index reaches the UI + /// thread. Perspective/orbit hover must never pay this one-time cost. + pub fn prepare_screen(&self) { + #[cfg(not(target_arch = "wasm32"))] + rayon::join( + || { + rayon::join( + || rayon::join(|| self.wires.prepare_screen(), || self.segments.prepare_screen()), + || { + rayon::join( + || self.snap_points.prepare_screen(), + || self.key_vertices.prepare_screen(), + ) + }, + ) + }, + || { + rayon::join( + || { + rayon::join( + || self.key_segments.prepare_screen(), + || self.fill_triangles.prepare_screen(), + ) + }, + || { + rayon::join( + || self.pick_triangles.prepare_screen(), + || self.glyphs.prepare_screen(), + ) + }, + ) + }, + ); + #[cfg(target_arch = "wasm32")] + { + self.wires.prepare_screen(); + self.segments.prepare_screen(); + self.snap_points.prepare_screen(); + self.key_vertices.prepare_screen(); + self.key_segments.prepare_screen(); + self.fill_triangles.prepare_screen(); + self.pick_triangles.prepare_screen(); + self.glyphs.prepare_screen(); + } + } + pub fn pick_radius_px(&self, base_radius_px: f32) -> f32 { base_radius_px.max(self.max_line_half_width_px) } @@ -1241,6 +1292,17 @@ impl InteractionIndex { } } +impl std::fmt::Debug for InteractionIndex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InteractionIndex") + .field("wires", &self.wire_handles.len()) + .field("segments", &self.segments.entries.len()) + .field("fill_triangles", &self.fill_triangles.entries.len()) + .field("pick_triangles", &self.pick_triangles.entries.len()) + .finish() + } +} + pub struct InteractionCandidates { wires: Arc>, wire_indices: Option>, @@ -1302,6 +1364,30 @@ impl InteractionCandidates { } } + /// Empty but explicitly indexed candidate set. Used while a large source + /// is still being prepared off-thread so UI input never falls back to a + /// full-scene scan. + pub fn pending(wires: Arc>) -> Self { + Self { + wires, + wire_indices: Some(Vec::new()), + segments: Some(Vec::new()), + snap_points: Some(Vec::new()), + key_vertices: Some(Vec::new()), + key_segments: Some(Vec::new()), + fill_triangles: Some(Vec::new()), + pick_triangles: Some(Vec::new()), + glyphs: Some(Vec::new()), + query_aabb: None, + screen_rect: None, + screen_view: None, + } + } + + pub fn is_indexed(&self) -> bool { + self.wire_indices.is_some() + } + pub fn iter(&self) -> WireIter<'_> { match &self.wire_indices { Some(indices) => WireIter::Indexed {