From ac042c5dbfe73a266509e9adc3a8fb3244f28167 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 4 Jun 2026 23:44:39 +0300 Subject: [PATCH] feat(statusbar): selection cycling for overlapping objects (SC pill) Add a click_hits_all hit-test and an SC pill that, when on, makes repeated clicks at the same spot step through the overlapping objects there (nearest first) instead of accumulating the selection. The whole cycling path is gated behind the toggle, so default picking is unchanged when it is off. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 9 ++++++++ src/app/update.rs | 46 ++++++++++++++++++++++++++++++++++++++ src/app/view.rs | 1 + src/scene/hit_test.rs | 37 ++++++++++++++++++++++++++++++ src/ui/statusbar.rs | 8 +++++++ src/ui/statusbar_config.rs | 4 ++++ 6 files changed, 105 insertions(+) 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", }