diff --git a/src/app/mod.rs b/src/app/mod.rs index 12bb6eded..221fad29b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -183,6 +183,11 @@ pub(super) struct OpenCADStudio { clean_screen: bool, /// Quick Properties: show a compact floating property panel on selection. quick_properties: bool, + /// Selection cycling: repeated clicks at one spot step through the + /// overlapping objects there instead of accumulating the selection. + selection_cycling: bool, + /// Cycling cursor: (screen point, ordered candidates, current index). + cycle_state: Option<(iced::Point, Vec, usize)>, /// Which status-bar pills the user has chosen to show (persisted). statusbar_config: crate::ui::statusbar_config::StatusBarConfig, /// Whether Tangent snap was enabled before a tangent-pick command started. @@ -758,6 +763,8 @@ pub enum Message { ToggleTransparencyDisplay, /// Toggle the Quick Properties floating panel. ToggleQuickProperties, + /// Toggle selection cycling for overlapping objects. + ToggleSelectionCycling, /// Toggle the selection-filter type picker open/closed. ToggleSelectionFilterPopup, /// Close the selection-filter type picker. @@ -1237,6 +1244,8 @@ impl OpenCADStudio { statusbar_config: crate::ui::statusbar_config::StatusBarConfig::load(), clean_screen: false, quick_properties: false, + selection_cycling: false, + cycle_state: None, pre_cmd_tangent: None, ortho_mode: false, polar_mode: false, diff --git a/src/app/update.rs b/src/app/update.rs index 2b8709e1e..b11cf208d 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2909,6 +2909,46 @@ impl OpenCADStudio { if box_anchor.is_none() { let all_wires = self.tabs[i].scene.hit_test_wires(); let vp_mat = self.tabs[i].scene.camera.borrow().view_proj(bounds); + + // Selection cycling: step through overlapping objects + // under the cursor on repeated clicks at the same spot. + // Gated behind the toggle, so default picking is unchanged. + let mut handled_by_cycling = false; + if self.selection_cycling { + let cands: Vec = scene::hit_test::click_hits_all( + p, + &all_wires[..], + vp_mat, + bounds, + ) + .into_iter() + .filter_map(|s| Scene::handle_from_wire_name(s)) + .filter(|&h| self.tabs[i].scene.passes_selection_filter(h)) + .collect(); + if !cands.is_empty() { + let same = + self.cycle_state.as_ref().map_or(false, |(pt, list, _)| { + (pt.x - p.x).abs() < 3.0 + && (pt.y - p.y).abs() < 3.0 + && *list == cands + }); + let idx = if same { + (self.cycle_state.as_ref().unwrap().2 + 1) % cands.len() + } else { + 0 + }; + let handle = cands[idx]; + self.cycle_state = Some((p, cands, idx)); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].scene.expand_selection_for_groups(&[handle]); + self.refresh_properties(); + selection_just_completed = true; + handled_by_cycling = true; + } + } + + if !handled_by_cycling { let hit = scene::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .or_else(|| { @@ -2961,6 +3001,7 @@ impl OpenCADStudio { sel.box_current = Some(p_full); sel.box_crossing = false; } + } } else { let a = box_anchor.unwrap(); let crossing = box_crossing; @@ -3601,6 +3642,11 @@ impl OpenCADStudio { self.quick_properties ^= true; Task::none() } + Message::ToggleSelectionCycling => { + self.selection_cycling ^= true; + self.cycle_state = None; + Task::none() + } Message::ToggleSelectionFilterPopup => { self.selection_filter_popup_open ^= true; Task::none() diff --git a/src/app/view.rs b/src/app/view.rs index 74b6df313..a3ca3d091 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1174,6 +1174,7 @@ impl OpenCADStudio { tab.scene.transparency_display, self.quick_properties, tab.scene.selection_filter_active(), + self.selection_cycling, &self.statusbar_config, ) }) diff --git a/src/scene/hit_test.rs b/src/scene/hit_test.rs index ae8ae0787..744e36080 100644 --- a/src/scene/hit_test.rs +++ b/src/scene/hit_test.rs @@ -55,6 +55,43 @@ pub fn click_hit<'a>( best } +/// Like `click_hit` but returns every wire within the click threshold, +/// nearest first. Used by selection cycling to step through overlapping +/// objects under the cursor. +pub fn click_hits_all<'a>( + cursor: Point, + wires: &'a [WireModel], + view_proj: Mat4, + bounds: Rectangle, +) -> Vec<&'a str> { + let mut hits: Vec<(f32, &str)> = Vec::new(); + for wire in wires { + let mut prev: Option = None; + let mut best_for_wire = CLICK_THRESHOLD_PX; + let mut hit = false; + for &[px, py, pz] in &wire.points { + if px.is_nan() { + prev = None; + continue; + } + let cur = world_to_screen(Vec3::new(px, py, pz), view_proj, bounds); + if let Some(p0) = prev { + let d = dist_point_to_segment(cursor, p0, cur); + if d < best_for_wire { + best_for_wire = d; + hit = true; + } + } + prev = Some(cur); + } + if hit { + hits.push((best_for_wire, &wire.name)); + } + } + 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 diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index f5d27043a..c757437ad 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -67,6 +67,8 @@ impl StatusBar { quick_properties: bool, // True when the selection filter is excluding at least one type. selection_filter_active: bool, + // Whether selection cycling is enabled. + selection_cycling: bool, // Which pills the user has chosen to show on the bar. config: &'a StatusBarConfig, ) -> Element<'a, Message> { @@ -213,6 +215,12 @@ impl StatusBar { "Selection Filtering\nLimit which object types can be picked", )); } + if vis(StatusPill::SelCycle) { + right_status = right_status.push(tip( + toggle_pill("SC", selection_cycling, Message::ToggleSelectionCycling), + "Selection Cycling\nRepeat-click to step through overlapping objects", + )); + } if vis(StatusPill::Vp) && !vp_label.is_empty() { right_status = right_status.push(tip( status_pill(vp_label).into(), diff --git a/src/ui/statusbar_config.rs b/src/ui/statusbar_config.rs index ddd780ed2..1b1407341 100644 --- a/src/ui/statusbar_config.rs +++ b/src/ui/statusbar_config.rs @@ -27,6 +27,7 @@ pub enum StatusPill { Isolate, QuickProps, SelFilter, + SelCycle, Vp, CleanScreen, } @@ -51,6 +52,7 @@ impl StatusPill { StatusPill::Isolate, StatusPill::QuickProps, StatusPill::SelFilter, + StatusPill::SelCycle, StatusPill::Vp, StatusPill::CleanScreen, ]; @@ -74,6 +76,7 @@ impl StatusPill { StatusPill::Isolate => "isolate", StatusPill::QuickProps => "quickprops", StatusPill::SelFilter => "selfilter", + StatusPill::SelCycle => "selcycle", StatusPill::Vp => "vp", StatusPill::CleanScreen => "cleanscreen", } @@ -98,6 +101,7 @@ impl StatusPill { StatusPill::Isolate => "Isolate Objects", StatusPill::QuickProps => "Quick Properties", StatusPill::SelFilter => "Selection Filtering", + StatusPill::SelCycle => "Selection Cycling", StatusPill::Vp => "Viewport Count", StatusPill::CleanScreen => "Clean Screen", }