From 9db206f31092cf4d8349306043fd01e54158a941 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 29 May 2026 20:30:56 +0300 Subject: [PATCH] feat(select): Select Similar + Quick Select panel (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways to extend the current selection by entity type + layer: * `Select Similar` (right-click → Select Similar, or `SELECTSIMILAR` / `SELSIM`): for every (type, layer) pair in the current selection, add every entity in the active layout that matches the pair. The seed selection stays selected; matches already selected are not double-counted. * `Quick Select` (right-click → Quick Select…, or `QSELECT` / `QS`): opens a centred floating panel with Object-type and Layer pick_lists (populated only with values that actually exist in the active layout) and an "Append to current selection" checkbox. `(Any)` in either slot skips that filter. Apply replaces the selection (or extends it with Append on) and reports the match count on the command line. Cancel, Esc, and outside-click all dismiss without applying. When opened with a selection present, the panel pre-fills both filters from the first selected entity so QSelect doubles as an editable Select Similar. Scoped to entities owned by the active layout's block-record so a paper layout's QSELECT never reaches into model-space entities (and vice versa). Entity-type names live in a single `entity_type_name` helper in `entities::traits` to keep the UI strings and the filter keys aligned. Co-Authored-By: Claude Opus 4.7 --- src/app/commands.rs | 11 +++ src/app/mod.rs | 32 +++++++++ src/app/update.rs | 79 +++++++++++++++++++++ src/app/view.rs | 152 +++++++++++++++++++++++++++++++++++++++++ src/entities/traits.rs | 50 ++++++++++++++ src/scene/mod.rs | 112 ++++++++++++++++++++++++++++++ 6 files changed, 436 insertions(+) diff --git a/src/app/commands.rs b/src/app/commands.rs index f1e6e96af..8bc4353d4 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1738,6 +1738,17 @@ impl OpenCADStudio { self.refresh_properties(); } + "SELECTSIMILAR" | "SELSIM" => { + let added = self.tabs[i].scene.select_similar(); + self.command_line + .push_output(&format!("Select Similar: {} added.", added)); + self.refresh_properties(); + } + + "QSELECT" | "QS" => { + return Task::done(Message::QSelectOpen); + } + // ── LIST — entity info ──────────────────────────────────────── "LIST" | "LI" => { let selected: Vec<_> = self.tabs[i].scene.selected_entities(); diff --git a/src/app/mod.rs b/src/app/mod.rs index d5890aa21..93350ef09 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -55,6 +55,16 @@ pub struct GripPendingValue { pub action: crate::scene::object::GripMenuAction, pub label: &'static str, } + +/// Open Quick Select panel state. `type_filter` / `layer_filter` of +/// `None` match every entity in that field; otherwise the filter is the +/// literal name to compare against. +#[derive(Clone, Debug)] +pub struct QSelectState { + pub type_filter: Option, + pub layer_filter: Option, + pub append: bool, +} use crate::snap::Snapper; use crate::ui::{AppMenu, CommandLine, Ribbon, StatusBar}; use acadrust::types::{Color as AcadColor, LineWeight}; @@ -135,6 +145,10 @@ pub(super) struct OpenCADStudio { /// until dismissed (click outside, ESC, cursor leaves the grip). grip_popup: Option, grip_pending: Option, + /// Open Quick Select panel state. `None` = panel closed. Filters are + /// applied via `Message::QSelectApply`; the panel is dismissed on + /// Apply / Cancel / Esc / outside-click. + qselect: Option, /// Show the UCS icon in the bottom-left corner of model space (UCSICON). show_ucs_icon: bool, /// Whether the ViewCube 3D gizmo is visible in model space (NAVVCUBE). @@ -627,6 +641,23 @@ pub enum Message { // ── About window ──────────────────────────────────────────────────── AboutOpen, AboutCopyInfo, + // ── Quick Select / Select Similar ─────────────────────────────────── + /// Extend the current selection with every entity in the active + /// layout that matches a selected entity by (type, layer). + SelectSimilar, + /// Open the Quick Select panel. Initialises filters from the current + /// selection's first entity (type + layer) when one is selected. + QSelectOpen, + /// Close the Quick Select panel without applying. + QSelectClose, + /// Type filter — `None` means "any type". + QSelectSetType(Option), + /// Layer filter — `None` means "any layer". + QSelectSetLayer(Option), + /// Append-to-current-selection toggle. + QSelectSetAppend(bool), + /// Apply the current filter and close the panel. + QSelectApply, /// The user clicked the title-bar ✕ (fires before the window closes). WindowCloseRequested(window::Id), /// A window was fully closed (fires after `window::close()` is called). @@ -818,6 +849,7 @@ impl OpenCADStudio { grip_hover: None, grip_popup: None, grip_pending: None, + qselect: None, show_ucs_icon: true, show_viewcube: true, show_properties: true, diff --git a/src/app/update.rs b/src/app/update.rs index 00aac5f03..f2acf748a 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1242,6 +1242,9 @@ impl OpenCADStudio { } // Cancel layout rename / context menus first, then fall through. let i_e = self.active_tab; + if self.qselect.take().is_some() { + return Task::none(); + } { let mut sel = self.tabs[i_e].scene.selection.borrow_mut(); if sel.context_menu.is_some() { @@ -3376,6 +3379,82 @@ impl OpenCADStudio { Task::none() } + Message::SelectSimilar => { + let i = self.active_tab; + self.tabs[i].scene.selection.borrow_mut().context_menu = None; + let added = self.tabs[i].scene.select_similar(); + self.command_line + .push_output(&format!("Select Similar: {} added.", added)); + self.refresh_properties(); + Task::none() + } + + Message::QSelectOpen => { + let i = self.active_tab; + self.tabs[i].scene.selection.borrow_mut().context_menu = None; + // Seed filters from the first selected entity (if any) so + // a right-click → Quick Select on a known object pre-fills + // the panel — same idea as Select Similar but exposes the + // pairing for editing. + let mut type_filter: Option = None; + let mut layer_filter: Option = None; + if let Some(&h) = self.tabs[i].scene.selected.iter().next() { + if let Some(e) = self.tabs[i].scene.document.get_entity(h) { + use crate::entities::traits::entity_type_name; + type_filter = Some(entity_type_name(e).to_string()); + layer_filter = Some(e.as_entity().layer().to_string()); + } + } + self.qselect = Some(crate::app::QSelectState { + type_filter, + layer_filter, + append: false, + }); + Task::none() + } + + Message::QSelectClose => { + self.qselect = None; + Task::none() + } + + Message::QSelectSetType(t) => { + if let Some(state) = self.qselect.as_mut() { + state.type_filter = t; + } + Task::none() + } + + Message::QSelectSetLayer(l) => { + if let Some(state) = self.qselect.as_mut() { + state.layer_filter = l; + } + Task::none() + } + + Message::QSelectSetAppend(b) => { + if let Some(state) = self.qselect.as_mut() { + state.append = b; + } + Task::none() + } + + Message::QSelectApply => { + let Some(state) = self.qselect.take() else { + return Task::none(); + }; + let i = self.active_tab; + let matched = self.tabs[i].scene.qselect( + state.type_filter.as_deref(), + state.layer_filter.as_deref(), + state.append, + ); + self.command_line + .push_output(&format!("QSELECT: {} object(s) selected.", matched)); + self.refresh_properties(); + Task::none() + } + // ── Properties panel messages ───────────────────────────────── Message::PropSelectionGroupChanged(group) => { self.tabs[self.active_tab].properties.selected_group = Some(group); diff --git a/src/app/view.rs b/src/app/view.rs index 49f8bc62e..76a0021e4 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -910,6 +910,14 @@ impl OpenCADStudio { iced::widget::Space::new().width(0).height(0).into() }; + let qselect_layer: Element<'_, Message> = if let Some(state) = &self.qselect { + let types = tab.scene.entity_type_names_in_layout(); + let layers = tab.scene.entity_layers_in_layout(); + qselect_overlay(state, &types, &layers) + } else { + iced::widget::Space::new().width(0).height(0).into() + }; + let open_progress_layer: Element<'_, Message> = if let Some(p) = &self.opening { crate::ui::open_progress::view(p, iced::time::Instant::now()) } else { @@ -923,6 +931,7 @@ impl OpenCADStudio { scale_layer, dropdown_layer, layout_ctx_layer, + qselect_layer, open_progress_layer, ] .into() @@ -1384,11 +1393,13 @@ fn viewport_context_menu_overlay( Message::Command("COPY".to_string()), )); items.push(sep()); + items.push(item("Select Similar".to_string(), Message::SelectSimilar)); } items.push(item( "Select All".to_string(), Message::Command("SELECTALL".to_string()), )); + items.push(item("Quick Select...".to_string(), Message::QSelectOpen)); items.push(item( "Zoom Extents".to_string(), Message::Command("ZOOM".to_string()), @@ -1503,6 +1514,147 @@ fn layout_context_menu_overlay(name: &str) -> Element<'_, Message> { stack![catcher, positioned].into() } +// ── Quick Select panel ───────────────────────────────────────────────────── + +const QSELECT_ANY: &str = "(Any)"; + +/// Floating panel for the Quick Select feature. Two pick_lists (object +/// type, layer) filter the candidate set down to entities matching both; +/// "(Any)" in either slot skips that filter. The panel returns to the +/// stored state on every render, so editing one filter doesn't reset the +/// other. Outside-click and Cancel both dismiss without applying. +fn qselect_overlay<'a>( + state: &'a crate::app::QSelectState, + types: &[&'static str], + layers: &[String], +) -> Element<'a, Message> { + use iced::widget::{checkbox, pick_list}; + const BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 0.98 }; + const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }; + const TEXT: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; + const BTN_OK: Color = Color { r: 0.22, g: 0.42, b: 0.68, a: 1.0 }; + const BTN_OK_HOV: Color = Color { r: 0.30, g: 0.52, b: 0.80, a: 1.0 }; + const BTN_BG: Color = Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 }; + const BTN_HOV: Color = Color { r: 0.30, g: 0.30, b: 0.30, a: 1.0 }; + + let mut type_options: Vec = vec![QSELECT_ANY.to_string()]; + type_options.extend(types.iter().map(|s| (*s).to_string())); + let mut layer_options: Vec = vec![QSELECT_ANY.to_string()]; + layer_options.extend(layers.iter().cloned()); + + let type_sel = state + .type_filter + .clone() + .unwrap_or_else(|| QSELECT_ANY.to_string()); + let layer_sel = state + .layer_filter + .clone() + .unwrap_or_else(|| QSELECT_ANY.to_string()); + + let label = |s: &'static str| text(s).size(12).color(TEXT).width(iced::Length::Fixed(90.0)); + + let btn = |lbl: &'static str, msg: Message, base: Color, hov: Color| { + button(text(lbl).size(12).color(TEXT)) + .on_press(msg) + .style(move |_: &Theme, st| button::Style { + background: Some(Background::Color( + if matches!(st, button::Status::Hovered | button::Status::Pressed) { + hov + } else { + base + }, + )), + text_color: TEXT, + border: Border { + color: BORDER, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .padding([4, 14]) + }; + + let panel_body = column![ + text("Quick Select").size(14).color(TEXT), + Space::new().height(10), + row![ + label("Object type:"), + pick_list(type_options, Some(type_sel), |s: String| { + if s == QSELECT_ANY { + Message::QSelectSetType(None) + } else { + Message::QSelectSetType(Some(s)) + } + }) + .width(Fill), + ] + .align_y(iced::Alignment::Center) + .spacing(8), + Space::new().height(6), + row![ + label("Layer:"), + pick_list(layer_options, Some(layer_sel), |s: String| { + if s == QSELECT_ANY { + Message::QSelectSetLayer(None) + } else { + Message::QSelectSetLayer(Some(s)) + } + }) + .width(Fill), + ] + .align_y(iced::Alignment::Center) + .spacing(8), + Space::new().height(10), + row![ + checkbox(state.append) + .on_toggle(Message::QSelectSetAppend) + .size(14), + Space::new().width(6), + text("Append to current selection").size(12).color(TEXT), + ] + .align_y(iced::Alignment::Center), + Space::new().height(14), + row![ + Space::new().width(Fill), + btn("Cancel", Message::QSelectClose, BTN_BG, BTN_HOV), + Space::new().width(8), + btn("Apply", Message::QSelectApply, BTN_OK, BTN_OK_HOV), + ] + .align_y(iced::Alignment::Center), + ] + .spacing(0); + + let panel = container(panel_body) + .padding(16) + .width(iced::Length::Fixed(360.0)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BG)), + border: Border { + color: BORDER, + width: 1.0, + radius: 6.0.into(), + }, + ..Default::default() + }); + + // Outside-click catcher — fills the whole screen, sits below the + // panel. The panel itself is rendered above and absorbs its own + // clicks via standard widget event handling. + let catcher = mouse_area( + container(iced::widget::Space::new().width(Fill).height(Fill)) + .width(Fill) + .height(Fill), + ) + .on_press(Message::QSelectClose) + .on_right_press(Message::QSelectClose); + + let centered = container(iced::widget::opaque(panel)) + .center(Fill); + + stack![catcher, centered].into() +} + /// Content for the floating "Unsaved Changes" OS window. const SAVE_FORMAT_OPTIONS: &[&str] = &[ "DWG 2018", "DWG 2013", "DWG 2010", "DWG 2007", "DWG 2004", "DWG 2000", "DWG R14", "DXF 2018", diff --git a/src/entities/traits.rs b/src/entities/traits.rs index ed4463796..224b4c77e 100644 --- a/src/entities/traits.rs +++ b/src/entities/traits.rs @@ -92,6 +92,56 @@ pub trait TextContent { fn replace_text(&mut self, search: &str, rep: &str); } +/// Human-readable variant name used by Quick Select / Select Similar +/// filtering. Stable across releases — the strings here are the canonical +/// type identifiers throughout the UI. +pub fn entity_type_name(et: &EntityType) -> &'static str { + match et { + EntityType::Point(_) => "Point", + EntityType::Line(_) => "Line", + EntityType::Circle(_) => "Circle", + EntityType::Arc(_) => "Arc", + EntityType::Ellipse(_) => "Ellipse", + EntityType::Polyline(_) => "Polyline", + EntityType::Polyline2D(_) => "Polyline2D", + EntityType::Polyline3D(_) => "Polyline3D", + EntityType::LwPolyline(_) => "LwPolyline", + EntityType::Text(_) => "Text", + EntityType::MText(_) => "MText", + EntityType::Spline(_) => "Spline", + EntityType::Dimension(_) => "Dimension", + EntityType::Hatch(_) => "Hatch", + EntityType::Solid(_) => "Solid", + EntityType::Face3D(_) => "3DFace", + EntityType::Insert(_) => "Insert", + EntityType::Block(_) => "Block", + EntityType::BlockEnd(_) => "BlockEnd", + EntityType::Ray(_) => "Ray", + EntityType::XLine(_) => "XLine", + EntityType::Viewport(_) => "Viewport", + EntityType::AttributeDefinition(_) => "AttributeDefinition", + EntityType::AttributeEntity(_) => "Attribute", + EntityType::Leader(_) => "Leader", + EntityType::MultiLeader(_) => "MultiLeader", + EntityType::MLine(_) => "MLine", + EntityType::Mesh(_) => "Mesh", + EntityType::RasterImage(_) => "RasterImage", + EntityType::Solid3D(_) => "Solid3D", + EntityType::Region(_) => "Region", + EntityType::Body(_) => "Body", + EntityType::Table(_) => "Table", + EntityType::Tolerance(_) => "Tolerance", + EntityType::PolyfaceMesh(_) => "PolyfaceMesh", + EntityType::Wipeout(_) => "Wipeout", + EntityType::Shape(_) => "Shape", + EntityType::Underlay(_) => "Underlay", + EntityType::Seqend(_) => "Seqend", + EntityType::Ole2Frame(_) => "Ole2Frame", + EntityType::PolygonMesh(_) => "PolygonMesh", + EntityType::Unknown(_) => "Unknown", + } +} + pub trait EntityTypeOps { fn to_truck_entity(&self, document: &CadDocument) -> Option; fn grips(&self) -> Vec; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 848cad494..b79dd1da1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3713,6 +3713,118 @@ impl Scene { .collect() } + /// Iterates every entity owned by the current layout's block-record. + /// Returns an empty vec when the block-record is missing or holds no + /// entity handles (legacy DXF without group-code 330 — we err on the + /// side of "no candidates" instead of scanning the whole document, so + /// model-block entities don't leak into a paper-layout selection). + fn current_layout_entity_handles(&self) -> Vec { + let block = self.current_layout_block_handle(); + self.document + .block_records + .iter() + .find(|br| br.handle == block) + .map(|br| br.entity_handles.clone()) + .unwrap_or_default() + } + + /// Extends the current selection with every entity in the active + /// layout that matches one of the selected entities by `(variant, + /// layer)`. The seed selection stays selected. No-op when nothing is + /// selected. Returns the number of newly-added entities. + pub fn select_similar(&mut self) -> usize { + use crate::entities::traits::entity_type_name; + if self.selected.is_empty() { + return 0; + } + let pairs: std::collections::HashSet<(&'static str, String)> = self + .selected + .iter() + .filter_map(|h| self.document.get_entity(*h)) + .map(|e| (entity_type_name(e), e.as_entity().layer().to_string())) + .collect(); + let handles = self.current_layout_entity_handles(); + let mut added = 0; + for h in handles { + if self.selected.contains(&h) { + continue; + } + if let Some(e) = self.document.get_entity(h) { + let key = (entity_type_name(e), e.as_entity().layer().to_string()); + if pairs.contains(&key) { + self.selected.insert(h); + added += 1; + } + } + } + if added > 0 { + self.bump_geometry(); + } + added + } + + /// Replaces (or extends, when `append` is true) the current selection + /// with every entity in the active layout that matches the optional + /// `type_name` and `layer` filters. A `None` filter means "any". + /// Returns the number of matching entities (i.e. the post-call + /// selection size if `append` was false, or the new total if it was + /// true and previously-selected matches are not double-counted). + pub fn qselect( + &mut self, + type_name: Option<&str>, + layer: Option<&str>, + append: bool, + ) -> usize { + use crate::entities::traits::entity_type_name; + if !append { + self.selected.clear(); + } + let handles = self.current_layout_entity_handles(); + let mut matched = 0; + for h in handles { + let Some(e) = self.document.get_entity(h) else { + continue; + }; + let type_ok = type_name.is_none_or(|t| entity_type_name(e) == t); + let layer_ok = layer.is_none_or(|l| e.as_entity().layer() == l); + if type_ok && layer_ok { + self.selected.insert(h); + matched += 1; + } + } + self.bump_geometry(); + matched + } + + /// Returns the sorted set of entity-type names present in the active + /// layout. Used to populate the Quick Select "Object type" dropdown + /// with only the types that actually exist in the drawing. + pub fn entity_type_names_in_layout(&self) -> Vec<&'static str> { + use crate::entities::traits::entity_type_name; + let mut names: std::collections::BTreeSet<&'static str> = + std::collections::BTreeSet::new(); + for h in self.current_layout_entity_handles() { + if let Some(e) = self.document.get_entity(h) { + names.insert(entity_type_name(e)); + } + } + names.into_iter().collect() + } + + /// Returns the sorted set of layer names referenced by entities in + /// the active layout. Used to populate the Quick Select "Layer" + /// dropdown. + pub fn entity_layers_in_layout(&self) -> Vec { + let mut names: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for h in self.current_layout_entity_handles() { + if let Some(e) = self.document.get_entity(h) { + names.insert(e.as_entity().layer().to_string()); + } + } + names.into_iter().collect() + } + // ── Erase ───────────────────────────────────────────────────────────── pub fn erase_entities(&mut self, handles: &[Handle]) {