From 09cf99f85c501de7f2bd44817acc4fbd92af9990 Mon Sep 17 00:00:00 2001 From: turbuczn Date: Mon, 12 Oct 2026 22:24:20 +0200 Subject: [PATCH] feat: Add advanced selection filter --- src/app/command_driver/mod.rs | 8 ++ src/app/mod.rs | 43 ++++-- src/app/update/command.rs | 9 +- src/app/update/mod.rs | 75 ++++++---- src/app/update/qselect.rs | 253 ++++++++++++++++++++++++++++++++++ src/app/view/overlay.rs | 97 ++++++++++++- src/command.rs | 12 +- src/scene/selection.rs | 204 +++++++++++++++++++++++---- 8 files changed, 621 insertions(+), 80 deletions(-) create mode 100644 src/app/update/qselect.rs diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index b34b45038..404238e41 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -169,6 +169,7 @@ impl OpenCADStudio { &result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..) + | CmdResult::QSelectSample { .. } | CmdResult::SolidEdgeBlend { .. } | CmdResult::SolidSubtract { .. } | CmdResult::SliceEntities { .. } @@ -436,6 +437,13 @@ impl OpenCADStudio { // runtime or messages such as a chosen render mode are dropped. let mut dispatched = Task::none(); match result { + CmdResult::QSelectSample { + state, + tab_id, + handle, + } => { + self.finish_qselect_sample(*state, tab_id, handle); + } CmdResult::OpenAutoConstrainSettings => { self.handle_open_auto_constrain_settings(); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 0e2242b9e..000ddec0c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -237,18 +237,27 @@ impl QSelectCondition { if self.type_filter.is_none() && self.property.is_none() { return Some(crate::t!("Choose an object type or property.").into_owned()); } - let Some(property) = &self.property else { return None; }; - if matches!(self.operator, QSelectOp::Any) { return None; } + let Some(property) = &self.property else { + return None; + }; + if matches!(self.operator, QSelectOp::Any) { + return None; + } if matches!(self.operator, QSelectOp::Gt | QSelectOp::Lt) - && !matches!(property.editor, QSelectValueEditor::Number) { + && !matches!(property.editor, QSelectValueEditor::Number) + { return Some(crate::t!("This operator requires a numeric property.").into_owned()); } match &property.editor { - QSelectValueEditor::Number if crate::entities::common::parse_f64(&self.value) - .is_none_or(|value| !value.is_finite()) => - Some(crate::t!("Enter a valid number.").into_owned()), - QSelectValueEditor::Choice(_) if self.value.is_empty() => - Some(crate::t!("Choose a value.").into_owned()), + QSelectValueEditor::Number + if crate::entities::common::parse_f64(&self.value) + .is_none_or(|value| !value.is_finite()) => + { + Some(crate::t!("Enter a valid number.").into_owned()) + } + QSelectValueEditor::Choice(_) if self.value.is_empty() => { + Some(crate::t!("Choose a value.").into_owned()) + } _ => None, } } @@ -277,9 +286,10 @@ pub struct QSelectState { impl QSelectState { pub fn normalize_operator(&mut self) { if matches!(self.operator, QSelectOp::Gt | QSelectOp::Lt) - && !self.property.as_ref().is_some_and(|property| { - matches!(property.editor, QSelectValueEditor::Number) - }) + && !self + .property + .as_ref() + .is_some_and(|property| matches!(property.editor, QSelectValueEditor::Number)) { self.operator = QSelectOp::Eq; } @@ -2312,7 +2322,9 @@ pub enum Message { /// A frame has been drawn (or the anti-hang timer expired) for a parked /// `capture` request: take the screenshot now, so it shows the latest /// state instead of the previous frame (C11). - ControlCaptureFrame { redrew: bool }, + ControlCaptureFrame { + redrew: bool, + }, ControlToggle, /// Node graph overlay interaction. Graph(crate::ui::node_graph::GraphMsg), @@ -5237,9 +5249,10 @@ impl OpenCADStudio { // control request by setting `opencadstudio.automation` to "1" in // localStorage (the same pre-load mechanism the settings use): the // startup donation prompt then never queues (#1349). - if web_sys::window().and_then(|window| window.local_storage().ok().flatten()).and_then( - |storage| storage.get_item("opencadstudio.automation").ok().flatten(), - ) == Some("1".to_string()) + if web_sys::window() + .and_then(|window| window.local_storage().ok().flatten()) + .and_then(|storage| storage.get_item("opencadstudio.automation").ok().flatten()) + == Some("1".to_string()) { s.control.automation_seen = true; } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 19eda0861..8688f8c86 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2111,9 +2111,8 @@ impl OpenCADStudio { pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec) -> Task { let i = self.active_tab; if self.tabs[i].is_start { - self.command_line.push_error( - crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref(), - ); + self.command_line + .push_error(crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref()); return Task::none(); } crate::scene::model::image_model::register_source(&name, std::sync::Arc::new(bytes)); @@ -2227,6 +2226,10 @@ impl OpenCADStudio { let append = matches!(scope, crate::app::QSelectScope::CurrentSpace) && remembered.as_ref().is_some_and(|settings| settings.append); self.qselect = Some(crate::app::QSelectState { + conditions: remembered + .as_ref() + .map_or_else(Vec::new, |settings| settings.conditions.clone()), + editing_condition: None, scope, available_types, available_properties, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 50c26e176..11c0608e5 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -511,12 +511,15 @@ impl OpenCADStudio { .as_ref() .map(|c| c.options()) .or_else(|| { - self.graphic_attributes.pending_fill_close.as_ref().map(|_| { - vec![ - crate::command::CmdOption::new("Yes", "Y"), - crate::command::CmdOption::new("No", "N"), - ] - }) + self.graphic_attributes + .pending_fill_close + .as_ref() + .map(|_| { + vec![ + crate::command::CmdOption::new("Yes", "Y"), + crate::command::CmdOption::new("No", "N"), + ] + }) }) .unwrap_or_default(); self.command_line.set_step_options(opts); @@ -747,9 +750,7 @@ impl OpenCADStudio { // A settled fetch's rebuild trigger was dropped at the // outcome cap; do one unconditional catch-up pass over // every registered name. - registered.extend( - crate::scene::model::image_model::registered_image_names(), - ); + registered.extend(crate::scene::model::image_model::registered_image_names()); } if !registered.is_empty() { for tab in self.tabs.iter_mut() { @@ -925,9 +926,8 @@ impl OpenCADStudio { // (its WebFileOpened then fails the id check and the // bytes are lost) — refuse like on_open_file does. if self.opening.is_some() { - self.command_line.push_info( - crate::t!("An open is already in progress.").as_ref(), - ); + self.command_line + .push_info(crate::t!("An open is already in progress.").as_ref()); return Task::none(); } let state = std::sync::Arc::new(crate::io::OpenProgressState::new( @@ -1422,7 +1422,7 @@ impl OpenCADStudio { LinkOutcome::Error(text) => self.command_line.push_error(&text), } Task::none() - }, + } #[cfg(target_arch = "wasm32")] Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result), @@ -1697,10 +1697,7 @@ impl OpenCADStudio { ) .add_filter(crate::t!("DWG Files").as_ref(), &["dwg", "DWG"]) .add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"]) - .add_filter( - crate::t!("Backup Files").as_ref(), - &["bak", "BAK"], - ) + .add_filter(crate::t!("Backup Files").as_ref(), &["bak", "BAK"]) .pick_file() .await; match handle { @@ -7094,17 +7091,26 @@ impl OpenCADStudio { return Task::none(); } if let Some(index) = state.editing_condition.take() { - if let Some(existing) = state.conditions.get_mut(index) { *existing = condition; } - } else { state.conditions.push(condition); } + if let Some(existing) = state.conditions.get_mut(index) { + *existing = condition; + } + } else { + state.conditions.push(condition); + } state.error = None; } Task::none() } Message::QSelectEditCondition(index) => { - let condition = self.qselect.as_ref().and_then(|state| state.conditions.get(index).cloned()); + let condition = self + .qselect + .as_ref() + .and_then(|state| state.conditions.get(index).cloned()); if let Some(condition) = condition { let scope = self.qselect.as_ref().unwrap().scope; - let properties = self.tabs[self.active_tab].scene.qselect_properties(condition.type_filter.as_deref(), scope); + let properties = self.tabs[self.active_tab] + .scene + .qselect_properties(condition.type_filter.as_deref(), scope); if let Some(state) = self.qselect.as_mut() { state.available_properties = properties; state.type_filter = condition.type_filter; @@ -7148,14 +7154,24 @@ impl OpenCADStudio { } Message::QSelectApply => { let validation_error = self.qselect.as_ref().and_then(|state| { - if self.tabs[self.active_tab].scene.qselect_candidate_count(state.scope) == 0 { + if self.tabs[self.active_tab] + .scene + .qselect_candidate_count(state.scope) + == 0 + { Some(crate::t!("No objects are available in this scope.").into_owned()) } else if state.editing_condition.is_some() { - Some(crate::t!("Update or cancel the condition edit before applying.").into_owned()) + Some( + crate::t!("Update or cancel the condition edit before applying.") + .into_owned(), + ) } else if state.conditions.is_empty() { state.draft_condition().validation_error() } else { - state.conditions.iter().find_map(|condition| condition.validation_error()) + state + .conditions + .iter() + .find_map(|condition| condition.validation_error()) } }); if let Some(error) = validation_error { @@ -7172,9 +7188,13 @@ impl OpenCADStudio { let i = self.active_tab; let conditions = if state.conditions.is_empty() { vec![state.draft_condition()] - } else { state.conditions.clone() }; + } else { + state.conditions.clone() + }; let matched = self.tabs[i].scene.qselect_conditions( - state.scope, &conditions, state.mode, + state.scope, + &conditions, + state.mode, state.append && matches!(state.scope, crate::app::QSelectScope::CurrentSpace), ); self.command_line @@ -7648,7 +7668,8 @@ impl OpenCADStudio { crate::ui::window::graphic_attributes::LINETYPE_SCALE_FIELD, ); if focused.as_ref() == Some(&scale_field) { - if !std::mem::replace(&mut self.graphic_attributes.linetype_scale_focused, true) { + if !std::mem::replace(&mut self.graphic_attributes.linetype_scale_focused, true) + { return iced::widget::operation::select_all(scale_field); } return Task::none(); diff --git a/src/app/update/qselect.rs b/src/app/update/qselect.rs new file mode 100644 index 000000000..f2805f55c --- /dev/null +++ b/src/app/update/qselect.rs @@ -0,0 +1,253 @@ +//! Read-only object sampling for the selection-filter dialog. + +use crate::app::{OpenCADStudio, QSelectCondition, QSelectState}; +use crate::command::{CadCommand, CmdResult}; +use codec::Handle; +use glam::DVec3; +use iced::Task; + +/// The command owns the suspended dialog, so Escape can restore the exact +/// draft, committed conditions, scope and result mode without global pick state. +struct QSelectSampleCommand { + state: Option>, + tab_id: u64, +} + +impl QSelectSampleCommand { + fn finish(&mut self, handle: Option) -> CmdResult { + match self.state.take() { + Some(state) => CmdResult::QSelectSample { + state, + tab_id: self.tab_id, + handle, + }, + None => CmdResult::Cancel, + } + } +} + +impl CadCommand for QSelectSampleCommand { + fn name(&self) -> &'static str { + "FILTER" + } + + fn prompt(&self) -> String { + format!("FILTER {}", crate::t!("Select object:")) + } + + fn needs_entity_pick(&self) -> bool { + true + } + fn entity_pick_includes_fills(&self) -> bool { + true + } + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult { + if handle.is_null() { + CmdResult::NeedPoint + } else { + self.finish(Some(handle)) + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + fn on_enter(&mut self) -> CmdResult { + self.finish(None) + } + fn on_escape(&mut self) -> CmdResult { + self.finish(None) + } +} + +fn append_unique_conditions(target: &mut Vec, incoming: Vec) { + for condition in incoming { + let duplicate = target.iter().any(|existing| { + existing.type_filter == condition.type_filter + && existing.property == condition.property + && existing.operator == condition.operator + && existing.value.eq_ignore_ascii_case(&condition.value) + }); + if !duplicate { + target.push(condition); + } + } +} + +impl OpenCADStudio { + pub(super) fn on_qselect_pick_object(&mut self) -> Task { + // Finish or cancel a condition edit first. Sampling only appends rows. + if self + .qselect + .as_ref() + .is_none_or(|state| state.editing_condition.is_some()) + { + return Task::none(); + } + let state = self.qselect.take().expect("checked open filter"); + // Keep a recoverable copy if another command or a document switch + // interrupts picking instead of ending it through Enter/Escape. + self.qselect_settings = Some((&state).into()); + let i = self.active_tab; + let command = QSelectSampleCommand { + state: Some(Box::new(state)), + tab_id: self.tabs[i].id, + }; + self.active_modal = None; + self.command_line.input.clear(); + self.command_line.set_step_options(Vec::new()); + self.command_line.push_info(&command.prompt()); + self.tabs[i].scene.selection.borrow_mut().menu.open_at = None; + self.tabs[i].active_cmd = Some(Box::new(command)); + Task::none() + } + + pub(in crate::app) fn finish_qselect_sample( + &mut self, + mut state: QSelectState, + tab_id: u64, + handle: Option, + ) { + let i = self.active_tab; + // A handle is only meaningful in its original document. + if self.tabs[i].id != tab_id { + return; + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + if let Some(handle) = handle { + let conditions = self.tabs[i].scene.qselect_conditions_from_entity(handle); + append_unique_conditions(&mut state.conditions, conditions); + state.error = None; + } + self.qselect = Some(state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{QSelectMode, QSelectOp, QSelectScope}; + + fn state() -> QSelectState { + QSelectState { + conditions: Vec::new(), + editing_condition: None, + scope: QSelectScope::CurrentSelection, + available_types: Vec::new(), + available_properties: Vec::new(), + candidate_count: 2, + type_filter: Some("Line".into()), + property: None, + operator: QSelectOp::Eq, + value: "unfinished draft".into(), + mode: QSelectMode::Exclude, + append: false, + error: None, + } + } + + #[test] + fn escape_restores_draft_and_scope_without_a_sample() { + let mut command = QSelectSampleCommand { + state: Some(Box::new(state())), + tab_id: 17, + }; + let CmdResult::QSelectSample { + state, + tab_id, + handle, + } = command.on_escape() + else { + panic!("Escape must restore the filter"); + }; + assert_eq!(tab_id, 17); + assert!(handle.is_none()); + assert_eq!(state.value, "unfinished draft"); + assert!(matches!(state.scope, QSelectScope::CurrentSelection)); + assert!(matches!(state.mode, QSelectMode::Exclude)); + } + + #[test] + fn empty_click_keeps_picker_active_and_enter_restores_dialog() { + let mut command = QSelectSampleCommand { + state: Some(Box::new(state())), + tab_id: 17, + }; + assert!(matches!( + command.on_entity_pick(Handle::NULL, DVec3::ZERO), + CmdResult::NeedPoint + )); + assert!(matches!( + command.on_enter(), + CmdResult::QSelectSample { handle: None, .. } + )); + } + + #[test] + fn repeated_sampling_preserves_existing_conditions_without_duplicates() { + let condition = state().draft_condition(); + let mut conditions = vec![condition.clone()]; + append_unique_conditions(&mut conditions, vec![condition.clone(), condition]); + assert_eq!(conditions.len(), 1); + assert_eq!(conditions[0].value, "unfinished draft"); + } + + #[test] + fn sample_round_trip_keeps_selection_and_dialog_geometry() { + use codec::entities::Line; + use codec::types::Vector3; + let mut app = OpenCADStudio::new_for_test(); + let _ = app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let first = app.tabs[i] + .scene + .add_entity(codec::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(10.0, 0.0, 0.0), + ))); + let sample = app.tabs[i] + .scene + .add_entity(codec::EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(20.0, 0.0, 0.0), + ))); + app.tabs[i].scene.select_entity(first, false); + let selection = app.tabs[i].scene.selected.clone(); + let dirty = app.tabs[i].dirty; + app.qselect = Some(state()); + app.modal_offset = iced::Vector::new(10.0, 20.0); + app.modal_resize = iced::Vector::new(30.0, 40.0); + + let _ = app.on_qselect_pick_object(); + assert!(app.qselect.is_none()); + let result = app.tabs[i] + .active_cmd + .as_mut() + .unwrap() + .on_entity_pick(sample, DVec3::ZERO); + let _ = app.apply_cmd_result(result); + let restored = app.qselect.as_ref().unwrap(); + assert!(!restored.conditions.is_empty()); + assert_eq!(restored.value, "unfinished draft"); + assert!(matches!(restored.scope, QSelectScope::CurrentSelection)); + assert_eq!(app.tabs[i].scene.selected, selection); + assert_eq!(app.tabs[i].dirty, dirty); + assert_eq!(app.modal_offset, iced::Vector::new(10.0, 20.0)); + assert_eq!(app.modal_resize, iced::Vector::new(30.0, 40.0)); + assert!(app.tabs[i].active_cmd.is_none()); + + let count = restored.conditions.len(); + let _ = app.on_qselect_pick_object(); + let result = app.tabs[i].active_cmd.as_mut().unwrap().on_escape(); + let _ = app.apply_cmd_result(result); + assert_eq!(app.qselect.as_ref().unwrap().conditions.len(), count); + assert_eq!(app.tabs[i].scene.selected, selection); + } +} diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index b2b896fdf..1943f69ab 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -1488,8 +1488,13 @@ pub(super) fn qselect_overlay<'a>( candidate_count, crate::ui::modal::ModalSizing::FILL, ); - let content = - crate::ui::modal::intrinsic(measurement, content, iced::Size::INFINITE, modal_resize); + // Long imported values must not make the natural dialog span the viewport. + let content = crate::ui::modal::intrinsic( + measurement, + content, + iced::Size::new(720.0, f32::INFINITY), + modal_resize, + ); crate::ui::modal::modal( Space::new().width(Fill).height(Fill), @@ -1616,7 +1621,7 @@ fn qselect_content<'a>( } else { None }; - let error = state.error.clone().or(derived_error); + let error = state.error.clone().or(derived_error.clone()); let scope_options = vec![ crate::app::QSelectScope::CurrentSpace, @@ -1635,10 +1640,72 @@ fn qselect_content<'a>( let cancel = dialog_button(t!("Cancel"), Message::QSelectClose, false); let apply = dialog_button_styled_opt( t!("Apply"), - error.is_none().then_some(Message::QSelectApply), + (candidate_count > 0 + && state.editing_condition.is_none() + && (state.conditions.is_empty() && error.is_none() || !state.conditions.is_empty())) + .then_some(Message::QSelectApply), button::primary, ); + let mut condition_rows = column![].spacing(5); + if state.conditions.is_empty() { + condition_rows = condition_rows.push(text(t!("No conditions added.")).size(12)); + } + for (index, condition) in state.conditions.iter().enumerate() { + let type_label = condition + .type_filter + .as_deref() + .map(|name| t!(name).into_owned()) + .unwrap_or_else(|| t!(QSELECT_ANY_TYPE).into_owned()); + let property_label = condition + .property + .as_ref() + .map(|property| property.to_string()) + .unwrap_or_else(|| t!(QSELECT_ANY_PROP).into_owned()); + let summary = format!( + "{}. {} | {} | {} | {}", + index + 1, + type_label, + property_label, + condition.operator, + condition.value + ); + condition_rows = condition_rows.push( + row![ + // The measuring copy must not fill the viewport's available width. + text(summary).size(12).width(flex_width), + dialog_button(t!("Edit"), Message::QSelectEditCondition(index), false), + dialog_button(t!("Delete"), Message::QSelectRemoveCondition(index), false), + ] + .spacing(6) + .align_y(iced::Alignment::Center), + ); + } + let add_condition = dialog_button_styled_opt( + if state.editing_condition.is_some() { + t!("Update condition") + } else { + t!("Add condition") + }, + (derived_error.is_none() && (state.type_filter.is_some() || state.property.is_some())) + .then_some(Message::QSelectAddCondition), + button::secondary, + ); + let condition_actions = row![ + add_condition, + dialog_button_styled_opt( + t!("Cancel edit"), + state.editing_condition.map(|_| Message::QSelectCancelEdit), + button::secondary + ), + dialog_button_styled_opt( + t!("Clear conditions"), + (!state.conditions.is_empty()).then_some(Message::QSelectClearConditions), + button::secondary + ), + ] + .spacing(6); + let panel_body = column![ section_label(t!("Scope")), Space::new().height(5), @@ -1706,6 +1773,28 @@ fn qselect_content<'a>( Space::new().height(10), container(rule::horizontal(1)).width(flex_width), Space::new().height(10), + condition_actions, + Space::new().height(8), + dialog_button_styled_opt( + t!("Select object"), + state + .editing_condition + .is_none() + .then_some(Message::QSelectPickObject), + button::secondary, + ), + Space::new().height(8), + section_label(t!("Conditions (all must match)")), + Space::new().height(5), + iced::widget::scrollable(condition_rows) + .height(120) + .width(sizing.width), + Space::new().height(5), + text(t!( + "When the list is nonempty, only listed conditions are applied." + )) + .size(11), + Space::new().height(10), section_label(t!("Result")), Space::new().height(5), column![ diff --git a/src/command.rs b/src/command.rs index 54efd1035..9bf1a580e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -382,7 +382,11 @@ impl CadCommand for FreeTextValuePromptCommand { } fn on_text_input(&mut self, text: &str) -> Option { - Some(CmdResult::Dispatch(format!("{} {}", self.name, text.trim()))) + Some(CmdResult::Dispatch(format!( + "{} {}", + self.name, + text.trim() + ))) } fn on_point(&mut self, _pt: DVec3) -> CmdResult { @@ -1510,6 +1514,12 @@ impl DimensionPreview { /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { + /// Return the suspended filter after a read-only sample pick or cancellation. + QSelectSample { + state: Box, + tab_id: u64, + handle: Option, + }, /// Command is still waiting for the next point; show updated prompt. NeedPoint, /// Update the committed-segment wire (normal colour) and keep collecting points. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index d80d5c7fb..11b2a67d6 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -426,6 +426,27 @@ impl Scene { value: &str, mode: crate::app::QSelectMode, append: bool, + ) -> usize { + let condition = crate::app::QSelectCondition { + type_filter: type_name.map(str::to_owned), + property: property_field.map(|field| crate::app::QSelectPropertyChoice { + field: field.to_owned(), + label: field.to_owned(), + editor: crate::app::QSelectValueEditor::Text, + }), + operator: op, + value: value.to_owned(), + }; + self.qselect_conditions(scope, &[condition], mode, append) + } + + /// Evaluate all conditions against the same candidate set, then apply result behavior once. + pub fn qselect_conditions( + &mut self, + scope: crate::app::QSelectScope, + conditions: &[crate::app::QSelectCondition], + mode: crate::app::QSelectMode, + append: bool, ) -> usize { use crate::app::{QSelectMode, QSelectOp}; use crate::entities::traits::entity_type_name; @@ -436,33 +457,44 @@ impl Scene { 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 prop_ok = if !type_ok { - true - } else { - match (property_field, op) { - (None, _) | (_, QSelectOp::Any) => true, - (Some(field), op) => match self.entity_property_value(e, field) { - Some(actual) => match op { - QSelectOp::Eq => actual.eq_ignore_ascii_case(value), - QSelectOp::Neq => !actual.eq_ignore_ascii_case(value), - QSelectOp::Gt | QSelectOp::Lt => { - match ( - crate::entities::common::parse_f64(&actual), - crate::entities::common::parse_f64(value), - ) { - (Some(a), Some(b)) if matches!(op, QSelectOp::Gt) => a > b, - (Some(a), Some(b)) => a < b, - _ => false, + let matches_filter = conditions.iter().all(|condition| { + let type_ok = condition + .type_filter + .as_deref() + .is_none_or(|t| entity_type_name(e) == t); + let prop_ok = if !type_ok { + true + } else { + match ( + condition + .property + .as_ref() + .map(|property| property.field.as_str()), + condition.operator, + ) { + (None, _) | (_, QSelectOp::Any) => true, + (Some(field), op) => match self.entity_property_value(e, field) { + Some(actual) => match op { + QSelectOp::Eq => actual.eq_ignore_ascii_case(&condition.value), + QSelectOp::Neq => !actual.eq_ignore_ascii_case(&condition.value), + QSelectOp::Gt | QSelectOp::Lt => { + match ( + crate::entities::common::parse_f64(&actual), + crate::entities::common::parse_f64(&condition.value), + ) { + (Some(a), Some(b)) if matches!(op, QSelectOp::Gt) => a > b, + (Some(a), Some(b)) => a < b, + _ => false, + } } - } - QSelectOp::Any => true, + QSelectOp::Any => true, + }, + None => false, }, - None => false, - }, - } - }; - let matches_filter = type_ok && prop_ok; + } + }; + type_ok && prop_ok + }); let keep = match mode { QSelectMode::Include => matches_filter, QSelectMode::Exclude => !matches_filter, @@ -582,6 +614,17 @@ impl Scene { &self, type_name: Option<&str>, scope: crate::app::QSelectScope, + ) -> Vec { + self.qselect_properties_with_sample(type_name, scope, None) + } + + /// Use the picked entity's own property schema, including optional fields + /// that may not be present on the first entity of this type in the drawing. + fn qselect_properties_with_sample( + &self, + type_name: Option<&str>, + scope: crate::app::QSelectScope, + sample_handle: Option, ) -> Vec { use crate::app::{QSelectPropertyChoice, QSelectValueEditor}; use crate::entities::traits::{entity_type_name, EntityTypeOps}; @@ -680,11 +723,16 @@ impl Scene { .iter() .map(|s| s.name.clone()) .collect(); - let sample = candidate_handles - .iter() - .copied() - .filter_map(|h| self.document.get_entity(h)) - .find(|e| entity_type_name(e) == t); + let sample = sample_handle + .and_then(|handle| self.document.get_entity(handle)) + .filter(|entity| entity_type_name(entity) == t) + .or_else(|| { + candidate_handles + .iter() + .copied() + .filter_map(|h| self.document.get_entity(h)) + .find(|e| entity_type_name(e) == t) + }); if let Some(sample) = sample { let mut sections = vec![crate::scene::cache::properties::general_section(sample)]; if let Some(section) = @@ -836,6 +884,58 @@ impl Scene { out } + /// Build editable equality conditions without changing the current selection. + /// The unique handle and empty values are deliberately not sampled. + pub fn qselect_conditions_from_entity( + &self, + handle: Handle, + ) -> Vec { + use crate::app::{QSelectCondition, QSelectOp, QSelectScope, QSelectValueEditor}; + let Some(entity) = self.document.get_entity(handle) else { + return Vec::new(); + }; + let type_name = crate::entities::traits::entity_type_name(entity).to_owned(); + let mut conditions = vec![QSelectCondition { + type_filter: Some(type_name.clone()), + property: None, + operator: QSelectOp::Eq, + value: String::new(), + }]; + let properties = self.qselect_properties_with_sample( + Some(&type_name), + QSelectScope::CurrentSpace, + Some(handle), + ); + for mut property in properties { + if property.field == "handle" { + continue; + } + let Some(value) = self.entity_property_value(entity, &property.field) else { + continue; + }; + if value.trim().is_empty() { + continue; + } + // Ensure a sampled choice can also be edited, even outside the + // filter's current-selection scope. + if let QSelectValueEditor::Choice(options) = &mut property.editor { + if !options.contains(&value) { + options.push(value.clone()); + } + } + let condition = QSelectCondition { + type_filter: Some(type_name.clone()), + property: Some(property), + operator: QSelectOp::Eq, + value, + }; + if condition.validation_error().is_none() { + conditions.push(condition); + } + } + conditions + } + /// Reads a property value from an entity for QSELECT comparison. /// Returns the canonical string used as the left-hand side of the /// operator test. Common properties have hand-rolled formatting so @@ -1172,6 +1272,50 @@ impl Scene { #[cfg(test)] mod tests { + #[test] + fn qselect_sample_matches_identical_entities_without_unique_handles() { + use crate::app::{QSelectMode, QSelectScope}; + use codec::entities::Line; + use codec::types::Vector3; + let mut scene = Scene::new(); + let line = || { + EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::new(10.0, 0.0, 0.0), + )) + }; + let first = scene.add_entity(line()); + let second = scene.add_entity(line()); + scene.select_entity(second, false); + let before = scene.selected.clone(); + let conditions = scene.qselect_conditions_from_entity(first); + assert_eq!( + scene.selected, before, + "sampling must not change the selection" + ); + assert!(conditions.iter().any(|condition| condition + .property + .as_ref() + .is_some_and(|property| property.field == "layer"))); + assert!(conditions.iter().all(|condition| condition + .property + .as_ref() + .is_none_or(|property| property.field != "handle"))); + assert!(conditions + .iter() + .all(|condition| condition.validation_error().is_none())); + assert_eq!( + scene.qselect_conditions( + QSelectScope::CurrentSpace, + &conditions, + QSelectMode::Include, + false, + ), + 2 + ); + assert!(scene.selected.contains(&first) && scene.selected.contains(&second)); + } + #[test] fn pe_url_of_reads_standard_hyperlink_xdata() { use codec::entities::Point;