feat(select): Select Similar + Quick Select panel (#28)

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-29 20:30:56 +03:00
1 parent cf925be861
commit 9db206f310
6 files changed
+436

No files matched your search

+11
View File
@@ -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();
+32
View File
@@ -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<String>,
pub layer_filter: Option<String>,
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<GripPopup>,
grip_pending: Option<GripPendingValue>,
/// 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<QSelectState>,
/// 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<String>),
/// Layer filter — `None` means "any layer".
QSelectSetLayer(Option<String>),
/// 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,
+79
View File
@@ -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<String> = None;
let mut layer_filter: Option<String> = 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);
+152
View File
@@ -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<String> = vec![QSELECT_ANY.to_string()];
type_options.extend(types.iter().map(|s| (*s).to_string()));
let mut layer_options: Vec<String> = 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",
+50
View File
@@ -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<TruckEntity>;
fn grips(&self) -> Vec<GripDef>;
+112
View File
@@ -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<Handle> {
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<String> {
let mut names: std::collections::BTreeSet<String> =
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]) {