diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 6a1619463..bc9f4b4d6 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -776,6 +776,7 @@ impl OpenCADStudio { all_wires, raw, view_rot, + eye, bounds, self.snapper.osnap_radius_px, ); @@ -941,6 +942,7 @@ impl OpenCADStudio { all_wires, snap_cursor, view_rot, + eye, bounds, self.snapper.osnap_radius_px, ); @@ -1547,6 +1549,7 @@ impl OpenCADStudio { all_wires, raw, view_rot, + eye, bounds, self.snapper.osnap_radius_px, ); @@ -2034,6 +2037,7 @@ impl OpenCADStudio { all_wires, snap_cursor, view_rot, + eye, bounds, self.snapper.osnap_radius_px, ); @@ -2206,10 +2210,11 @@ impl OpenCADStudio { .unwrap_or(false) { let (view_rot2, eye2, all_wires2) = self.pick_view(i, &edit_cam, bounds); - let click_candidates = self.tabs[i].scene.interaction_candidates_near( + let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near( all_wires2, world_pt, view_rot2, + eye2, bounds, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, ); @@ -2445,9 +2450,14 @@ impl OpenCADStudio { aabb }, ); - let area_candidates = self.tabs[i] - .scene - .interaction_candidates_in_aabb(all_wires, world_aabb, view_rot); + let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb( + all_wires, + world_aabb, + [a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)], + view_rot, + eye, + bounds, + ); let candidate_handles = self.tabs[i] .scene .interaction_candidate_handles(&area_candidates); @@ -2475,6 +2485,16 @@ impl OpenCADStudio { bounds, candidate_handles.as_ref(), )); + handles.extend(scene::pick::hit_test::box_hit_insert_hatch( + a, + p, + crossing, + self.tabs[i].scene.insert_hatches_for_click().as_ref(), + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); handles.extend(self.tabs[i].scene.mesh_box_hit( a, p, @@ -2548,9 +2568,28 @@ impl OpenCADStudio { aabb }, ); - let area_candidates = self.tabs[i] - .scene - .interaction_candidates_in_aabb(all_wires, world_aabb, view_rot); + let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb( + all_wires, + world_aabb, + poly_pts.iter().fold( + [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ], + |mut rect, point| { + rect[0] = rect[0].min(point.x); + rect[1] = rect[1].min(point.y); + rect[2] = rect[2].max(point.x); + rect[3] = rect[3].max(point.y); + rect + }, + ), + view_rot, + eye, + bounds, + ); let candidate_handles = self.tabs[i] .scene .interaction_candidate_handles(&area_candidates); @@ -2576,6 +2615,15 @@ impl OpenCADStudio { bounds, candidate_handles.as_ref(), )); + handles.extend(scene::pick::hit_test::poly_hit_insert_hatch( + &poly_pts, + crossing, + self.tabs[i].scene.insert_hatches_for_click().as_ref(), + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); handles.extend(self.tabs[i].scene.mesh_poly_hit( &poly_pts, crossing, @@ -2630,10 +2678,11 @@ impl OpenCADStudio { if box_anchor.is_none() { let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); let click_world = self.cursor_model_point(i, &edit_cam, p, bounds); - let click_candidates = self.tabs[i].scene.interaction_candidates_near( + let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near( all_wires, click_world, view_rot, + eye, bounds, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, ); @@ -2695,7 +2744,7 @@ impl OpenCADStudio { // parent Insert (AutoCAD behaviour). scene::pick::hit_test::click_hit_insert_hatch( p, - &self.tabs[i].scene.insert_hatches_for_click()[..], + self.tabs[i].scene.insert_hatches_for_click().as_ref(), view_rot, eye, bounds, @@ -2801,9 +2850,14 @@ impl OpenCADStudio { aabb }, ); - let area_candidates = self.tabs[i] - .scene - .interaction_candidates_in_aabb(all_wires, world_aabb, view_rot); + let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb( + all_wires, + world_aabb, + [a.x.min(p.x), a.y.min(p.y), a.x.max(p.x), a.y.max(p.y)], + view_rot, + eye, + bounds, + ); let candidate_handles = self.tabs[i] .scene .interaction_candidate_handles(&area_candidates); @@ -2831,6 +2885,16 @@ impl OpenCADStudio { bounds, candidate_handles.as_ref(), )); + handles.extend(scene::pick::hit_test::box_hit_insert_hatch( + a, + p, + crossing, + self.tabs[i].scene.insert_hatches_for_click().as_ref(), + view_rot, + eye, + bounds, + candidate_handles.as_ref(), + )); handles.extend(self.tabs[i].scene.mesh_box_hit( a, p, @@ -2937,10 +3001,11 @@ impl OpenCADStudio { }; let all_wires = self.tabs[i].scene.hit_test_wires(); let click_world = self.cursor_model_point(i, &edit_cam, p, bounds); - let click_candidates = self.tabs[i].scene.interaction_candidates_near( + let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near( all_wires, click_world, view_rot, + eye, bounds, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, ); @@ -3058,10 +3123,11 @@ impl OpenCADStudio { }; let all_wires = self.tabs[i].scene.hit_test_wires(); let click_world = self.cursor_model_point(i, &edit_cam, p, bounds); - let click_candidates = self.tabs[i].scene.interaction_candidates_near( + let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near( all_wires, click_world, view_rot, + eye, bounds, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, ); @@ -3384,10 +3450,11 @@ impl OpenCADStudio { .map(|(cam, _)| cam); let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds); let hover_world = self.cursor_model_point(i, &edit_cam, p, bounds); - let hover_candidates = self.tabs[i].scene.interaction_candidates_near( + let hover_candidates = self.tabs[i].scene.interaction_pick_candidates_near( all_wires, hover_world, view_rot, + eye, bounds, scene::pick::hit_test::CLICK_THRESHOLD_PX * 2.0, ); @@ -3421,7 +3488,7 @@ impl OpenCADStudio { .or_else(|| { scene::pick::hit_test::click_hit_insert_hatch( p, - &self.tabs[i].scene.insert_hatches_for_click()[..], + self.tabs[i].scene.insert_hatches_for_click().as_ref(), view_rot, eye, bounds, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 33af13b2c..5fbd0521a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -66,6 +66,42 @@ fn hatch_interaction_aabb(hatch: &model::hatch_model::HatchModel) -> Option<[f64 aabb.iter().all(|value| value.is_finite()).then_some(aabb) } +fn mesh_interaction_aabb(set: &model::mesh_model::MeshLodSet) -> Option<[f64; 6]> { + let mut aabb = [ + f64::INFINITY, + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ]; + let mut include = |high: [f32; 3], low: [f32; 3]| { + let point = [ + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ]; + if point.iter().all(|value| value.is_finite()) { + for axis in 0..3 { + aabb[axis] = aabb[axis].min(point[axis]); + aabb[axis + 3] = aabb[axis + 3].max(point[axis]); + } + } + }; + for mesh in &set.lods { + for (index, &high) in mesh.verts.iter().enumerate() { + include(high, mesh.verts_low.get(index).copied().unwrap_or([0.0; 3])); + } + } + for (index, &high) in set.edge_verts.iter().enumerate() { + include( + high, + set.edge_verts_low.get(index).copied().unwrap_or([0.0; 3]), + ); + } + aabb.iter().all(|value| value.is_finite()).then_some(aabb) +} + pub use model::hatch_model::HatchModel; pub use model::image_model::ImageModel; pub use model::mesh_model::MeshLodSet; @@ -940,7 +976,7 @@ pub struct Scene { /// area selection. The source pointer is part of the key because a camera /// cull can replace the wire Arc without changing `geometry_epoch`. interaction_index_cache: RefCell< - Option<( + Vec<( u64, usize, std::sync::Weak>, @@ -951,10 +987,14 @@ pub struct Scene { /// hatch-only and solid-only block instances. interaction_handle_index_cache: RefCell< Option<( + u64, u64, 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. @@ -987,6 +1027,20 @@ pub struct Scene { image_cache: RefCell>)>>, /// Cached mesh models, keyed by geometry_epoch. mesh_cache: RefCell>)>>, + /// Picking mesh source for a non-model active space, keyed by geometry epoch + /// and interaction block. Model/MSPACE reuse `mesh_cache` directly. + interaction_mesh_cache: RefCell>)>>, + /// Direct handle → expanded mesh-set indices for the current renderer mesh + /// source. Block instances may contribute several sets under one Insert + /// handle. The weak source guard prevents stale pointer reuse. + #[allow(clippy::type_complexity)] + mesh_pick_lookup_cache: RefCell< + Option<( + usize, + std::sync::Weak>, + Arc>>, + )>, + >, /// Per-viewport (VP-frozen-layer) filtered fill / image / mesh sets, keyed by /// the order-independent signature of the viewport's frozen-layer set. A /// content viewport that freezes layers must hide their hatches, 2-D solids, @@ -1003,7 +1057,7 @@ pub struct Scene { /// heavy block-instanced drawing re-explodes thousands of inserts on every /// hover. The set is geometry-derived, so a camera move / hover never /// invalidates it. - insert_hatch_cache: RefCell>)>>, + insert_hatch_cache: RefCell>>)>>, /// Sheet "dressing" cache over the unified resident set: the paper sheet /// drops its own border wire and appends the printable-area guide. /// `(geometry_epoch, content gen, wires)` — the base is camera-independent @@ -1231,14 +1285,17 @@ impl Scene { block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), selection_generation: 0, wire_cache: RefCell::new(None), - interaction_index_cache: RefCell::new(None), + interaction_index_cache: RefCell::new(Vec::new()), 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), wipeout_cache: RefCell::new(None), image_cache: RefCell::new(None), mesh_cache: RefCell::new(None), + interaction_mesh_cache: RefCell::new(None), + mesh_pick_lookup_cache: RefCell::new(None), frozen_hatch_cache: RefCell::new(HashMap::default()), frozen_wipeout_cache: RefCell::new(HashMap::default()), frozen_image_cache: RefCell::new(HashMap::default()), @@ -1287,54 +1344,8 @@ impl Scene { } } - /// Compute the current camera's world-space XY view AABB with - /// `world_offset` already subtracted (so the result is in the same f32 - /// space as emitted wire points). Adds a 25% margin around the - /// frustum to absorb pan inertia and avoid clipped-edge popping. - pub(super) fn view_world_aabb(&self) -> Option<[f32; 4]> { - if self.current_layout != "Model" { - // Paper-space viewport composition handles its own culling; the - // top-level paper view is small enough not to need it. - return None; - } - // Until the first explicit camera move (typically `fit_all()` after - // file open), the camera sits at the default origin while geometry - // lives at large local offsets — culling against the default rect - // would discard everything and starve fit_all of points to fit to. - if self.camera_generation == 0 { - return None; - } - let cam = self.camera.borrow(); - // A world-XY rectangle only bounds the on-screen footprint in a plan - // (top-down) view. In a tilted / 3D view the visible area maps to a - // skewed world region, so an axis-aligned XY box would wrongly cull - // entities that are actually on screen — and because this box culls the - // pick wire set (while the render holds the full set), those entities - // render but become unselectable. Skip culling there, matching - // `view_cull_aabb`. - let fwd = cam.rotation * glam::Vec3::Z; - if fwd.z.abs() < 0.999 { - return None; - } - let aspect = self.last_render_aspect.get().max(0.01); - let h = cam.ortho_size(); - let w = h * aspect; - let margin = 1.25_f32; - // `cam.target` is in the same local f32 space as emitted wire points - // (fit_to_bounds populates it from local wire coords). No further - // `world_offset` subtraction is needed. - let cx = cam.target.x as f32; - let cy = cam.target.y as f32; - Some([ - cx - w * margin, - cy - h * margin, - cx + w * margin, - cy + h * margin, - ]) - } - - /// Called by the render pipeline once per frame so `view_world_aabb` knows - /// the active widget's aspect ratio. + /// Called by the render pipeline once per frame so camera fit operations + /// know the active widget's aspect ratio. pub fn set_render_aspect(&self, aspect: f32) { if aspect.is_finite() && aspect > 0.0 { self.last_render_aspect.set(aspect); @@ -1347,7 +1358,7 @@ impl Scene { /// Also returns `None` in paper space: `last_world_per_pixel` tracks the /// model camera, so a cached value applied to mm-sheet entity AABBs would /// be a stale model-world wpp and cull every paper-space annotation. - /// Matches the same skip already in `view_world_aabb`. + /// Paper-space callers use their own scale instead. pub(super) fn world_per_pixel(&self) -> Option { if self.current_layout != "Model" { return None; @@ -3746,6 +3757,133 @@ impl Scene { arc } + fn interaction_block_handle(&self) -> Handle { + if self.block_edit_block.is_none() && self.active_viewport.is_some() { + self.model_space_block_handle() + } else { + self.current_layout_block_handle() + } + } + + fn interaction_viewport_frozen_layers(&self) -> Option<&[Handle]> { + if self.block_edit_block.is_some() { + return None; + } + let handle = self.active_viewport?; + match self.document.get_entity(handle) { + Some(EntityType::Viewport(viewport)) => Some(&viewport.frozen_layers), + _ => None, + } + } + + fn interaction_space_key(&self) -> u64 { + let block = self.interaction_block_handle().value(); + let viewport = self + .active_viewport + .filter(|_| self.block_edit_block.is_none()) + .map_or(0, |handle| handle.value()); + let frozen = self + .interaction_viewport_frozen_layers() + .map_or(0, |layers| { + layers + .iter() + .fold(layers.len() as u64, |signature, handle| { + signature ^ handle.value().wrapping_mul(0x9E37_79B9_7F4A_7C15) + }) + }); + block ^ viewport.rotate_left(29) ^ frozen.rotate_left(47) + } + + fn interaction_layer_frozen(&self, layer: &str) -> bool { + let Some(frozen) = self.interaction_viewport_frozen_layers() else { + return false; + }; + self.document + .layers + .get(layer) + .is_some_and(|record| frozen.contains(&record.handle)) + } + + fn interaction_meshes_arc(&self) -> Arc> { + let block = self.interaction_block_handle(); + if self.block_edit_block.is_none() && block == self.model_space_block_handle() { + let Some(frozen) = self.interaction_viewport_frozen_layers() else { + return self.meshes_arc(); + }; + if frozen.is_empty() { + return self.meshes_arc(); + } + let key = self.interaction_space_key(); + if let Some((epoch, cached_key, meshes)) = self.interaction_mesh_cache.borrow().as_ref() + { + if *epoch == self.geometry_epoch && *cached_key == key { + return Arc::clone(meshes); + } + } + let frozen: HashSet = frozen.iter().copied().collect(); + let meshes = self.meshes_for_viewport(&frozen); + *self.interaction_mesh_cache.borrow_mut() = + Some((self.geometry_epoch, key, Arc::clone(&meshes))); + return meshes; + } + let key = self.interaction_space_key(); + if let Some((epoch, cached_key, meshes)) = self.interaction_mesh_cache.borrow().as_ref() { + if *epoch == self.geometry_epoch && *cached_key == key { + return Arc::clone(meshes); + } + } + let mut all: Vec = self + .meshes + .iter() + .filter(|(&handle, _)| { + self.mesh_entity_visible(handle) + && self.document.get_entity(handle).is_some_and(|entity| { + self.belongs_to_visible_block(handle, entity.common().owner_handle, block) + }) + }) + .map(|(_, set)| set.clone()) + .collect(); + all.extend(self.instanced_block_meshes(block, None)); + let meshes = Arc::new(all); + *self.interaction_mesh_cache.borrow_mut() = + Some((self.geometry_epoch, key, Arc::clone(&meshes))); + meshes + } + + fn mesh_pick_lookup(&self, meshes: &Arc>) -> Arc>> { + let source = Arc::as_ptr(meshes) as usize; + { + let cache = self.mesh_pick_lookup_cache.borrow(); + if let Some((ptr, weak, lookup)) = cache.as_ref() { + if *ptr == source + && weak + .upgrade() + .is_some_and(|cached| Arc::ptr_eq(&cached, meshes)) + { + return Arc::clone(lookup); + } + } + } + let mut lookup: HashMap> = HashMap::default(); + for (index, set) in meshes.iter().enumerate() { + let Some(value) = set + .lods + .first() + .and_then(|mesh| mesh.name.parse::().ok()) + else { + continue; + }; + lookup + .entry(Handle::new(value)) + .or_default() + .push(index as u32); + } + let lookup = Arc::new(lookup); + *self.mesh_pick_lookup_cache.borrow_mut() = + Some((source, Arc::downgrade(meshes), Arc::clone(&lookup))); + lookup + } + /// Solid-mesh set (top-level + block-instanced), optionally dropping those /// whose layer is frozen in a content viewport (`frozen`). `None` reproduces /// the full set. @@ -3939,6 +4077,18 @@ impl Scene { !self.layer_hidden(&c.layer) } + fn mesh_visible_for_interaction(&self, handle: Handle) -> bool { + self.mesh_entity_visible(handle) + && self.document.get_entity(handle).is_some_and(|entity| { + !self.interaction_layer_frozen(&entity.common().layer) + && self.belongs_to_visible_block( + handle, + entity.common().owner_handle, + self.interaction_block_handle(), + ) + }) + } + /// One transformed mesh per block-definition solid instance reached from an /// INSERT owned by `layout_block`. Nested INSERTs accumulate their /// transform. Empty when no block solids exist. (#123) @@ -4140,40 +4290,43 @@ impl Scene { &self, candidate_handles: Option<&HashSet>, ) -> HashMap { - let layout_block = self.current_layout_block_handle(); - let model_block = self.model_space_block_handle(); - let layer_hidden = |layer: &str| { - self.document - .layers - .get(layer) - .map(|l| l.flags.off || l.flags.frozen) - .unwrap_or(false) + let visible = |h: Handle, m: &HatchModel| { + self.hatch_visible_for_interaction(h) + .then(|| (h, m.clone())) }; - self.hatches - .iter() - .filter_map(|(&h, m)| { - if candidate_handles.is_some_and(|handles| !handles.contains(&h)) { - return None; - } - let c = self.document.get_entity(h)?.common(); - if c.invisible || layer_hidden(&c.layer) { - return None; - } - // Mirror `synced_hatch_models`' visibility test (which drives - // the fill render) so anything drawn is also clickable on its - // fill, not just its boundary wire. The model-space fallback - // matters when the layout block handle differs from the - // entity's owner (issue: hatch fill not selectable). - if self.belongs_to_visible_block(h, c.owner_handle, layout_block) - || (self.block_edit_block.is_none() - && self.belongs_to_visible_block(h, c.owner_handle, model_block)) - { - Some((h, m.clone())) - } else { - None - } - }) - .collect() + if let Some(handles) = candidate_handles { + handles + .iter() + .filter_map(|&handle| { + self.hatches + .get(&handle) + .and_then(|hatch| visible(handle, hatch)) + }) + .collect() + } else { + self.hatches + .iter() + .filter_map(|(&handle, hatch)| visible(handle, hatch)) + .collect() + } + } + + fn hatch_visible_for_interaction(&self, handle: Handle) -> bool { + let Some(common) = self + .document + .get_entity(handle) + .map(|entity| entity.common()) + else { + return false; + }; + if common.invisible + || (!self.hidden.is_empty() && self.hidden.contains(&handle)) + || self.layer_hidden(&common.layer) + || self.interaction_layer_frozen(&common.layer) + { + return false; + } + self.belongs_to_visible_block(handle, common.owner_handle, self.interaction_block_handle()) } /// Per-Insert hatch models in the current layout, keyed by the Insert @@ -4266,24 +4419,20 @@ impl Scene { result } - pub fn insert_hatches_for_click(&self) -> Arc> { + pub fn insert_hatches_for_click(&self) -> Arc>> { + let interaction_block = self.interaction_block_handle(); + let space_key = self.interaction_space_key(); { let c = self.insert_hatch_cache.borrow(); - if let Some((epoch, ref arc)) = *c { - if epoch == self.geometry_epoch { + if let Some((epoch, cached_space, ref arc)) = *c { + if epoch == self.geometry_epoch && cached_space == space_key { return Arc::clone(arc); } } } - let layout_block = self.current_layout_block_handle(); - let layer_hidden = |layer: &str| { - self.document - .layers - .get(layer) - .map(|l| l.flags.off || l.flags.frozen) - .unwrap_or(false) - }; - let mut out: Vec<(Handle, HatchModel)> = Vec::new(); + let layer_hidden = + |layer: &str| self.layer_hidden(layer) || self.interaction_layer_frozen(layer); + let mut out: HashMap> = HashMap::default(); // Exploding an INSERT to find block-internal hatches is expensive, so // skip blocks that contain no hatch at all (the common case for solid- // only blocks). The hatch-presence test is memoised across inserts. @@ -4293,13 +4442,16 @@ impl Scene { let EntityType::Insert(ins) = entity else { continue; }; - if ins.common.invisible || layer_hidden(&ins.common.layer) { + if ins.common.invisible + || (!self.hidden.is_empty() && self.hidden.contains(&ins.common.handle)) + || layer_hidden(&ins.common.layer) + { continue; } if !self.belongs_to_visible_block( ins.common.handle, ins.common.owner_handle, - layout_block, + interaction_block, ) { continue; } @@ -4319,12 +4471,13 @@ impl Scene { } let color = self.render_style(&EntityType::Hatch(dxf.clone())).0; if let Some(model) = Self::hatch_model_from_dxf(&dxf, color) { - out.push((ins.common.handle, model)); + out.entry(ins.common.handle).or_default().push(model); } } } let arc = Arc::new(out); - *self.insert_hatch_cache.borrow_mut() = Some((self.geometry_epoch, Arc::clone(&arc))); + *self.insert_hatch_cache.borrow_mut() = + Some((self.geometry_epoch, space_key, Arc::clone(&arc))); arc } @@ -4343,42 +4496,96 @@ impl Scene { } let layout_block = self.current_layout_block_handle(); match self.active_viewport { - None => Arc::new(self.wires_for_block(layout_block)), + None => self.paper_sheet_wires_arc(), Some(vp_handle) => { Arc::new(self.viewport_content_wires(layout_block, Some(vp_handle), None)) } } } - /// Below this size a full scan is cheaper than building both wire and - /// segment grids. - const INTERACTION_INDEX_MIN: usize = 4_000; + /// Small simple sets are cheaper to scan. Either many wires or substantial + /// sub-geometry (one giant polyline/text batch/mesh) enables the index. + const INTERACTION_INDEX_MIN_WIRES: usize = 4_000; + const INTERACTION_INDEX_MIN_WORK: usize = 20_000; + + fn interaction_index_worthwhile(&self, wires: &[WireModel]) -> bool { + if 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 + } fn interaction_index( &self, wires: &Arc>, ) -> Arc { + const MAX_CACHED_SOURCES: usize = 4; let source = Arc::as_ptr(wires) as usize; { - let cache = self.interaction_index_cache.borrow(); - if let Some((epoch, ptr, weak, index)) = cache.as_ref() { - if *epoch == self.geometry_epoch - && *ptr == source + 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)) - { - return Arc::clone(index); - } + }) { + let entry = cache.remove(position); + let index = Arc::clone(&entry.3); + cache.push(entry); + return index; } } let index = Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(wires)); - *self.interaction_index_cache.borrow_mut() = Some(( + let mut cache = self.interaction_index_cache.borrow_mut(); + cache.push(( self.geometry_epoch, source, Arc::downgrade(wires), Arc::clone(&index), )); + if cache.len() > MAX_CACHED_SOURCES { + cache.remove(0); + } index } @@ -4390,46 +4597,61 @@ impl Scene { if self.current_layout == "Model" { return true; } - self.active_viewport.is_some_and(|viewport| { - let resident = self.model_wires_for_viewport_arc(viewport, screen_height_px); - Arc::ptr_eq(&resident, wires) - }) + match self.active_viewport { + Some(viewport) => { + let resident = self.model_wires_for_viewport_arc(viewport, screen_height_px); + Arc::ptr_eq(&resident, wires) + } + None => Arc::ptr_eq(&self.paper_sheet_wires_arc(), wires), + } } fn interaction_handle_index( &self, ) -> Arc { + let space_key = self.interaction_space_key(); { let cache = self.interaction_handle_index_cache.borrow(); - if let Some((epoch, index)) = cache.as_ref() { - if *epoch == self.geometry_epoch { + if let Some((epoch, cached_space, index)) = cache.as_ref() { + if *epoch == self.geometry_epoch && *cached_space == space_key { return Arc::clone(index); } } } - let mut entries: Vec<(u64, [f64; 4])> = Vec::new(); - for (handle, hatch) in self.insert_hatches_for_click().iter() { - if let Some(aabb) = hatch_interaction_aabb(hatch) { - entries.push((handle.value(), aabb)); + let mut entries: Vec<(u64, [f64; 6])> = Vec::new(); + for (&handle, hatch) in &self.hatches { + if !self.hatch_visible_for_interaction(handle) { + continue; + } + if let Some([min_x, min_y, max_x, max_y]) = hatch_interaction_aabb(hatch) { + entries.push((handle.value(), [min_x, min_y, 0.0, max_x, max_y, 0.0])); } } - for set in self.meshes_arc().iter() { + for (&handle, hatches) in self.insert_hatches_for_click().iter() { + for hatch in hatches { + if let Some([min_x, min_y, max_x, max_y]) = hatch_interaction_aabb(hatch) { + entries.push((handle.value(), [min_x, min_y, 0.0, max_x, max_y, 0.0])); + } + } + } + for set in self.interaction_meshes_arc().iter() { let Some(mesh) = set.lods.first() else { continue; }; let Ok(handle) = mesh.name.parse::() else { continue; }; - let [min_x, min_y, max_x, max_y] = set.world_aabb; - entries.push(( - handle, - [min_x as f64, min_y as f64, max_x as f64, max_y as f64], - )); + if !self.mesh_visible_for_interaction(Handle::new(handle)) { + continue; + } + if let Some(aabb) = mesh_interaction_aabb(set) { + entries.push((handle, aabb)); + } } let index = Arc::new(crate::scene::pick::interaction_index::InteractionHandleIndex::build(entries)); *self.interaction_handle_index_cache.borrow_mut() = - Some((self.geometry_epoch, Arc::clone(&index))); + Some((self.geometry_epoch, space_key, Arc::clone(&index))); index } @@ -4442,19 +4664,82 @@ impl Scene { 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, false, + ) + } + + pub fn interaction_pick_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, + ) + } + + fn interaction_candidates_near_impl( + &self, + wires: Arc>, + cursor: glam::DVec3, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + radius_px: f32, + include_line_weight: bool, ) -> crate::scene::pick::interaction_index::InteractionCandidates { if !self.interaction_source_is_resident(&wires, bounds.height) - || wires.len() < Self::INTERACTION_INDEX_MIN + || !self.interaction_index_worthwhile(&wires) { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); } + let index = self.interaction_index(&wires); + let radius_px = if include_line_weight { + index.pick_radius_px(radius_px) + } else { + radius_px + }; let flat_ortho = view_rot.z_axis.x.abs() < 1e-9 && view_rot.z_axis.y.abs() < 1e-9 && (view_rot.w_axis.w - 1.0).abs() < 1e-6; if !flat_ortho { - return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); + let clip = view_rot * (cursor - eye).as_vec3().extend(1.0); + if !clip.is_finite() || clip.w <= 1e-6 { + return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); + } + let ndc = clip.truncate() / clip.w; + let screen = iced::Point::new( + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ); + let radius = radius_px.max(0.0); + return index.query_screen( + wires, + [ + screen.x - radius, + screen.y - radius, + screen.x + radius, + screen.y + radius, + ], + view_rot, + eye, + bounds, + ); } let world_x_px = ((view_rot.x_axis.x * bounds.width * 0.5).powi(2) + (view_rot.x_axis.y * bounds.height * 0.5).powi(2)) @@ -4473,44 +4758,60 @@ impl Scene { cursor.x + radius, cursor.y + radius, ]; - self.interaction_index(&wires).query(wires, query) + index.query_xy(wires, query) } - /// World-XY rectangular broad phase for box/lasso/fence and command - /// windows. Tilted and paper views use the same candidate type with an - /// all-wire fallback so exact screen-space rules stay unchanged. + /// Shared rectangular broad phase for box/lasso/fence and command windows. + /// Flat orthographic views query world XY; tilted/perspective views query + /// projected 3D bounds using the supplied screen rectangle. pub fn interaction_candidates_in_aabb( &self, wires: Arc>, aabb: [f64; 4], + screen_rect: [f32; 4], view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, ) -> crate::scene::pick::interaction_index::InteractionCandidates { let flat_ortho = view_rot.z_axis.x.abs() < 1e-9 && view_rot.z_axis.y.abs() < 1e-9 && (view_rot.w_axis.w - 1.0).abs() < 1e-6; - if !self.interaction_source_is_resident(&wires, 0.0) - || wires.len() < Self::INTERACTION_INDEX_MIN - || !flat_ortho + if !self.interaction_source_is_resident(&wires, bounds.height) + || !self.interaction_index_worthwhile(&wires) { return crate::scene::pick::interaction_index::InteractionCandidates::all(wires); } - self.interaction_index(&wires).query(wires, aabb) + if flat_ortho { + self.interaction_index(&wires).query_xy(wires, aabb) + } else { + self.interaction_index(&wires) + .query_screen(wires, screen_rect, view_rot, eye, bounds) + } } pub fn interaction_candidate_handles( &self, candidates: &crate::scene::pick::interaction_index::InteractionCandidates, ) -> Option> { - let aabb = candidates.query_aabb()?; let mut handles: HashSet = candidates .iter() .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) .collect(); - let index = self.entity_index(); - handles.extend(index.tree.query_rect(aabb)); + if let Some(aabb) = candidates.query_aabb() { + let index = self.entity_index(); + handles.extend(index.tree.query_rect(aabb)); + handles.extend( + self.interaction_handle_index() + .query_xy(aabb) + .into_iter() + .map(Handle::new), + ); + return Some(handles); + } + let (screen_rect, view_rot, eye, bounds) = candidates.screen_query()?; handles.extend( self.interaction_handle_index() - .query(aabb) + .query_screen(screen_rect, view_rot, eye, bounds) .into_iter() .map(Handle::new), ); @@ -4519,28 +4820,28 @@ impl Scene { pub fn interaction_handles_in_world_aabb(&self, aabb: [f64; 4]) -> HashSet { let wires = self.hit_test_wires(); - let candidates = self.interaction_index(&wires).query(wires, aabb); - candidates + let candidates = self.interaction_index(&wires).query_xy(wires, aabb); + let mut handles: HashSet = candidates .iter() .filter_map(|wire| Self::handle_from_wire_name(&wire.name)) - .collect() - } - - /// Pick a meshed 3D solid by clicking on its shaded body (face), not just - /// its thin projected edges. Returns the front-most mesh under `cursor`. - #[allow(dead_code)] - pub fn mesh_click_hit( - &self, - cursor: iced::Point, - view_rot: glam::Mat4, - eye: glam::DVec3, - bounds: iced::Rectangle, - ) -> Option { - let iter = self - .meshes - .iter() - .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); - pick::hit_test::mesh_click_hit(cursor, iter, view_rot, eye, bounds) + .collect(); + let insert_hatches = self.insert_hatches_for_click(); + let meshes = self.interaction_meshes_arc(); + let mesh_lookup = self.mesh_pick_lookup(&meshes); + handles.extend( + self.interaction_handle_index() + .query_xy(aabb) + .into_iter() + .filter_map(|value| { + let handle = Handle::new(value); + (self.hatch_visible_for_interaction(handle) + || insert_hatches.contains_key(&handle) + || (mesh_lookup.contains_key(&handle) + && self.mesh_visible_for_interaction(handle))) + .then_some(handle) + }), + ); + handles } /// True when any handle resolves to an ACIS volume entity (3D solid / @@ -4569,12 +4870,47 @@ impl Scene { bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, ) -> Vec { - let iter = self - .meshes - .iter() - .filter(|(h, _)| candidate_handles.is_none_or(|handles| handles.contains(h))) - .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); - pick::hit_test::mesh_box_hit(a, b, crossing, iter, view_rot, eye, bounds) + let meshes = self.interaction_meshes_arc(); + let lookup = self.mesh_pick_lookup(&meshes); + let handles: Vec = candidate_handles.map_or_else( + || lookup.keys().copied().collect(), + |candidates| candidates.iter().copied().collect(), + ); + let mut out = Vec::new(); + for handle in handles { + if !self.mesh_visible_for_interaction(handle) + || matches!( + self.document.get_entity(handle), + Some(EntityType::Insert(_)) + ) + { + continue; + } + let Some(indices) = lookup.get(&handle) else { + continue; + }; + if indices + .iter() + .filter_map(|&index| meshes.get(index as usize)) + .any(|set| { + set.lods.first().is_some_and(|mesh| { + !pick::hit_test::mesh_box_hit( + a, + b, + crossing, + std::iter::once((handle, mesh)), + view_rot, + eye, + bounds, + ) + .is_empty() + }) + }) + { + out.push(handle); + } + } + out } /// Top-level solid handles caught by a lasso polygon. @@ -4587,12 +4923,46 @@ impl Scene { bounds: iced::Rectangle, candidate_handles: Option<&HashSet>, ) -> Vec { - let iter = self - .meshes - .iter() - .filter(|(h, _)| candidate_handles.is_none_or(|handles| handles.contains(h))) - .filter_map(|(h, set)| set.lods.first().map(|m| (*h, m))); - pick::hit_test::mesh_poly_hit(poly, crossing, iter, view_rot, eye, bounds) + let meshes = self.interaction_meshes_arc(); + let lookup = self.mesh_pick_lookup(&meshes); + let handles: Vec = candidate_handles.map_or_else( + || lookup.keys().copied().collect(), + |candidates| candidates.iter().copied().collect(), + ); + let mut out = Vec::new(); + for handle in handles { + if !self.mesh_visible_for_interaction(handle) + || matches!( + self.document.get_entity(handle), + Some(EntityType::Insert(_)) + ) + { + continue; + } + let Some(indices) = lookup.get(&handle) else { + continue; + }; + if indices + .iter() + .filter_map(|&index| meshes.get(index as usize)) + .any(|set| { + set.lods.first().is_some_and(|mesh| { + !pick::hit_test::mesh_poly_hit( + poly, + crossing, + std::iter::once((handle, mesh)), + view_rot, + eye, + bounds, + ) + .is_empty() + }) + }) + { + out.push(handle); + } + } + out } /// Front-most solid under the cursor across BOTH top-level solid meshes @@ -4612,28 +4982,42 @@ impl Scene { // block instances), cached per geometry epoch — so hover no longer // re-expands every block instance on each move. Every `MeshLodSet` // carries its handle (in `mesh.name`) and a 3D AABB. - let meshes = self.meshes_arc(); - // Broad-phase: project each solid's 3D AABB and skip the per-triangle - // ray test for those whose footprint isn't under the cursor — O(solids) - // cheap projections instead of O(total triangles) on every hover. - let candidates = meshes.iter().filter_map(|set| { - let m = set.lods.first()?; - if !pick::hit_test::aabb_under_cursor( - set.world_aabb, - set.z_aabb, - cursor, - view_rot, - eye, - bounds, - ) { - return None; + let meshes = self.interaction_meshes_arc(); + let lookup = candidate_handles.map(|_| self.mesh_pick_lookup(&meshes)); + // Candidate handles already came from the precise f64 interaction BVH. + // When no index is active the source is small/non-resident, so exact + // triangles are safer than reintroducing the old f32 AABB precision loss. + let mut sets: Vec<(Handle, &MeshLodSet)> = Vec::new(); + if let (Some(handles), Some(lookup)) = (candidate_handles, lookup.as_ref()) { + for handle in handles { + if !self.mesh_visible_for_interaction(*handle) { + continue; + } + let Some(indices) = lookup.get(handle) else { + continue; + }; + sets.extend( + indices + .iter() + .filter_map(|&index| meshes.get(index as usize).map(|set| (*handle, set))), + ); } - let handle = Handle::new(m.name.parse::().ok()?); - if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) { - return None; - } - Some((handle, m)) - }); + } else { + sets.extend(meshes.iter().filter_map(|set| { + let handle = set + .lods + .first()? + .name + .parse::() + .ok() + .map(Handle::new)?; + self.mesh_visible_for_interaction(handle) + .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) } @@ -4654,36 +5038,43 @@ impl Scene { if self.block_meshes.is_empty() { return Vec::new(); } - let layout_block = self.current_layout_block_handle(); + let meshes = self.interaction_meshes_arc(); + let lookup = self.mesh_pick_lookup(&meshes); + let handles: Vec = candidate_handles.map_or_else( + || lookup.keys().copied().collect(), + |candidates| candidates.iter().copied().collect(), + ); let mut out = Vec::new(); - for e in self.document.entities() { - if e.common().owner_handle != layout_block { - continue; - } - let EntityType::Insert(ins) = e else { continue }; - if candidate_handles.is_some_and(|handles| !handles.contains(&ins.common.handle)) - || !self.mesh_entity_visible(ins.common.handle) + for handle in handles { + if !matches!( + self.document.get_entity(handle), + Some(EntityType::Insert(_)) + ) || !self.mesh_visible_for_interaction(handle) { continue; } - let mut sets = Vec::new(); - self.expand_block_meshes(&ins.block_name, &ins.get_transform(), 0, None, &mut sets); - let hit = sets.iter().any(|set| { - set.lods.first().map_or(false, |m| { - !pick::hit_test::mesh_box_hit( - a, - b, - crossing, - std::iter::once((ins.common.handle, m)), - view_rot, - eye, - bounds, - ) - .is_empty() - }) - }); + let Some(indices) = lookup.get(&handle) else { + continue; + }; + let hit = indices + .iter() + .filter_map(|&index| meshes.get(index as usize)) + .any(|set| { + set.lods.first().map_or(false, |m| { + !pick::hit_test::mesh_box_hit( + a, + b, + crossing, + std::iter::once((handle, m)), + view_rot, + eye, + bounds, + ) + .is_empty() + }) + }); if hit { - out.push(ins.common.handle); + out.push(handle); } } out @@ -4702,55 +5093,47 @@ impl Scene { if self.block_meshes.is_empty() { return Vec::new(); } - let layout_block = self.current_layout_block_handle(); + let meshes = self.interaction_meshes_arc(); + let lookup = self.mesh_pick_lookup(&meshes); + let handles: Vec = candidate_handles.map_or_else( + || lookup.keys().copied().collect(), + |candidates| candidates.iter().copied().collect(), + ); let mut out = Vec::new(); - for e in self.document.entities() { - if e.common().owner_handle != layout_block { - continue; - } - let EntityType::Insert(ins) = e else { continue }; - if candidate_handles.is_some_and(|handles| !handles.contains(&ins.common.handle)) - || !self.mesh_entity_visible(ins.common.handle) + for handle in handles { + if !matches!( + self.document.get_entity(handle), + Some(EntityType::Insert(_)) + ) || !self.mesh_visible_for_interaction(handle) { continue; } - let mut sets = Vec::new(); - self.expand_block_meshes(&ins.block_name, &ins.get_transform(), 0, None, &mut sets); - let hit = sets.iter().any(|set| { - set.lods.first().map_or(false, |m| { - !pick::hit_test::mesh_poly_hit( - poly, - crossing, - std::iter::once((ins.common.handle, m)), - view_rot, - eye, - bounds, - ) - .is_empty() - }) - }); + let Some(indices) = lookup.get(&handle) else { + continue; + }; + let hit = indices + .iter() + .filter_map(|&index| meshes.get(index as usize)) + .any(|set| { + set.lods.first().map_or(false, |m| { + !pick::hit_test::mesh_poly_hit( + poly, + crossing, + std::iter::once((handle, m)), + view_rot, + eye, + bounds, + ) + .is_empty() + }) + }); if hit { - out.push(ins.common.handle); + out.push(handle); } } out } - /// Tessellate all non-invisible entities owned by `block_handle`. - fn wires_for_block(&self, block_handle: Handle) -> Vec { - // Default culling is driven by the live `Scene::camera`. Multi-tile - // Model layouts and paper-space content viewports call - // `wires_for_block_culled` directly with their own per-view cull - // parameters so each pane culls independently. - self.wires_for_block_culled( - block_handle, - self.view_world_aabb(), - self.world_per_pixel(), - None, - None, - ) - } - /// Whether entity `e` renders as direct content of `block_handle` right now: /// visible (not invisible / hidden / on an off-or-frozen layer / frozen /// through the viewport) and owned by the block. Shared by the full resident diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 39d10d457..4ea1b6aa7 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -121,6 +121,30 @@ fn tris_hit_depth( None } +fn triangle_ref_hit_depth( + cursor: Point, + wire: &WireModel, + start: usize, + pick_only: bool, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Option { + let (points, low) = if pick_only { + (&wire.pick_tris, &wire.pick_tris_low) + } else { + (&wire.fill_tris, &wire.fill_tris_low) + }; + let points = points.get(start..start + 3)?; + let mut tri = [[0.0; 3]; 3]; + let mut tri_low = [[0.0; 3]; 3]; + tri.copy_from_slice(points); + for (dst, index) in tri_low.iter_mut().zip(start..start + 3) { + *dst = low.get(index).copied().unwrap_or([0.0; 3]); + } + tris_hit_depth(cursor, &tri, &tri_low, view_rot, eye, bounds) +} + /// Screen-space area of the smallest SDF glyph quad containing `cursor`. /// /// Block expansion batches same-style text runs into one [`WireModel`], so the @@ -270,20 +294,41 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( // 3D solid is. Same projected-triangle containment as `mesh_click_hit`, // front-most wins. let mut best_fill: Option<(f32, &str)> = None; - for wire in wires.iter() { - if wire.fill_tris.is_empty() { - continue; + if let Some(triangles) = wires.fill_triangles() { + for triangle in triangles { + let Some(wire) = wires.source_wire(triangle.wire) else { + continue; + }; + if let Some(depth) = triangle_ref_hit_depth( + cursor, + wire, + triangle.start as usize, + false, + view_rot, + eye, + bounds, + ) { + if best_fill.is_none_or(|(best, _)| depth < best) { + best_fill = Some((depth, wire.name.as_str())); + } + } } - if let Some(d) = tris_hit_depth( - cursor, - &wire.fill_tris, - &wire.fill_tris_low, - view_rot, - eye, - bounds, - ) { - if best_fill.map_or(true, |(bd, _)| d < bd) { - best_fill = Some((d, wire.name.as_str())); + } else { + for wire in wires.iter() { + if wire.fill_tris.is_empty() { + continue; + } + if let Some(depth) = tris_hit_depth( + cursor, + &wire.fill_tris, + &wire.fill_tris_low, + view_rot, + eye, + bounds, + ) { + if best_fill.is_none_or(|(best, _)| depth < best) { + best_fill = Some((depth, wire.name.as_str())); + } } } } @@ -301,31 +346,50 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( // surface — where the two overlap, the nearer thing to the eye is decided // by depth, but a wire that has a real fill should win on it first. let mut best_wall: Option<(f32, &str)> = None; - for wire in wires.iter() { - if wire.pick_tris.is_empty() { - continue; + if let Some(triangles) = wires.pick_triangles() { + for triangle in triangles { + let Some(wire) = wires.source_wire(triangle.wire) else { + continue; + }; + if let Some(depth) = triangle_ref_hit_depth( + cursor, + wire, + triangle.start as usize, + true, + view_rot, + eye, + bounds, + ) { + if best_wall.is_none_or(|(best, _)| depth < best) { + best_wall = Some((depth, wire.name.as_str())); + } + } } - // This runs on every hover that misses everything else — the common case - // over empty space — and a wall is two triangles per base segment, so an - // extruded circle alone is ~128 of them. Reject on the box first or a - // drawing full of thickness turns every mouse move into a projection of - // its every wall. - if z_flat - && wire.aabb != WireModel::UNBOUNDED_AABB - && aabb_rejects(wire.aabb, cursor, 0.0, view_rot, eye, bounds) - { - continue; - } - if let Some(d) = tris_hit_depth( - cursor, - &wire.pick_tris, - &wire.pick_tris_low, - view_rot, - eye, - bounds, - ) { - if best_wall.map_or(true, |(bd, _)| d < bd) { - best_wall = Some((d, wire.name.as_str())); + } else { + for wire in wires.iter() { + if wire.pick_tris.is_empty() { + continue; + } + // This runs on every hover that misses everything else — the common case + // over empty space — and a wall is two triangles per base segment, so an + // extruded circle alone is ~128 of them. Reject on the box first. + if z_flat + && wire.aabb != WireModel::UNBOUNDED_AABB + && aabb_rejects(wire.aabb, cursor, 0.0, view_rot, eye, bounds) + { + continue; + } + if let Some(depth) = tris_hit_depth( + cursor, + &wire.pick_tris, + &wire.pick_tris_low, + view_rot, + eye, + bounds, + ) { + if best_wall.is_none_or(|(best, _)| depth < best) { + best_wall = Some((depth, wire.name.as_str())); + } } } } @@ -339,14 +403,33 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( // priority — real edges and fills above always win. let mut best_area = f32::MAX; let mut best_box: Option<&str> = None; - for wire in wires.iter() { - if wire.text_verts.is_empty() { - continue; + if let Some(glyphs) = wires.glyphs() { + for glyph in glyphs { + let Some(wire) = wires.source_wire(glyph.wire) else { + continue; + }; + let start = glyph.start as usize; + let Some(vertices) = wire.text_verts.get(start..start + 6) else { + continue; + }; + if let Some(area) = text_quad_hit_area(cursor, vertices, view_rot, eye, bounds) { + if area < best_area { + best_area = area; + best_box = Some(wire.name.as_str()); + } + } } - if let Some(area) = text_quad_hit_area(cursor, &wire.text_verts, view_rot, eye, bounds) { - if area < best_area { - best_area = area; - best_box = Some(wire.name.as_str()); + } else { + for wire in wires.iter() { + if wire.text_verts.is_empty() { + continue; + } + if let Some(area) = text_quad_hit_area(cursor, &wire.text_verts, view_rot, eye, bounds) + { + if area < best_area { + best_area = area; + best_box = Some(wire.name.as_str()); + } } } } @@ -434,102 +517,148 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } } } + // Filled mesh faces join the cycle too, matching `click_hit`. Ranked at + // threshold distance because face depth is not comparable to edge distance. + if let Some(triangles) = wires.fill_triangles() { + let mut matched: HashSet = HashSet::default(); + for triangle in triangles { + if matched.contains(&triangle.wire) { + continue; + } + let Some(wire) = wires.source_wire(triangle.wire) else { + continue; + }; + if hits.iter().any(|&(_, name)| name == wire.name) { + matched.insert(triangle.wire); + continue; + } + if triangle_ref_hit_depth( + cursor, + wire, + triangle.start as usize, + false, + view_rot, + eye, + bounds, + ) + .is_some() + { + hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + matched.insert(triangle.wire); + } + } + } else { + for wire in wires.iter() { + if wire.fill_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { + continue; + } + if tris_hit_depth( + cursor, + &wire.fill_tris, + &wire.fill_tris_low, + view_rot, + eye, + bounds, + ) + .is_some() + { + hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + } + } + } + // Thickness walls join the cycle so an extruded entity picked on its wall // can be stepped past to whatever sits behind it. Ranked at the threshold, // below every proximity hit — same convention the text boxes below use. // // A wall's own edges live on the same wire, so skip any wire the loop above // already caught: cycling must not offer one entity twice. - for wire in wires.iter() { - if wire.pick_tris.is_empty() || hits.iter().any(|&(_, n)| n == wire.name) { - continue; + if let Some(triangles) = wires.pick_triangles() { + let mut matched: HashSet = HashSet::default(); + for triangle in triangles { + if matched.contains(&triangle.wire) { + continue; + } + let Some(wire) = wires.source_wire(triangle.wire) else { + continue; + }; + if hits.iter().any(|&(_, name)| name == wire.name) { + matched.insert(triangle.wire); + continue; + } + if triangle_ref_hit_depth( + cursor, + wire, + triangle.start as usize, + true, + view_rot, + eye, + bounds, + ) + .is_some() + { + hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + matched.insert(triangle.wire); + } } - if tris_hit_depth( - cursor, - &wire.pick_tris, - &wire.pick_tris_low, - view_rot, - eye, - bounds, - ) - .is_some() - { - hits.push((CLICK_THRESHOLD_PX, &wire.name)); + } else { + for wire in wires.iter() { + if wire.pick_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { + continue; + } + if tris_hit_depth( + cursor, + &wire.pick_tris, + &wire.pick_tris_low, + view_rot, + eye, + bounds, + ) + .is_some() + { + hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + } } } // SDF text: use the same exact glyph-quad test as `click_hit`; a batched // text wire's union AABB may cover empty space between distant runs (#438). // Ranked after real geometry (distance = the click threshold). - for wire in wires.iter() { - if wire.text_verts.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { - continue; + if let Some(glyphs) = wires.glyphs() { + let mut matched: HashSet = HashSet::default(); + for glyph in glyphs { + if matched.contains(&glyph.wire) { + continue; + } + let Some(wire) = wires.source_wire(glyph.wire) else { + continue; + }; + if hits.iter().any(|&(_, name)| name == wire.name) { + matched.insert(glyph.wire); + continue; + } + let start = glyph.start as usize; + let Some(vertices) = wire.text_verts.get(start..start + 6) else { + continue; + }; + if text_quad_hit_area(cursor, vertices, view_rot, eye, bounds).is_some() { + hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + matched.insert(glyph.wire); + } } - if text_quad_hit_area(cursor, &wire.text_verts, view_rot, eye, bounds).is_some() { - hits.push((CLICK_THRESHOLD_PX, &wire.name)); + } else { + for wire in wires.iter() { + if wire.text_verts.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { + continue; + } + if text_quad_hit_area(cursor, &wire.text_verts, view_rot, eye, bounds).is_some() { + hits.push((CLICK_THRESHOLD_PX, wire.name.as_str())); + } } } hits.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); hits.into_iter().map(|(_, name)| name).collect() } -/// Pick a 3D solid by clicking anywhere on its shaded body: project each -/// mesh triangle to screen space and test whether the cursor lands inside it. -/// Returns the front-most hit (smallest projected depth). Lets meshed solids -/// (whose only wire geometry is thin edges) be selected on their faces. -/// Conservative broad-phase: does the solid's 3D AABB project to a screen rect -/// that the cursor falls within (± `CLICK_THRESHOLD_PX`)? Lets the caller skip -/// ray-testing the triangles of solids whose footprint isn't under the cursor — -/// O(solids) cheap projections instead of O(triangles) per hover. Returns -/// `true` (don't cull) whenever any corner is at/behind the camera, where the -/// projected rect can't be trusted. -/// -/// `world_aabb` is `[min_x, min_y, max_x, max_y]`, `z_aabb` is `[min_z, max_z]`, -/// and `view_proj` is the same matrix `mesh_click_hit` projects vertices with. -pub fn aabb_under_cursor( - world_aabb: [f32; 4], - z_aabb: [f32; 2], - cursor: Point, - view_proj: Mat4, - eye: glam::DVec3, - bounds: Rectangle, -) -> bool { - let [min_x, min_y, max_x, max_y] = world_aabb; - let [min_z, max_z] = z_aabb; - if !min_x.is_finite() || !min_z.is_finite() { - return true; // degenerate bound — don't cull - } - let (mut sx0, mut sy0, mut sx1, mut sy1) = ( - f32::INFINITY, - f32::INFINITY, - f32::NEG_INFINITY, - f32::NEG_INFINITY, - ); - for &z in &[min_z, max_z] { - for &(x, y) in &[ - (min_x, min_y), - (max_x, min_y), - (min_x, max_y), - (max_x, max_y), - ] { - let rel = - glam::DVec3::new(x as f64 - eye.x, y as f64 - eye.y, z as f64 - eye.z).as_vec3(); - let clip = view_proj * rel.extend(1.0); - if clip.w <= 1e-6 { - return true; // corner at/behind the camera — can't bound; keep it - } - let ndc = clip.truncate() / clip.w; - let px = (ndc.x + 1.0) * 0.5 * bounds.width; - let py = (1.0 - ndc.y) * 0.5 * bounds.height; - sx0 = sx0.min(px); - sy0 = sy0.min(py); - sx1 = sx1.max(px); - sy1 = sy1.max(py); - } - } - let m = CLICK_THRESHOLD_PX; - cursor.x >= sx0 - m && cursor.x <= sx1 + m && cursor.y >= sy0 - m && cursor.y <= sy1 + m -} - pub fn mesh_click_hit<'a>( cursor: Point, meshes: impl Iterator, @@ -693,6 +822,316 @@ pub fn mesh_poly_hit<'a>( // ── Box / window selection ──────────────────────────────────────────────── +fn projected_wire_triangle( + wire: &WireModel, + start: usize, + pick_only: bool, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Option<[Point; 3]> { + let (points, low) = if pick_only { + (&wire.pick_tris, &wire.pick_tris_low) + } else { + (&wire.fill_tris, &wire.fill_tris_low) + }; + let points = points.get(start..start + 3)?; + Some(std::array::from_fn(|offset| { + world_to_screen( + wp64(points[offset], low, start + offset), + view_rot, + eye, + bounds, + ) + })) +} + +fn triangle_crosses_box(triangle: [Point; 3], corners: [Point; 4]) -> bool { + let min_x = corners[0].x; + let min_y = corners[0].y; + let max_x = corners[2].x; + let max_y = corners[2].y; + let inside_box = + |point: Point| point.x >= min_x && point.x <= max_x && point.y >= min_y && point.y <= max_y; + triangle.iter().copied().any(inside_box) + || corners + .iter() + .copied() + .any(|corner| point_in_polygon(corner, &triangle)) + || (0..3).any(|tri_edge| { + let a = triangle[tri_edge]; + let b = triangle[(tri_edge + 1) % 3]; + (0..4).any(|box_edge| { + segments_intersect(a, b, corners[box_edge], corners[(box_edge + 1) % 4]) + }) + }) +} + +fn triangle_crosses_polygon(triangle: [Point; 3], poly: &[Point]) -> bool { + triangle + .iter() + .copied() + .any(|point| point_in_polygon(point, poly)) + || poly + .iter() + .copied() + .any(|point| point_in_polygon(point, &triangle)) + || (0..3) + .any(|edge| segment_crosses_polygon(triangle[edge], triangle[(edge + 1) % 3], poly)) +} + +fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( + wires: &'a W, + corners: [Point; 4], + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut seen: HashSet<&str> = HashSet::default(); + let inside = |point: Point| { + point.x >= corners[0].x + && point.x <= corners[2].x + && point.y >= corners[0].y + && point.y <= corners[2].y + }; + let segment_hits = |a: Point, b: Point| { + inside(a) + || inside(b) + || (0..4).any(|edge| segments_intersect(a, b, corners[edge], corners[(edge + 1) % 4])) + }; + + for segment in wires.segments().unwrap_or_default() { + let Some(wire) = wires.source_wire(segment.wire) else { + continue; + }; + let start = segment.start as usize; + if start + 1 >= wire.points.len() { + continue; + } + let a = world_to_screen( + wp64(wire.points[start], &wire.points_low, start), + view_rot, + eye, + bounds, + ); + let b = world_to_screen( + wp64(wire.points[start + 1], &wire.points_low, start + 1), + view_rot, + eye, + bounds, + ); + if segment_hits(a, b) && seen.insert(wire.name.as_str()) { + out.push(wire.name.as_str()); + } + } + for (triangles, pick_only) in [ + (wires.fill_triangles().unwrap_or_default(), false), + (wires.pick_triangles().unwrap_or_default(), true), + ] { + for triangle in triangles { + let Some(wire) = wires.source_wire(triangle.wire) else { + continue; + }; + if seen.contains(wire.name.as_str()) { + continue; + } + if projected_wire_triangle( + wire, + triangle.start as usize, + pick_only, + view_rot, + eye, + bounds, + ) + .is_some_and(|triangle| triangle_crosses_box(triangle, corners)) + && seen.insert(wire.name.as_str()) + { + out.push(wire.name.as_str()); + } + } + } + for glyph in wires.glyphs().unwrap_or_default() { + let Some(wire) = wires.source_wire(glyph.wire) else { + continue; + }; + if seen.contains(wire.name.as_str()) { + continue; + } + let start = glyph.start as usize; + let Some(vertices) = wire.text_verts.get(start..start + 6) else { + continue; + }; + let screen: [Point; 6] = std::array::from_fn(|offset| { + let vertex = vertices[offset]; + world_to_screen( + glam::DVec3::new( + vertex.pos[0] as f64 + vertex.pos_low[0] as f64, + vertex.pos[1] as f64 + vertex.pos_low[1] as f64, + vertex.pos[2] as f64 + vertex.pos_low[2] as f64, + ), + view_rot, + eye, + bounds, + ) + }); + if [0usize, 3].into_iter().any(|offset| { + triangle_crosses_box( + [screen[offset], screen[offset + 1], screen[offset + 2]], + corners, + ) + }) && seen.insert(wire.name.as_str()) + { + out.push(wire.name.as_str()); + } + } + + // Degenerate point-only wires have no indexed segment or surface primitive. + for wire in wires.iter() { + if seen.contains(wire.name.as_str()) + || wire.points.len() >= 2 + || !wire.fill_tris.is_empty() + || !wire.pick_tris.is_empty() + || !wire.text_verts.is_empty() + { + continue; + } + if wire.points.iter().enumerate().any(|(index, &point)| { + point[0].is_finite() + && inside(world_to_screen( + wp64(point, &wire.points_low, index), + view_rot, + eye, + bounds, + )) + }) && seen.insert(wire.name.as_str()) + { + out.push(wire.name.as_str()); + } + } + out +} + +fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( + wires: &'a W, + poly: &[Point], + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut seen: HashSet<&str> = HashSet::default(); + for segment in wires.segments().unwrap_or_default() { + let Some(wire) = wires.source_wire(segment.wire) else { + continue; + }; + let start = segment.start as usize; + if start + 1 >= wire.points.len() { + continue; + } + let a = world_to_screen( + wp64(wire.points[start], &wire.points_low, start), + view_rot, + eye, + bounds, + ); + let b = world_to_screen( + wp64(wire.points[start + 1], &wire.points_low, start + 1), + view_rot, + eye, + bounds, + ); + if (point_in_polygon(a, poly) + || point_in_polygon(b, poly) + || segment_crosses_polygon(a, b, poly)) + && seen.insert(wire.name.as_str()) + { + out.push(wire.name.as_str()); + } + } + for (triangles, pick_only) in [ + (wires.fill_triangles().unwrap_or_default(), false), + (wires.pick_triangles().unwrap_or_default(), true), + ] { + for triangle in triangles { + let Some(wire) = wires.source_wire(triangle.wire) else { + continue; + }; + if seen.contains(wire.name.as_str()) { + continue; + } + if projected_wire_triangle( + wire, + triangle.start as usize, + pick_only, + view_rot, + eye, + bounds, + ) + .is_some_and(|triangle| triangle_crosses_polygon(triangle, poly)) + && seen.insert(wire.name.as_str()) + { + out.push(wire.name.as_str()); + } + } + } + for glyph in wires.glyphs().unwrap_or_default() { + let Some(wire) = wires.source_wire(glyph.wire) else { + continue; + }; + if seen.contains(wire.name.as_str()) { + continue; + } + let start = glyph.start as usize; + let Some(vertices) = wire.text_verts.get(start..start + 6) else { + continue; + }; + let screen: [Point; 6] = std::array::from_fn(|offset| { + let vertex = vertices[offset]; + world_to_screen( + glam::DVec3::new( + vertex.pos[0] as f64 + vertex.pos_low[0] as f64, + vertex.pos[1] as f64 + vertex.pos_low[1] as f64, + vertex.pos[2] as f64 + vertex.pos_low[2] as f64, + ), + view_rot, + eye, + bounds, + ) + }); + if [0usize, 3].into_iter().any(|offset| { + triangle_crosses_polygon( + [screen[offset], screen[offset + 1], screen[offset + 2]], + poly, + ) + }) && seen.insert(wire.name.as_str()) + { + out.push(wire.name.as_str()); + } + } + for wire in wires.iter() { + if seen.contains(wire.name.as_str()) + || wire.points.len() >= 2 + || !wire.fill_tris.is_empty() + || !wire.pick_tris.is_empty() + || !wire.text_verts.is_empty() + { + continue; + } + if wire.points.iter().enumerate().any(|(index, &point)| { + point[0].is_finite() + && point_in_polygon( + world_to_screen(wp64(point, &wire.points_low, index), view_rot, eye, bounds), + poly, + ) + }) && seen.insert(wire.name.as_str()) + { + out.push(wire.name.as_str()); + } + } + out +} + /// Return the names of wires selected by a completed rectangular selection box. /// /// - **Window mode** (`crossing = false`, left→right drag): @@ -730,6 +1169,15 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( let box_tr = Point { x: max_x, y: min_y }; let box_bl = Point { x: min_x, y: max_y }; let box_br = Point { x: max_x, y: max_y }; + if crossing && wires.segments().is_some() { + return indexed_box_crossing_hits( + wires, + [box_tl, box_tr, box_br, box_bl], + view_rot, + eye, + bounds, + ); + } // Q: lazy projection — accumulate screen points without allocating per-wire Vec. wires @@ -824,6 +1272,9 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( if poly.len() < 3 { return vec![]; } + if crossing && wires.segments().is_some() { + return indexed_polygon_crossing_hits(wires, poly, view_rot, eye, bounds); + } // Q: lazy projection — no Vec allocation per wire. wires @@ -1039,24 +1490,37 @@ pub fn click_hit_hatch( None } -/// Same as `click_hit_hatch` but iterates `(Handle, HatchModel)` pairs -/// where the Handle is the parent Insert handle (block-internal -/// hatches). The first matching pair returns its Insert handle so +/// Same as `click_hit_hatch` but tests block-internal hatches grouped by +/// their parent Insert handle. The first matching model returns its Insert so /// clicking a sub-hatch of a block selects the Insert, matching /// AutoCAD's behaviour for block sub-entities. pub fn click_hit_insert_hatch( cursor: Point, - insert_hatches: &[(Handle, HatchModel)], + insert_hatches: &HashMap>, view_rot: Mat4, eye: glam::DVec3, bounds: Rectangle, candidate_handles: Option<&HashSet>, ) -> Option { - for (handle, hatch) in insert_hatches { - if candidate_handles.is_some_and(|handles| !handles.contains(handle)) { - continue; + if let Some(handles) = candidate_handles { + for handle in handles { + let Some(hatches) = insert_hatches.get(handle) else { + continue; + }; + if hatches + .iter() + .any(|hatch| hatch_contains_screen_point(hatch, cursor, view_rot, eye, bounds)) + { + return Some(*handle); + } } - if hatch_contains_screen_point(hatch, cursor, view_rot, eye, bounds) { + return None; + } + for (handle, hatches) in insert_hatches { + if hatches + .iter() + .any(|hatch| hatch_contains_screen_point(hatch, cursor, view_rot, eye, bounds)) + { return Some(*handle); } } @@ -1101,6 +1565,80 @@ fn hatch_contains_screen_point( } /// Return Handles of hatches selected by a completed rectangular selection box. +fn hatch_box_hit( + corner_a: Point, + corner_b: Point, + crossing: bool, + hatch: &HatchModel, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> bool { + let min_x = corner_a.x.min(corner_b.x); + let max_x = corner_a.x.max(corner_b.x); + let min_y = corner_a.y.min(corner_b.y); + let max_y = corner_a.y.max(corner_b.y); + if (max_x - min_x) < 1.0 || (max_y - min_y) < 1.0 || hatch.boundary.is_empty() { + return false; + } + let inside = + |point: Point| point.x >= min_x && point.x <= max_x && point.y >= min_y && point.y <= max_y; + let (ox, oy) = (hatch.world_origin[0], hatch.world_origin[1]); + let screen: Vec = hatch + .boundary + .iter() + .map(|&[x, y]| { + if x.is_finite() && y.is_finite() { + world_to_screen( + glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), + view_rot, + eye, + bounds, + ) + } else { + Point::new(f32::NAN, f32::NAN) + } + }) + .collect(); + if !screen + .iter() + .any(|point| point.x.is_finite() && point.y.is_finite()) + { + return false; + } + if !crossing { + return screen + .iter() + .filter(|point| point.x.is_finite() && point.y.is_finite()) + .copied() + .all(inside); + } + let corners = [ + Point::new(min_x, min_y), + Point::new(max_x, min_y), + Point::new(max_x, max_y), + Point::new(min_x, max_y), + ]; + screen.iter().copied().any(inside) + || screen + .windows(2) + .filter(|edge| { + edge[0].x.is_finite() + && edge[0].y.is_finite() + && edge[1].x.is_finite() + && edge[1].y.is_finite() + }) + .any(|edge| { + (0..4).any(|side| { + segments_intersect(edge[0], edge[1], corners[side], corners[(side + 1) % 4]) + }) + }) + || corners + .iter() + .copied() + .any(|corner| point_in_polygon(corner, &screen)) +} + pub fn box_hit_hatch( corner_a: Point, corner_b: Point, @@ -1111,54 +1649,123 @@ pub fn box_hit_hatch( bounds: Rectangle, candidate_handles: Option<&HashSet>, ) -> Vec { - let min_x = corner_a.x.min(corner_b.x); - let max_x = corner_a.x.max(corner_b.x); - let min_y = corner_a.y.min(corner_b.y); - let max_y = corner_a.y.max(corner_b.y); - - if (max_x - min_x) < 1.0 || (max_y - min_y) < 1.0 { - return vec![]; - } - - let inside = |sp: Point| sp.x >= min_x && sp.x <= max_x && sp.y >= min_y && sp.y <= max_y; - hatches .iter() .filter_map(|(&handle, hatch)| { if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) { return None; } - if hatch.boundary.is_empty() { - return None; - } - let (ox, oy) = (hatch.world_origin[0], hatch.world_origin[1]); - let screen: Vec = hatch - .boundary - .iter() - .map(|&[x, y]| { - world_to_screen( - glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), - view_rot, - eye, - bounds, - ) - }) - .collect(); - let hit = if crossing { - screen.iter().any(|&sp| inside(sp)) - } else { - screen.iter().all(|&sp| inside(sp)) - }; - if hit { - Some(handle) - } else { - None - } + hatch_box_hit(corner_a, corner_b, crossing, hatch, view_rot, eye, bounds) + .then_some(handle) }) .collect() } +pub fn box_hit_insert_hatch( + corner_a: Point, + corner_b: Point, + crossing: bool, + insert_hatches: &HashMap>, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, + candidate_handles: Option<&HashSet>, +) -> Vec { + let mut out = Vec::new(); + if let Some(handles) = candidate_handles { + for handle in handles { + if insert_hatches.get(handle).is_some_and(|hatches| { + let test = |hatch: &HatchModel| { + hatch_box_hit(corner_a, corner_b, crossing, hatch, view_rot, eye, bounds) + }; + if crossing { + hatches.iter().any(test) + } else { + !hatches.is_empty() && hatches.iter().all(test) + } + }) { + out.push(*handle); + } + } + } else { + for (handle, hatches) in insert_hatches { + let test = |hatch: &HatchModel| { + hatch_box_hit(corner_a, corner_b, crossing, hatch, view_rot, eye, bounds) + }; + let hit = if crossing { + hatches.iter().any(test) + } else { + !hatches.is_empty() && hatches.iter().all(test) + }; + if hit { + out.push(*handle); + } + } + } + out +} + /// Return Handles of hatches selected by a freehand polygon lasso. +fn hatch_polygon_hit( + poly: &[Point], + crossing: bool, + hatch: &HatchModel, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, +) -> bool { + if poly.len() < 3 || hatch.boundary.is_empty() { + return false; + } + let (ox, oy) = (hatch.world_origin[0], hatch.world_origin[1]); + let screen: Vec = hatch + .boundary + .iter() + .map(|&[x, y]| { + if x.is_finite() && y.is_finite() { + world_to_screen( + glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), + view_rot, + eye, + bounds, + ) + } else { + Point::new(f32::NAN, f32::NAN) + } + }) + .collect(); + if !screen + .iter() + .any(|point| point.x.is_finite() && point.y.is_finite()) + { + return false; + } + if crossing { + screen + .iter() + .copied() + .any(|point| point_in_polygon(point, poly)) + || screen + .windows(2) + .filter(|edge| { + edge[0].x.is_finite() + && edge[0].y.is_finite() + && edge[1].x.is_finite() + && edge[1].y.is_finite() + }) + .any(|edge| segment_crosses_polygon(edge[0], edge[1], poly)) + || poly + .iter() + .copied() + .any(|point| point_in_polygon(point, &screen)) + } else { + screen + .iter() + .filter(|point| point.x.is_finite() && point.y.is_finite()) + .all(|point| point_in_polygon(*point, poly)) + } +} + pub fn poly_hit_hatch( poly: &[Point], crossing: bool, @@ -1168,49 +1775,60 @@ pub fn poly_hit_hatch( bounds: Rectangle, candidate_handles: Option<&HashSet>, ) -> Vec { - if poly.len() < 3 { - return vec![]; - } - hatches .iter() .filter_map(|(&handle, hatch)| { if candidate_handles.is_some_and(|handles| !handles.contains(&handle)) { return None; } - if hatch.boundary.is_empty() { - return None; - } - let (ox, oy) = (hatch.world_origin[0], hatch.world_origin[1]); - let screen: Vec = hatch - .boundary - .iter() - .map(|&[x, y]| { - world_to_screen( - glam::DVec3::new(x as f64 + ox, y as f64 + oy, 0.0), - view_rot, - eye, - bounds, - ) - }) - .collect(); - let hit = if crossing { - screen.iter().any(|&sp| point_in_polygon(sp, poly)) - || screen - .windows(2) - .any(|seg| segment_crosses_polygon(seg[0], seg[1], poly)) - } else { - screen.iter().all(|&sp| point_in_polygon(sp, poly)) - }; - if hit { - Some(handle) - } else { - None - } + hatch_polygon_hit(poly, crossing, hatch, view_rot, eye, bounds).then_some(handle) }) .collect() } +pub fn poly_hit_insert_hatch( + poly: &[Point], + crossing: bool, + insert_hatches: &HashMap>, + view_rot: Mat4, + eye: glam::DVec3, + bounds: Rectangle, + candidate_handles: Option<&HashSet>, +) -> Vec { + let mut out = Vec::new(); + if let Some(handles) = candidate_handles { + for handle in handles { + if insert_hatches.get(handle).is_some_and(|hatches| { + let test = |hatch: &HatchModel| { + hatch_polygon_hit(poly, crossing, hatch, view_rot, eye, bounds) + }; + if crossing { + hatches.iter().any(test) + } else { + !hatches.is_empty() && hatches.iter().all(test) + } + }) { + out.push(*handle); + } + } + } else { + for (handle, hatches) in insert_hatches { + let test = |hatch: &HatchModel| { + hatch_polygon_hit(poly, crossing, hatch, view_rot, eye, bounds) + }; + let hit = if crossing { + hatches.iter().any(test) + } else { + !hatches.is_empty() && hatches.iter().all(test) + }; + if hit { + out.push(*handle); + } + } + } + out +} + /// Minimum distance from point `p` to line segment `[a, b]` in 2-D. fn dist_point_to_segment(p: Point, a: Point, b: Point) -> f32 { let abx = b.x - a.x; diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 6957199d6..6a3cf44bb 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -1,15 +1,19 @@ //! Shared spatial broad phase for cursor and area interactions. //! //! Exact snap and selection rules remain in their consumers. This index only -//! narrows a large, camera-independent Model wire set to wire and segment -//! candidates whose world-XY bounds overlap the interaction aperture. +//! narrows large resident wire sets to local wire/sub-primitive candidates: +//! compact world-XY grids for plan views and projected 3D BVHs for tilted or +//! perspective views. use crate::scene::model::wire_model::WireModel; +use glam::{DVec3, Mat4}; +use iced::Rectangle; use std::sync::Arc; const TARGET_ENTRIES_PER_CELL: usize = 8; const MAX_SPAN_CELLS: u64 = 64; const MAX_AXIS_CELLS: u32 = 8_192; +const BVH_LEAF_SIZE: usize = 16; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct SegmentRef { @@ -17,43 +21,291 @@ pub struct SegmentRef { pub start: u32, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SnapPointRef { + pub wire: u32, + pub index: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct KeyVertexRef { + pub wire: u32, + pub index: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct KeySegmentRef { + pub wire: u32, + pub start: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct TriangleRef { + pub wire: u32, + pub start: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct GlyphRef { + pub wire: u32, + pub start: u32, +} + #[derive(Clone, Copy)] -struct Entry { - aabb: [f64; 4], +struct Entry3 { + aabb: [f64; 6], value: T, } -struct SpatialGrid { +fn entry_aabb2(entry: &Entry3) -> [f64; 4] { + [entry.aabb[0], entry.aabb[1], entry.aabb[3], entry.aabb[4]] +} + +#[derive(Clone, Copy)] +struct BvhNode2 { + aabb: [f64; 4], + left: u32, + right: u32, + start: u32, + len: u32, +} + +struct SpatialBvh2 { + nodes: Vec, + order: Vec, +} + +impl SpatialBvh2 { + fn build(entries: &[Entry3], mut order: Vec) -> Self { + let mut nodes = Vec::new(); + if !order.is_empty() { + build_bvh2_node(entries, &mut order, 0, &mut nodes); + } + Self { nodes, order } + } + + fn query(&self, entries: &[Entry3], query: [f64; 4], out: &mut Vec) { + if self.nodes.is_empty() { + return; + } + let mut stack = vec![0u32]; + while let Some(node_idx) = stack.pop() { + let node = self.nodes[node_idx as usize]; + if !aabb_overlaps(node.aabb, query) { + continue; + } + if node.left == u32::MAX { + for &entry_idx in &self.order[node.start as usize..(node.start + node.len) as usize] + { + if aabb_overlaps(entry_aabb2(&entries[entry_idx as usize]), query) { + out.push(entry_idx); + } + } + } else { + stack.push(node.left); + stack.push(node.right); + } + } + } +} + +fn build_bvh2_node( + entries: &[Entry3], + order: &mut [u32], + base: usize, + nodes: &mut Vec, +) -> u32 { + let mut bounds = [ + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ]; + for &idx in order.iter() { + let aabb = entry_aabb2(&entries[idx as usize]); + bounds[0] = bounds[0].min(aabb[0]); + bounds[1] = bounds[1].min(aabb[1]); + bounds[2] = bounds[2].max(aabb[2]); + bounds[3] = bounds[3].max(aabb[3]); + } + let node_idx = nodes.len() as u32; + nodes.push(BvhNode2 { + aabb: bounds, + left: u32::MAX, + right: u32::MAX, + start: base as u32, + len: order.len() as u32, + }); + if order.len() <= BVH_LEAF_SIZE { + return node_idx; + } + let axis = usize::from(bounds[3] - bounds[1] > bounds[2] - bounds[0]); + order.sort_unstable_by(|&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); + nodes[node_idx as usize].left = left; + nodes[node_idx as usize].right = right; + node_idx +} + +#[derive(Clone, Copy)] +struct BvhNode3 { + aabb: [f64; 6], + left: u32, + right: u32, + start: u32, + len: u32, +} + +struct SpatialBvh3 { + entries: Vec>, + nodes: Vec, + order: Vec, +} + +impl SpatialBvh3 { + fn build(entries: Vec>) -> Self { + let mut order: Vec = (0..entries.len() as u32).collect(); + let mut nodes = Vec::new(); + if !order.is_empty() { + build_bvh3_node(&entries, &mut order, 0, &mut nodes); + } + Self { + entries, + nodes, + order, + } + } + + fn query_screen( + &self, + screen_rect: [f32; 4], + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, + ) -> Vec { + if self.nodes.is_empty() { + return Vec::new(); + } + let mut stack = vec![0u32]; + let mut entry_indices = Vec::new(); + while let Some(node_idx) = stack.pop() { + let node = self.nodes[node_idx as usize]; + if !aabb3_projects_into(node.aabb, screen_rect, view_rot, eye, bounds) { + continue; + } + if node.left == u32::MAX { + for &entry_idx in &self.order[node.start as usize..(node.start + node.len) as usize] + { + let entry = self.entries[entry_idx as usize]; + if aabb3_projects_into(entry.aabb, screen_rect, view_rot, eye, bounds) { + entry_indices.push(entry.value); + } + } + } else { + stack.push(node.left); + stack.push(node.right); + } + } + entry_indices.sort_unstable(); + entry_indices.dedup(); + entry_indices + } +} + +fn build_bvh3_node( + entries: &[Entry3], + order: &mut [u32], + base: usize, + nodes: &mut Vec, +) -> u32 { + let mut bounds = [ + f64::INFINITY, + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ]; + for &idx in order.iter() { + let aabb = entries[idx as usize].aabb; + for axis in 0..3 { + bounds[axis] = bounds[axis].min(aabb[axis]); + bounds[axis + 3] = bounds[axis + 3].max(aabb[axis + 3]); + } + } + let node_idx = nodes.len() as u32; + nodes.push(BvhNode3 { + aabb: bounds, + left: u32::MAX, + right: u32::MAX, + start: base as u32, + len: order.len() as u32, + }); + if order.len() <= BVH_LEAF_SIZE { + return node_idx; + } + let mut axis = 0; + for candidate in 1..3 { + if bounds[candidate + 3] - bounds[candidate] > bounds[axis + 3] - bounds[axis] { + axis = candidate; + } + } + order.sort_unstable_by(|&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); + nodes[node_idx as usize].left = left; + nodes[node_idx as usize].right = right; + node_idx +} + +struct SpatialGrid { min: [f64; 2], cell: f64, cols: u32, rows: u32, - entries: Vec>, - cells: Vec>, - oversized: Vec, + cell_offsets: Vec, + cell_entries: Vec, + oversized: SpatialBvh2, } -impl SpatialGrid { - fn build(entries: Vec>) -> Self { +impl SpatialGrid { + fn build(entries: &[Entry3]) -> Self { if entries.is_empty() { return Self { min: [0.0; 2], cell: 1.0, cols: 1, rows: 1, - entries, - cells: vec![Vec::new()], - oversized: Vec::new(), + cell_offsets: vec![0, 0], + cell_entries: Vec::new(), + oversized: SpatialBvh2 { + nodes: Vec::new(), + order: Vec::new(), + }, }; } let mut min = [f64::INFINITY; 2]; let mut max = [f64::NEG_INFINITY; 2]; - for entry in &entries { - min[0] = min[0].min(entry.aabb[0]); - min[1] = min[1].min(entry.aabb[1]); - max[0] = max[0].max(entry.aabb[2]); - max[1] = max[1].max(entry.aabb[3]); + for entry in entries { + let aabb = entry_aabb2(entry); + min[0] = min[0].min(aabb[0]); + min[1] = min[1].min(aabb[1]); + max[0] = max[0].max(aabb[2]); + max[1] = max[1].max(aabb[3]); } let ext_x = (max[0] - min[0]).max(1e-9); let ext_y = (max[1] - min[1]).max(1e-9); @@ -65,17 +317,19 @@ impl SpatialGrid { .max(1e-9); let cols = (((ext_x / cell).ceil() as u64) + 1).clamp(1, MAX_AXIS_CELLS as u64) as u32; let rows = (((ext_y / cell).ceil() as u64) + 1).clamp(1, MAX_AXIS_CELLS as u64) as u32; - let mut cells = vec![Vec::new(); cols as usize * rows as usize]; + let cell_count = cols as usize * rows as usize; + let mut counts = vec![0u32; cell_count]; let mut oversized = Vec::new(); let col_of = |x: f64| (((x - min[0]) / cell).floor()).clamp(0.0, (cols - 1) as f64) as u32; let row_of = |y: f64| (((y - min[1]) / cell).floor()).clamp(0.0, (rows - 1) as f64) as u32; for (idx, entry) in entries.iter().enumerate() { - let c0 = col_of(entry.aabb[0]); - let c1 = col_of(entry.aabb[2]); - let r0 = row_of(entry.aabb[1]); - let r1 = row_of(entry.aabb[3]); + let aabb = entry_aabb2(entry); + let c0 = col_of(aabb[0]); + let c1 = col_of(aabb[2]); + let r0 = row_of(aabb[1]); + let r1 = row_of(aabb[3]); let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64; if span > MAX_SPAN_CELLS { oversized.push(idx as u32); @@ -84,24 +338,53 @@ impl SpatialGrid { for row in r0..=r1 { let base = row as usize * cols as usize; for col in c0..=c1 { - cells[base + col as usize].push(idx as u32); + counts[base + col as usize] += 1; } } } + let mut cell_offsets = Vec::with_capacity(cell_count + 1); + cell_offsets.push(0); + for count in counts { + cell_offsets.push(cell_offsets.last().copied().unwrap_or(0) + count); + } + let mut cell_entries = vec![0u32; *cell_offsets.last().unwrap_or(&0) as usize]; + let mut cursors = cell_offsets[..cell_count].to_vec(); + for (idx, entry) in entries.iter().enumerate() { + let aabb = entry_aabb2(entry); + let c0 = col_of(aabb[0]); + let c1 = col_of(aabb[2]); + let r0 = row_of(aabb[1]); + let r1 = row_of(aabb[3]); + let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64; + if span > MAX_SPAN_CELLS { + continue; + } + for row in r0..=r1 { + let base = row as usize * cols as usize; + for col in c0..=c1 { + let cell_idx = base + col as usize; + let cursor = &mut cursors[cell_idx]; + cell_entries[*cursor as usize] = idx as u32; + *cursor += 1; + } + } + } + let oversized = SpatialBvh2::build(&entries, oversized); Self { min, cell, cols, rows, - entries, - cells, + cell_offsets, + cell_entries, oversized, } } - fn query(&self, query: [f64; 4]) -> Vec { - let mut entry_indices = self.oversized.clone(); + fn query(&self, entries: &[Entry3], query: [f64; 4]) -> Vec { + let mut entry_indices = Vec::new(); + self.oversized.query(entries, query, &mut entry_indices); let grid_max_x = self.min[0] + self.cols as f64 * self.cell; let grid_max_y = self.min[1] + self.rows as f64 * self.cell; if query[2] >= self.min[0] @@ -118,7 +401,10 @@ impl SpatialGrid { for r in row(query[1])..=row(query[3]) { let base = r as usize * self.cols as usize; for c in col(query[0])..=col(query[2]) { - entry_indices.extend_from_slice(&self.cells[base + c as usize]); + let cell_idx = base + c as usize; + let start = self.cell_offsets[cell_idx] as usize; + let end = self.cell_offsets[cell_idx + 1] as usize; + entry_indices.extend_from_slice(&self.cell_entries[start..end]); } } } @@ -128,8 +414,8 @@ impl SpatialGrid { let mut out: Vec = entry_indices .into_iter() .filter_map(|idx| { - let entry = self.entries.get(idx as usize)?; - aabb_overlaps(entry.aabb, query).then_some(entry.value) + let entry = entries.get(idx as usize)?; + aabb_overlaps(entry_aabb2(entry), query).then_some(entry.value) }) .collect(); out.sort_unstable(); @@ -138,93 +424,358 @@ impl SpatialGrid { } } +struct SpatialSet { + xy: SpatialGrid, + xyz: SpatialBvh3, +} + +impl SpatialSet { + fn build(entries: Vec>) -> Self { + let xy = SpatialGrid::build(&entries); + Self { + xy, + xyz: SpatialBvh3::build(entries), + } + } + + fn query_xy(&self, aabb: [f64; 4]) -> Vec { + self.xy.query(&self.xyz.entries, aabb) + } + + fn query_screen( + &self, + screen_rect: [f32; 4], + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, + ) -> Vec { + self.xyz.query_screen(screen_rect, view_rot, eye, bounds) + } +} + pub struct InteractionIndex { - wires: SpatialGrid, - segments: SpatialGrid, + wires: SpatialSet, + segments: SpatialSet, + snap_points: SpatialSet, + key_vertices: SpatialSet, + key_segments: SpatialSet, + fill_triangles: SpatialSet, + pick_triangles: SpatialSet, + glyphs: SpatialSet, unbounded_wires: Vec, + max_line_half_width_px: f32, } pub struct InteractionHandleIndex { - handles: SpatialGrid, + handles: SpatialSet, } impl InteractionHandleIndex { - pub fn build(entries: impl IntoIterator) -> Self { + pub fn build(entries: impl IntoIterator) -> Self { Self { - handles: SpatialGrid::build( + handles: SpatialSet::build( entries .into_iter() .filter(|(_, aabb)| { aabb.iter().all(|value| value.is_finite()) - && aabb[0] <= aabb[2] - && aabb[1] <= aabb[3] + && aabb[0] <= aabb[3] + && aabb[1] <= aabb[4] + && aabb[2] <= aabb[5] }) - .map(|(value, aabb)| Entry { aabb, value }) + .map(|(value, aabb)| Entry3 { aabb, value }) .collect(), ), } } - pub fn query(&self, aabb: [f64; 4]) -> Vec { - self.handles.query(aabb) + pub fn query_xy(&self, aabb: [f64; 4]) -> Vec { + self.handles.query_xy(aabb) + } + + pub fn query_screen( + &self, + screen_rect: [f32; 4], + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, + ) -> Vec { + self.handles + .query_screen(screen_rect, view_rot, eye, bounds) } } impl InteractionIndex { + pub fn estimated_work(wires: &[WireModel]) -> usize { + wires.iter().fold(0usize, |total, wire| { + total + .saturating_add(wire.points.len()) + .saturating_add(wire.key_vertices.len()) + .saturating_add(wire.snap_pts.len() * 2) + .saturating_add(wire.fill_tris.len()) + .saturating_add(wire.pick_tris.len()) + .saturating_add(wire.text_verts.len() / 2) + }) + } + pub fn build(wires: &[WireModel]) -> Self { let mut wire_entries = Vec::with_capacity(wires.len()); let mut segment_entries = Vec::new(); + let mut snap_point_entries = Vec::new(); + let mut key_vertex_entries = Vec::new(); + let mut key_segment_entries = Vec::new(); + let mut fill_triangle_entries = Vec::new(); + let mut pick_triangle_entries = Vec::new(); + let mut glyph_entries = Vec::new(); let mut unbounded_wires = Vec::new(); + let mut max_line_half_width_px = 0.0f32; for (wire_idx, wire) in wires.iter().enumerate() { - if let Some(aabb) = finite_wire_aabb(wire) { - wire_entries.push(Entry { + if wire.line_weight_px.is_finite() { + max_line_half_width_px = + max_line_half_width_px.max((wire.line_weight_px * 0.5).max(0.0)); + } + let wire_idx = wire_idx as u32; + if let Some(aabb) = finite_wire_aabb3(wire) { + wire_entries.push(Entry3 { aabb, - value: wire_idx as u32, + value: wire_idx, }); } else { - unbounded_wires.push(wire_idx as u32); + unbounded_wires.push(wire_idx); } for start in 0..wire.points.len().saturating_sub(1) { let a = wire_point(wire, start); let b = wire_point(wire, start + 1); - if !a[0].is_finite() || !a[1].is_finite() || !b[0].is_finite() || !b[1].is_finite() - { + let Some(aabb) = points_aabb3([a, b]) else { + continue; + }; + segment_entries.push(Entry3 { + aabb, + value: SegmentRef { + wire: wire_idx, + start: start as u32, + }, + }); + } + for (index, (point, _)) in wire.snap_pts.iter().enumerate() { + if !point.is_finite() { continue; } - segment_entries.push(Entry { - aabb: [ - a[0].min(b[0]), - a[1].min(b[1]), - a[0].max(b[0]), - a[1].max(b[1]), - ], - value: SegmentRef { - wire: wire_idx as u32, + snap_point_entries.push(Entry3 { + aabb: [point.x, point.y, point.z, point.x, point.y, point.z], + value: SnapPointRef { + wire: wire_idx, + index: index as u32, + }, + }); + } + for (index, &point) in wire.key_vertices.iter().enumerate() { + if !point.iter().all(|value| value.is_finite()) { + continue; + } + key_vertex_entries.push(Entry3 { + aabb: [point[0], point[1], point[2], point[0], point[1], point[2]], + value: KeyVertexRef { + wire: wire_idx, + index: index as u32, + }, + }); + } + for start in 0..wire.key_vertices.len().saturating_sub(1) { + let Some(aabb) = + points_aabb3([wire.key_vertices[start], wire.key_vertices[start + 1]]) + else { + continue; + }; + key_segment_entries.push(Entry3 { + aabb, + value: KeySegmentRef { + wire: wire_idx, + start: start as u32, + }, + }); + } + append_triangle_entries( + wire_idx, + &wire.fill_tris, + &wire.fill_tris_low, + &mut fill_triangle_entries, + ); + append_triangle_entries( + wire_idx, + &wire.pick_tris, + &wire.pick_tris_low, + &mut pick_triangle_entries, + ); + for start in (0..wire.text_verts.len()).step_by(6) { + let Some(quad) = wire.text_verts.get(start..start + 6) else { + break; + }; + let Some(aabb) = points_aabb3(quad.iter().map(|vertex| { + [ + vertex.pos[0] as f64 + vertex.pos_low[0] as f64, + vertex.pos[1] as f64 + vertex.pos_low[1] as f64, + vertex.pos[2] as f64 + vertex.pos_low[2] as f64, + ] + })) else { + continue; + }; + glyph_entries.push(Entry3 { + aabb, + value: GlyphRef { + wire: wire_idx, start: start as u32, }, }); } } + #[cfg(not(target_arch = "wasm32"))] + let ( + ((wires, segments), (snap_points, key_vertices)), + ((key_segments, fill_triangles), (pick_triangles, glyphs)), + ) = rayon::join( + || { + rayon::join( + || { + rayon::join( + || SpatialSet::build(wire_entries), + || SpatialSet::build(segment_entries), + ) + }, + || { + rayon::join( + || SpatialSet::build(snap_point_entries), + || SpatialSet::build(key_vertex_entries), + ) + }, + ) + }, + || { + rayon::join( + || { + rayon::join( + || SpatialSet::build(key_segment_entries), + || SpatialSet::build(fill_triangle_entries), + ) + }, + || { + rayon::join( + || SpatialSet::build(pick_triangle_entries), + || SpatialSet::build(glyph_entries), + ) + }, + ) + }, + ); + #[cfg(target_arch = "wasm32")] + let ( + wires, + segments, + snap_points, + key_vertices, + key_segments, + fill_triangles, + pick_triangles, + glyphs, + ) = ( + SpatialSet::build(wire_entries), + SpatialSet::build(segment_entries), + SpatialSet::build(snap_point_entries), + SpatialSet::build(key_vertex_entries), + SpatialSet::build(key_segment_entries), + SpatialSet::build(fill_triangle_entries), + SpatialSet::build(pick_triangle_entries), + SpatialSet::build(glyph_entries), + ); + Self { - wires: SpatialGrid::build(wire_entries), - segments: SpatialGrid::build(segment_entries), + wires, + segments, + snap_points, + key_vertices, + key_segments, + fill_triangles, + pick_triangles, + glyphs, unbounded_wires, + max_line_half_width_px, } } - pub fn query(&self, wires: Arc>, aabb: [f64; 4]) -> InteractionCandidates { - let mut wire_indices = self.wires.query(aabb); + pub fn pick_radius_px(&self, base_radius_px: f32) -> f32 { + base_radius_px.max(self.max_line_half_width_px) + } + + pub fn query_xy(&self, wires: Arc>, aabb: [f64; 4]) -> InteractionCandidates { + let mut wire_indices = self.wires.query_xy(aabb); wire_indices.extend_from_slice(&self.unbounded_wires); wire_indices.sort_unstable(); wire_indices.dedup(); InteractionCandidates { wires, wire_indices: Some(wire_indices), - segments: Some(self.segments.query(aabb)), + segments: Some(self.segments.query_xy(aabb)), + snap_points: Some(self.snap_points.query_xy(aabb)), + key_vertices: Some(self.key_vertices.query_xy(aabb)), + key_segments: Some(self.key_segments.query_xy(aabb)), + fill_triangles: Some(self.fill_triangles.query_xy(aabb)), + pick_triangles: Some(self.pick_triangles.query_xy(aabb)), + glyphs: Some(self.glyphs.query_xy(aabb)), query_aabb: Some(aabb), + screen_rect: None, + screen_view: None, + } + } + + pub fn query_screen( + &self, + wires: Arc>, + screen_rect: [f32; 4], + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, + ) -> InteractionCandidates { + let mut wire_indices = self.wires.query_screen(screen_rect, view_rot, eye, bounds); + wire_indices.extend_from_slice(&self.unbounded_wires); + wire_indices.sort_unstable(); + wire_indices.dedup(); + InteractionCandidates { + wires, + wire_indices: Some(wire_indices), + segments: Some( + self.segments + .query_screen(screen_rect, view_rot, eye, bounds), + ), + snap_points: Some( + self.snap_points + .query_screen(screen_rect, view_rot, eye, bounds), + ), + key_vertices: Some( + self.key_vertices + .query_screen(screen_rect, view_rot, eye, bounds), + ), + key_segments: Some( + self.key_segments + .query_screen(screen_rect, view_rot, eye, bounds), + ), + fill_triangles: Some(self.fill_triangles.query_screen( + screen_rect, + view_rot, + eye, + bounds, + )), + pick_triangles: Some(self.pick_triangles.query_screen( + screen_rect, + view_rot, + eye, + bounds, + )), + glyphs: Some(self.glyphs.query_screen(screen_rect, view_rot, eye, bounds)), + query_aabb: None, + screen_rect: Some(screen_rect), + screen_view: Some((view_rot, eye, bounds)), } } } @@ -233,7 +784,15 @@ pub struct InteractionCandidates { wires: Arc>, wire_indices: Option>, segments: Option>, + snap_points: Option>, + key_vertices: Option>, + key_segments: Option>, + fill_triangles: Option>, + pick_triangles: Option>, + glyphs: Option>, query_aabb: Option<[f64; 4]>, + screen_rect: Option<[f32; 4]>, + screen_view: Option<(Mat4, DVec3, Rectangle)>, } pub trait WireSource { @@ -243,6 +802,24 @@ pub trait WireSource { fn segments(&self) -> Option<&[SegmentRef]> { None } + fn snap_points(&self) -> Option<&[SnapPointRef]> { + None + } + fn key_vertices(&self) -> Option<&[KeyVertexRef]> { + None + } + fn key_segments(&self) -> Option<&[KeySegmentRef]> { + None + } + fn fill_triangles(&self) -> Option<&[TriangleRef]> { + None + } + fn pick_triangles(&self) -> Option<&[TriangleRef]> { + None + } + fn glyphs(&self) -> Option<&[GlyphRef]> { + None + } fn source_wire(&self, index: u32) -> Option<&WireModel>; } @@ -252,7 +829,15 @@ impl InteractionCandidates { wires, wire_indices: None, segments: None, + snap_points: None, + key_vertices: None, + key_segments: None, + fill_triangles: None, + pick_triangles: None, + glyphs: None, query_aabb: None, + screen_rect: None, + screen_view: None, } } @@ -285,6 +870,30 @@ impl InteractionCandidates { self.segments.as_deref() } + pub fn snap_points(&self) -> Option<&[SnapPointRef]> { + self.snap_points.as_deref() + } + + pub fn key_vertices(&self) -> Option<&[KeyVertexRef]> { + self.key_vertices.as_deref() + } + + pub fn key_segments(&self) -> Option<&[KeySegmentRef]> { + self.key_segments.as_deref() + } + + pub fn fill_triangles(&self) -> Option<&[TriangleRef]> { + self.fill_triangles.as_deref() + } + + pub fn pick_triangles(&self) -> Option<&[TriangleRef]> { + self.pick_triangles.as_deref() + } + + pub fn glyphs(&self) -> Option<&[GlyphRef]> { + self.glyphs.as_deref() + } + pub fn source_wire(&self, index: u32) -> Option<&WireModel> { self.wires.get(index as usize) } @@ -292,6 +901,12 @@ impl InteractionCandidates { pub fn query_aabb(&self) -> Option<[f64; 4]> { self.query_aabb } + + pub fn screen_query(&self) -> Option<([f32; 4], Mat4, DVec3, Rectangle)> { + self.screen_rect + .zip(self.screen_view) + .map(|(rect, (view, eye, bounds))| (rect, view, eye, bounds)) + } } impl WireSource for InteractionCandidates { @@ -311,6 +926,30 @@ impl WireSource for InteractionCandidates { self.segments() } + fn snap_points(&self) -> Option<&[SnapPointRef]> { + self.snap_points() + } + + fn key_vertices(&self) -> Option<&[KeyVertexRef]> { + self.key_vertices() + } + + fn key_segments(&self) -> Option<&[KeySegmentRef]> { + self.key_segments() + } + + fn fill_triangles(&self) -> Option<&[TriangleRef]> { + self.fill_triangles() + } + + fn pick_triangles(&self) -> Option<&[TriangleRef]> { + self.pick_triangles() + } + + fn glyphs(&self) -> Option<&[GlyphRef]> { + self.glyphs() + } + fn source_wire(&self, index: u32) -> Option<&WireModel> { self.source_wire(index) } @@ -392,30 +1031,58 @@ impl<'a> Iterator for WireIter<'a> { } } -fn finite_wire_aabb(wire: &WireModel) -> Option<[f64; 4]> { +fn finite_wire_aabb3(wire: &WireModel) -> Option<[f64; 6]> { let [min_x, min_y, max_x, max_y] = wire.aabb; - (min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()).then(|| { - let mag = wire.aabb.iter().fold(0.0f32, |m, c| m.max(c.abs())); - let pad = (mag * f32::EPSILON * 2.0) as f64; - let mut aabb = [ - min_x as f64 - pad, - min_y as f64 - pad, - max_x as f64 + pad, - max_y as f64 + pad, - ]; - // Some valid snap points are outside visible curve bounds: notably the - // center of a short arc. Keep those discoverable without widening the - // segment query used by Intersection/Nearest. - for (point, _) in &wire.snap_pts { - if point.x.is_finite() && point.y.is_finite() { - aabb[0] = aabb[0].min(point.x); - aabb[1] = aabb[1].min(point.y); - aabb[2] = aabb[2].max(point.x); - aabb[3] = aabb[3].max(point.y); - } + if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() { + return None; + } + let mag = wire.aabb.iter().fold(0.0f32, |m, c| m.max(c.abs())); + let pad = (mag * f32::EPSILON * 2.0) as f64; + let mut aabb = [ + min_x as f64 - pad, + min_y as f64 - pad, + f64::INFINITY, + max_x as f64 + pad, + max_y as f64 + pad, + f64::NEG_INFINITY, + ]; + let mut include = |point: [f64; 3]| { + if point.iter().all(|value| value.is_finite()) { + aabb[0] = aabb[0].min(point[0]); + aabb[1] = aabb[1].min(point[1]); + aabb[2] = aabb[2].min(point[2]); + aabb[3] = aabb[3].max(point[0]); + aabb[4] = aabb[4].max(point[1]); + aabb[5] = aabb[5].max(point[2]); } - aabb - }) + }; + for index in 0..wire.points.len() { + include(wire_point(wire, index)); + } + for &(point, _) in &wire.snap_pts { + include(point.to_array()); + } + for &point in &wire.key_vertices { + include(point); + } + for (index, &point) in wire.fill_tris.iter().enumerate() { + include(point_with_low(point, &wire.fill_tris_low, index)); + } + for (index, &point) in wire.pick_tris.iter().enumerate() { + include(point_with_low(point, &wire.pick_tris_low, index)); + } + for vertex in &wire.text_verts { + include([ + vertex.pos[0] as f64 + vertex.pos_low[0] as f64, + vertex.pos[1] as f64 + vertex.pos_low[1] as f64, + vertex.pos[2] as f64 + vertex.pos_low[2] as f64, + ]); + } + if !aabb[2].is_finite() { + aabb[2] = 0.0; + aabb[5] = 0.0; + } + Some(aabb) } fn wire_point(wire: &WireModel, index: usize) -> [f64; 3] { @@ -428,6 +1095,99 @@ fn wire_point(wire: &WireModel, index: usize) -> [f64; 3] { ] } +fn point_with_low(point: [f32; 3], low: &[[f32; 3]], index: usize) -> [f64; 3] { + let residual = low.get(index).copied().unwrap_or([0.0; 3]); + [ + point[0] as f64 + residual[0] as f64, + point[1] as f64 + residual[1] as f64, + point[2] as f64 + residual[2] as f64, + ] +} + +fn points_aabb3(points: impl IntoIterator) -> Option<[f64; 6]> { + let mut aabb = [ + f64::INFINITY, + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ]; + let mut any = false; + for point in points { + if !point.iter().all(|value| value.is_finite()) { + return None; + } + any = true; + for axis in 0..3 { + aabb[axis] = aabb[axis].min(point[axis]); + aabb[axis + 3] = aabb[axis + 3].max(point[axis]); + } + } + any.then_some(aabb) +} + +fn append_triangle_entries( + wire: u32, + points: &[[f32; 3]], + low: &[[f32; 3]], + out: &mut Vec>, +) { + for start in (0..points.len()).step_by(3) { + if start + 2 >= points.len() { + break; + } + let Some(aabb) = + points_aabb3((start..start + 3).map(|index| point_with_low(points[index], low, index))) + else { + continue; + }; + out.push(Entry3 { + aabb, + value: TriangleRef { + wire, + start: start as u32, + }, + }); + } +} + +fn aabb3_projects_into( + aabb: [f64; 6], + screen_rect: [f32; 4], + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, +) -> bool { + let mut projected = [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ]; + for x in [aabb[0], aabb[3]] { + for y in [aabb[1], aabb[4]] { + for z in [aabb[2], aabb[5]] { + let clip = view_rot * (DVec3::new(x, y, z) - eye).as_vec3().extend(1.0); + if !clip.is_finite() || clip.w <= 1e-6 { + return true; + } + let ndc = clip.truncate() / clip.w; + let sx = (ndc.x + 1.0) * 0.5 * bounds.width; + let sy = (1.0 - ndc.y) * 0.5 * bounds.height; + projected[0] = projected[0].min(sx); + projected[1] = projected[1].min(sy); + projected[2] = projected[2].max(sx); + projected[3] = projected[3].max(sy); + } + } + } + projected[2] >= screen_rect[0] + && projected[0] <= screen_rect[2] + && projected[3] >= screen_rect[1] + && projected[1] <= screen_rect[3] +} + fn aabb_overlaps(a: [f64; 4], b: [f64; 4]) -> bool { a[2] >= b[0] && a[0] <= b[2] && a[3] >= b[1] && a[1] <= b[3] } diff --git a/src/snap.rs b/src/snap.rs index 1599925b2..15a388952 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -90,7 +90,7 @@ pub struct OtrackHit { // ── Snapper ─────────────────────────────────────────────────────────────── -use rustc_hash::FxHashSet as HashSet; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; pub struct Snapper { /// Global snap on/off toggle. When false, all snapping is bypassed @@ -926,7 +926,7 @@ impl Snapper { } } } - let allow_pairwise = wires.segments().is_some() || in_range_pts <= MAX_PAIRWISE_POINTS; + let allow_unindexed_pairwise = in_range_pts <= MAX_PAIRWISE_POINTS; let local_segments = indexed_segments(wires); let mut try_pt = |world: glam::DVec3, snap_type: SnapType| { @@ -959,56 +959,87 @@ impl Snapper { }; // ── Pre-baked snap points (Center, Node, Quadrant, Insertion) ────── - for wire in wires.iter() { - for &(world, hint) in &wire.snap_pts { - let snap_type = match hint { - SnapHint::Center => SnapType::Center, - SnapHint::Node => SnapType::Node, - SnapHint::Quadrant => SnapType::Quadrant, - SnapHint::Insertion => SnapType::Insertion, - SnapHint::Midpoint => SnapType::Midpoint, + let mut try_snap_hint = |world: DVec3, hint: SnapHint| { + let snap_type = match hint { + SnapHint::Center => SnapType::Center, + SnapHint::Node => SnapType::Node, + SnapHint::Quadrant => SnapType::Quadrant, + SnapHint::Insertion => SnapType::Insertion, + SnapHint::Midpoint => SnapType::Midpoint, + }; + if self.is_on(snap_type) { + try_pt(world, snap_type); + } + }; + if let Some(points) = wires.snap_points() { + for point_ref in points { + let Some(&(world, hint)) = wires + .source_wire(point_ref.wire) + .and_then(|wire| wire.snap_pts.get(point_ref.index as usize)) + else { + continue; }; - if self.is_on(snap_type) { - try_pt(world, snap_type); + try_snap_hint(world, hint); + } + } else { + for wire in wires.iter() { + for &(world, hint) in &wire.snap_pts { + try_snap_hint(world, hint); } } } + drop(try_snap_hint); // ── Endpoint ─────────────────────────────────────────────────────── if self.is_on(SnapType::Endpoint) { - for wire in wires.iter() { - if !wire_in_range(wire) { - continue; + if let Some(vertices) = wires.key_vertices() { + for vertex_ref in vertices { + let Some(&point) = wires + .source_wire(vertex_ref.wire) + .and_then(|wire| wire.key_vertices.get(vertex_ref.index as usize)) + else { + continue; + }; + try_pt(DVec3::from_array(point), SnapType::Endpoint); } - if !wire.key_vertices.is_empty() { - // Use explicit vertices (Line, LwPolyline): every vertex is an endpoint. - for &p in &wire.key_vertices { - try_pt(glam::DVec3::new(p[0], p[1], p[2]), SnapType::Endpoint); - } - } else { - // Tessellated curves (Circle, Arc, Ellipse): only an OPEN - // one (an arc) has real endpoints. A full circle / ellipse is - // closed — its tessellation's first/last is a seam point, not - // an endpoint — and is the only tessellated curve that carries - // Quadrant snap hints (arcs never do), so emit no Endpoint for - // those (#275). + // Tessellated open curves have no key-vertex set. Their only + // endpoints are first/last, so testing candidate wires remains + // O(local wires), independent of tessellation density. + for wire in wires.iter().filter(|wire| wire.key_vertices.is_empty()) { let closed = wire .snap_pts .iter() - .any(|(_, h)| matches!(h, SnapHint::Quadrant)); - if !closed { - if let Some(&p) = wire.points.first() { - try_pt( - glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), - SnapType::Endpoint, - ); + .any(|(_, hint)| matches!(hint, SnapHint::Quadrant)); + if closed { + continue; + } + if !wire.points.is_empty() { + try_pt(wp_f64(wire, 0), SnapType::Endpoint); + } + if wire.points.len() > 1 { + try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint); + } + } + } else { + for wire in wires.iter() { + if !wire_in_range(wire) { + continue; + } + if !wire.key_vertices.is_empty() { + for &point in &wire.key_vertices { + try_pt(DVec3::from_array(point), SnapType::Endpoint); } - if wire.points.len() > 1 { - if let Some(&p) = wire.points.last() { - try_pt( - glam::DVec3::new(p[0] as f64, p[1] as f64, p[2] as f64), - SnapType::Endpoint, - ); + } else { + let closed = wire + .snap_pts + .iter() + .any(|(_, hint)| matches!(hint, SnapHint::Quadrant)); + if !closed { + if !wire.points.is_empty() { + try_pt(wp_f64(wire, 0), SnapType::Endpoint); + } + if wire.points.len() > 1 { + try_pt(wp_f64(wire, wire.points.len() - 1), SnapType::Endpoint); } } } @@ -1017,20 +1048,32 @@ impl Snapper { } // ── Midpoint ─────────────────────────────────────────────────────── - // Only explicit vertex sets (Line, LwPolyline) contribute per-segment - // midpoints. Tessellated curves (Circle, Arc, Ellipse, Spline) emit a - // single `SnapHint::Midpoint` snap_pt where one exists — iterating - // every chord here would otherwise turn a circle's tessellation into - // a haze of false midpoint hits. See #34. + // Tessellated curves contribute their pre-baked Midpoint above. if self.is_on(SnapType::Midpoint) { - for wire in wires.iter() { - if !wire_in_range(wire) { - continue; + if let Some(segments) = wires.key_segments() { + for segment_ref in segments { + let Some(wire) = wires.source_wire(segment_ref.wire) else { + continue; + }; + let start = segment_ref.start as usize; + let Some((&a, &b)) = wire + .key_vertices + .get(start) + .zip(wire.key_vertices.get(start + 1)) + else { + continue; + }; + let a = DVec3::from_array(a); + let b = DVec3::from_array(b); + if a.distance_squared(b) > 1e-12 { + try_pt((a + b) * 0.5, SnapType::Midpoint); + } } - if !wire.key_vertices.is_empty() { - for seg in wire.key_vertices.windows(2) { - let a = glam::DVec3::new(seg[0][0], seg[0][1], seg[0][2]); - let b = glam::DVec3::new(seg[1][0], seg[1][1], seg[1][2]); + } else { + for wire in wires.iter().filter(|wire| wire_in_range(wire)) { + for segment in wire.key_vertices.windows(2) { + let a = DVec3::from_array(segment[0]); + let b = DVec3::from_array(segment[1]); if a.distance_squared(b) > 1e-12 { try_pt((a + b) * 0.5, SnapType::Midpoint); } @@ -1095,30 +1138,35 @@ impl Snapper { } // ── Intersection — segment-segment intersections (pairwise, gated) ── - if self.is_on(SnapType::Intersection) && allow_pairwise { + if self.is_on(SnapType::Intersection) + && (local_segments.is_some() || allow_unindexed_pairwise) + { if let Some(segments) = &local_segments { - // Cursor-local segment bounds make this set small. In a - // pathological dense crossing retain closest segments instead - // of disabling Intersection for the whole aperture. - const MAX_LOCAL_INTERSECTION_SEGMENTS: usize = 512; + // Exact cursor-local sweep: never discard a valid intersection + // in dense geometry. Min-X ordering plus Y overlap avoids + // comparing segment pairs whose bounds cannot meet. let mut local: Vec<&IndexedSegment> = segments.iter().collect(); - local.sort_by(|a, b| { - segment_aabb_distance2(a, cursor_world) - .total_cmp(&segment_aabb_distance2(b, cursor_world)) - }); - local.truncate(MAX_LOCAL_INTERSECTION_SEGMENTS); local.sort_by(|a, b| a.min_x().total_cmp(&b.min_x())); - for i in 0..local.len() { - let a = local[i]; - for &b in local.iter().skip(i + 1) { - if b.min_x() > a.max_x() { - break; - } - if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() { - continue; - } - if let Some(pt) = seg_intersect_3d(a.a, a.b, b.a, b.b) { - try_pt(pt, SnapType::Intersection); + if indexed_sweep_within_budget(&local) { + 'intersection_sweep: for i in 0..local.len() { + let a = local[i]; + for &b in local.iter().skip(i + 1) { + if b.min_x() > a.max_x() { + break; + } + if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() { + continue; + } + if let Some(pt) = seg_intersect_3d(a.a, a.b, b.a, b.b) { + let exact_cursor = dist2( + world_to_screen(pt, view_rot, eye, bounds), + cursor_screen, + ) <= f32::EPSILON; + try_pt(pt, SnapType::Intersection); + if exact_cursor { + break 'intersection_sweep; + } + } } } } @@ -1237,39 +1285,57 @@ impl Snapper { // ── Apparent Intersection — screen-space intersections (pairwise, gated) ── // L: pre-project each in-range wire's points to screen once, not once per segment pair. - if self.is_on(SnapType::ApparentIntersection) && allow_pairwise { + if self.is_on(SnapType::ApparentIntersection) + && (local_segments.is_some() || allow_unindexed_pairwise) + { if let Some(segments) = &local_segments { - const MAX_LOCAL_APPARENT_SEGMENTS: usize = 512; - let mut local: Vec<&IndexedSegment> = segments.iter().collect(); - local.sort_by(|a, b| { - segment_aabb_distance2(a, cursor_world) - .total_cmp(&segment_aabb_distance2(b, cursor_world)) - }); - local.truncate(MAX_LOCAL_APPARENT_SEGMENTS); - let projected: Vec<(Point, Point)> = local + // Project once, then run an exact screen-AABB sweep. Unlike the + // previous 512-item cap this cannot drop the true apparent + // intersection in a dense aperture. + let mut projected: Vec<(&IndexedSegment, Point, Point, [f32; 4])> = segments .iter() .map(|seg| { + let a = world_to_screen(seg.a, view_rot, eye, bounds); + let b = world_to_screen(seg.b, view_rot, eye, bounds); ( - world_to_screen(seg.a, view_rot, eye, bounds), - world_to_screen(seg.b, view_rot, eye, bounds), + seg, + a, + b, + [a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)], ) }) .collect(); - for i in 0..local.len() { - for j in (i + 1)..local.len() { - if local[i].wire == local[j].wire { - continue; - } - if let Some((ta, _)) = seg_intersect_2d( - projected[i].0, - projected[i].1, - projected[j].0, - projected[j].1, - ) { - try_pt( - local[i].a + ta as f64 * (local[i].b - local[i].a), - SnapType::ApparentIntersection, - ); + projected.sort_by(|a, b| a.3[0].total_cmp(&b.3[0])); + if projected_sweep_within_budget(&projected) { + 'apparent_sweep: for i in 0..projected.len() { + let (segment_a, screen_a0, screen_a1, aabb_a) = projected[i]; + for &(segment_b, screen_b0, screen_b1, aabb_b) in + projected.iter().skip(i + 1) + { + if aabb_b[0] > aabb_a[2] { + break; + } + if segment_a.wire == segment_b.wire + || aabb_a[3] < aabb_b[1] + || aabb_a[1] > aabb_b[3] + { + continue; + } + if let Some((ta, _)) = + seg_intersect_2d(screen_a0, screen_a1, screen_b0, screen_b1) + { + let apparent_screen = Point::new( + screen_a0.x + ta * (screen_a1.x - screen_a0.x), + screen_a0.y + ta * (screen_a1.y - screen_a0.y), + ); + try_pt( + segment_a.a + ta as f64 * (segment_a.b - segment_a.a), + SnapType::ApparentIntersection, + ); + if dist2(apparent_screen, cursor_screen) <= f32::EPSILON { + break 'apparent_sweep; + } + } } } } @@ -1450,27 +1516,15 @@ impl Snapper { // the cursor is to the curve. Runs here, after `try_pt`'s borrow ends, // so it can update the candidate state directly. (#152) if self.is_on(SnapType::Center) { - for wire in wires.iter() { - if !wire_in_range(wire) { - continue; - } - // Only tessellated curves carry a pre-baked Center hint; reuse - // it as the snap target. Lines / polylines have none → skip. + let mut offer = |wire: &WireModel, curve_d2: f32| { let Some(center) = wire .snap_pts .iter() - .find(|(_, h)| matches!(h, SnapHint::Center)) + .find(|(_, hint)| matches!(hint, SnapHint::Center)) .map(|&(c, _)| c) else { - continue; + return; }; - // Nearest screen distance from the cursor to the curve itself. - let mut curve_d2 = f32::INFINITY; - for i in 0..wire.points.len().saturating_sub(1) { - let p = nearest_on_segment(cursor_world, wp_f64(wire, i), wp_f64(wire, i + 1)); - let sp = world_to_screen(p, view_rot, eye, bounds); - curve_d2 = curve_d2.min(dist2(sp, cursor_screen)); - } let screen = world_to_screen(center, view_rot, eye, bounds); // Rim-hover Center: the cursor is on the CURVE, not near the // centre, so its distance metric (curve_d2 ≈ 0 anywhere on the @@ -1496,6 +1550,51 @@ impl Snapper { extension_base2: None, }); } + }; + if let Some(segments) = &local_segments { + let mut distances: HashMap = HashMap::default(); + for segment in segments { + let Some(wire) = wires.source_wire(segment.wire) else { + continue; + }; + if !wire + .snap_pts + .iter() + .any(|(_, hint)| matches!(hint, SnapHint::Center)) + { + continue; + } + let nearest = nearest_on_segment(cursor_world, segment.a, segment.b); + let distance = dist2( + world_to_screen(nearest, view_rot, eye, bounds), + cursor_screen, + ); + distances + .entry(segment.wire) + .and_modify(|best| *best = best.min(distance)) + .or_insert(distance); + } + for (wire_idx, distance) in distances { + if let Some(wire) = wires.source_wire(wire_idx) { + offer(wire, distance); + } + } + } else { + for wire in wires.iter().filter(|wire| wire_in_range(wire)) { + let mut curve_d2 = f32::INFINITY; + for index in 0..wire.points.len().saturating_sub(1) { + let nearest = nearest_on_segment( + cursor_world, + wp_f64(wire, index), + wp_f64(wire, index + 1), + ); + curve_d2 = curve_d2.min(dist2( + world_to_screen(nearest, view_rot, eye, bounds), + cursor_screen, + )); + } + offer(wire, curve_d2); + } } } @@ -1683,6 +1782,46 @@ impl IndexedSegment { } } +const MAX_INDEXED_PAIRWISE_WORK: usize = 500_000; + +fn indexed_sweep_within_budget(segments: &[&IndexedSegment]) -> bool { + let mut work = 0usize; + for (index, &a) in segments.iter().enumerate() { + for &b in segments.iter().skip(index + 1) { + if b.min_x() > a.max_x() { + break; + } + work += 1; + if work > MAX_INDEXED_PAIRWISE_WORK { + return false; + } + if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() { + continue; + } + } + } + true +} + +fn projected_sweep_within_budget(segments: &[(&IndexedSegment, Point, Point, [f32; 4])]) -> bool { + let mut work = 0usize; + for (index, &(a, _, _, aabb_a)) in segments.iter().enumerate() { + for &(b, _, _, aabb_b) in segments.iter().skip(index + 1) { + if aabb_b[0] > aabb_a[2] { + break; + } + work += 1; + if work > MAX_INDEXED_PAIRWISE_WORK { + return false; + } + if a.wire == b.wire || aabb_a[3] < aabb_b[1] || aabb_a[1] > aabb_b[3] { + continue; + } + } + } + true +} + fn indexed_segments(wires: &W) -> Option> { let refs = wires.segments()?; Some( @@ -1706,24 +1845,6 @@ fn indexed_segments(wires: &W) -> Option f64 { - let dx = if point.x < segment.min_x() { - segment.min_x() - point.x - } else if point.x > segment.max_x() { - point.x - segment.max_x() - } else { - 0.0 - }; - let dy = if point.y < segment.min_y() { - segment.min_y() - point.y - } else if point.y > segment.max_y() { - point.y - segment.max_y() - } else { - 0.0 - }; - dx * dx + dy * dy -} - /// Reconstruct the absolute f64 position of wire vertex `i` from its /// double-single high/low pair. At UTM-scale coordinates the `points` (high) /// f32 alone is ~0.5 m off; adding the low residual restores f64 precision so