forked from 1b-agent/opencadstudio-upstream
feat: Add advanced selection filter
This commit is contained in:
1 parent
7c61583ac2
commit
09cf99f85c
8 files changed
+621
-80
No files matched your search
@@ -169,6 +169,7 @@ impl OpenCADStudio {
|
|||||||
&result,
|
&result,
|
||||||
CmdResult::Relaunch(..)
|
CmdResult::Relaunch(..)
|
||||||
| CmdResult::Dispatch(..)
|
| CmdResult::Dispatch(..)
|
||||||
|
| CmdResult::QSelectSample { .. }
|
||||||
| CmdResult::SolidEdgeBlend { .. }
|
| CmdResult::SolidEdgeBlend { .. }
|
||||||
| CmdResult::SolidSubtract { .. }
|
| CmdResult::SolidSubtract { .. }
|
||||||
| CmdResult::SliceEntities { .. }
|
| CmdResult::SliceEntities { .. }
|
||||||
@@ -436,6 +437,13 @@ impl OpenCADStudio {
|
|||||||
// runtime or messages such as a chosen render mode are dropped.
|
// runtime or messages such as a chosen render mode are dropped.
|
||||||
let mut dispatched = Task::none();
|
let mut dispatched = Task::none();
|
||||||
match result {
|
match result {
|
||||||
|
CmdResult::QSelectSample {
|
||||||
|
state,
|
||||||
|
tab_id,
|
||||||
|
handle,
|
||||||
|
} => {
|
||||||
|
self.finish_qselect_sample(*state, tab_id, handle);
|
||||||
|
}
|
||||||
CmdResult::OpenAutoConstrainSettings => {
|
CmdResult::OpenAutoConstrainSettings => {
|
||||||
self.handle_open_auto_constrain_settings();
|
self.handle_open_auto_constrain_settings();
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-15
@@ -237,18 +237,27 @@ impl QSelectCondition {
|
|||||||
if self.type_filter.is_none() && self.property.is_none() {
|
if self.type_filter.is_none() && self.property.is_none() {
|
||||||
return Some(crate::t!("Choose an object type or property.").into_owned());
|
return Some(crate::t!("Choose an object type or property.").into_owned());
|
||||||
}
|
}
|
||||||
let Some(property) = &self.property else { return None; };
|
let Some(property) = &self.property else {
|
||||||
if matches!(self.operator, QSelectOp::Any) { return None; }
|
return None;
|
||||||
|
};
|
||||||
|
if matches!(self.operator, QSelectOp::Any) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
if matches!(self.operator, QSelectOp::Gt | QSelectOp::Lt)
|
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());
|
return Some(crate::t!("This operator requires a numeric property.").into_owned());
|
||||||
}
|
}
|
||||||
match &property.editor {
|
match &property.editor {
|
||||||
QSelectValueEditor::Number if crate::entities::common::parse_f64(&self.value)
|
QSelectValueEditor::Number
|
||||||
.is_none_or(|value| !value.is_finite()) =>
|
if crate::entities::common::parse_f64(&self.value)
|
||||||
Some(crate::t!("Enter a valid number.").into_owned()),
|
.is_none_or(|value| !value.is_finite()) =>
|
||||||
QSelectValueEditor::Choice(_) if self.value.is_empty() =>
|
{
|
||||||
Some(crate::t!("Choose a value.").into_owned()),
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,9 +286,10 @@ pub struct QSelectState {
|
|||||||
impl QSelectState {
|
impl QSelectState {
|
||||||
pub fn normalize_operator(&mut self) {
|
pub fn normalize_operator(&mut self) {
|
||||||
if matches!(self.operator, QSelectOp::Gt | QSelectOp::Lt)
|
if matches!(self.operator, QSelectOp::Gt | QSelectOp::Lt)
|
||||||
&& !self.property.as_ref().is_some_and(|property| {
|
&& !self
|
||||||
matches!(property.editor, QSelectValueEditor::Number)
|
.property
|
||||||
})
|
.as_ref()
|
||||||
|
.is_some_and(|property| matches!(property.editor, QSelectValueEditor::Number))
|
||||||
{
|
{
|
||||||
self.operator = QSelectOp::Eq;
|
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
|
/// 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
|
/// `capture` request: take the screenshot now, so it shows the latest
|
||||||
/// state instead of the previous frame (C11).
|
/// state instead of the previous frame (C11).
|
||||||
ControlCaptureFrame { redrew: bool },
|
ControlCaptureFrame {
|
||||||
|
redrew: bool,
|
||||||
|
},
|
||||||
ControlToggle,
|
ControlToggle,
|
||||||
/// Node graph overlay interaction.
|
/// Node graph overlay interaction.
|
||||||
Graph(crate::ui::node_graph::GraphMsg),
|
Graph(crate::ui::node_graph::GraphMsg),
|
||||||
@@ -5237,9 +5249,10 @@ impl OpenCADStudio {
|
|||||||
// control request by setting `opencadstudio.automation` to "1" in
|
// control request by setting `opencadstudio.automation` to "1" in
|
||||||
// localStorage (the same pre-load mechanism the settings use): the
|
// localStorage (the same pre-load mechanism the settings use): the
|
||||||
// startup donation prompt then never queues (#1349).
|
// startup donation prompt then never queues (#1349).
|
||||||
if web_sys::window().and_then(|window| window.local_storage().ok().flatten()).and_then(
|
if web_sys::window()
|
||||||
|storage| storage.get_item("opencadstudio.automation").ok().flatten(),
|
.and_then(|window| window.local_storage().ok().flatten())
|
||||||
) == Some("1".to_string())
|
.and_then(|storage| storage.get_item("opencadstudio.automation").ok().flatten())
|
||||||
|
== Some("1".to_string())
|
||||||
{
|
{
|
||||||
s.control.automation_seen = true;
|
s.control.automation_seen = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2111,9 +2111,8 @@ impl OpenCADStudio {
|
|||||||
pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec<u8>) -> Task<Message> {
|
pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec<u8>) -> Task<Message> {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
if self.tabs[i].is_start {
|
if self.tabs[i].is_start {
|
||||||
self.command_line.push_error(
|
self.command_line
|
||||||
crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref(),
|
.push_error(crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref());
|
||||||
);
|
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
crate::scene::model::image_model::register_source(&name, std::sync::Arc::new(bytes));
|
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)
|
let append = matches!(scope, crate::app::QSelectScope::CurrentSpace)
|
||||||
&& remembered.as_ref().is_some_and(|settings| settings.append);
|
&& remembered.as_ref().is_some_and(|settings| settings.append);
|
||||||
self.qselect = Some(crate::app::QSelectState {
|
self.qselect = Some(crate::app::QSelectState {
|
||||||
|
conditions: remembered
|
||||||
|
.as_ref()
|
||||||
|
.map_or_else(Vec::new, |settings| settings.conditions.clone()),
|
||||||
|
editing_condition: None,
|
||||||
scope,
|
scope,
|
||||||
available_types,
|
available_types,
|
||||||
available_properties,
|
available_properties,
|
||||||
|
|||||||
+48
-27
@@ -511,12 +511,15 @@ impl OpenCADStudio {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| c.options())
|
.map(|c| c.options())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
self.graphic_attributes.pending_fill_close.as_ref().map(|_| {
|
self.graphic_attributes
|
||||||
vec![
|
.pending_fill_close
|
||||||
crate::command::CmdOption::new("Yes", "Y"),
|
.as_ref()
|
||||||
crate::command::CmdOption::new("No", "N"),
|
.map(|_| {
|
||||||
]
|
vec![
|
||||||
})
|
crate::command::CmdOption::new("Yes", "Y"),
|
||||||
|
crate::command::CmdOption::new("No", "N"),
|
||||||
|
]
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
self.command_line.set_step_options(opts);
|
self.command_line.set_step_options(opts);
|
||||||
@@ -747,9 +750,7 @@ impl OpenCADStudio {
|
|||||||
// A settled fetch's rebuild trigger was dropped at the
|
// A settled fetch's rebuild trigger was dropped at the
|
||||||
// outcome cap; do one unconditional catch-up pass over
|
// outcome cap; do one unconditional catch-up pass over
|
||||||
// every registered name.
|
// every registered name.
|
||||||
registered.extend(
|
registered.extend(crate::scene::model::image_model::registered_image_names());
|
||||||
crate::scene::model::image_model::registered_image_names(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if !registered.is_empty() {
|
if !registered.is_empty() {
|
||||||
for tab in self.tabs.iter_mut() {
|
for tab in self.tabs.iter_mut() {
|
||||||
@@ -925,9 +926,8 @@ impl OpenCADStudio {
|
|||||||
// (its WebFileOpened then fails the id check and the
|
// (its WebFileOpened then fails the id check and the
|
||||||
// bytes are lost) — refuse like on_open_file does.
|
// bytes are lost) — refuse like on_open_file does.
|
||||||
if self.opening.is_some() {
|
if self.opening.is_some() {
|
||||||
self.command_line.push_info(
|
self.command_line
|
||||||
crate::t!("An open is already in progress.").as_ref(),
|
.push_info(crate::t!("An open is already in progress.").as_ref());
|
||||||
);
|
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
|
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),
|
LinkOutcome::Error(text) => self.command_line.push_error(&text),
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
},
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
Message::WebAutosavesScanned(result) => self.on_web_autosaves_scanned(result),
|
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!("DWG Files").as_ref(), &["dwg", "DWG"])
|
||||||
.add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"])
|
.add_filter(crate::t!("DXF Files").as_ref(), &["dxf", "DXF"])
|
||||||
.add_filter(
|
.add_filter(crate::t!("Backup Files").as_ref(), &["bak", "BAK"])
|
||||||
crate::t!("Backup Files").as_ref(),
|
|
||||||
&["bak", "BAK"],
|
|
||||||
)
|
|
||||||
.pick_file()
|
.pick_file()
|
||||||
.await;
|
.await;
|
||||||
match handle {
|
match handle {
|
||||||
@@ -7094,17 +7091,26 @@ impl OpenCADStudio {
|
|||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
if let Some(index) = state.editing_condition.take() {
|
if let Some(index) = state.editing_condition.take() {
|
||||||
if let Some(existing) = state.conditions.get_mut(index) { *existing = condition; }
|
if let Some(existing) = state.conditions.get_mut(index) {
|
||||||
} else { state.conditions.push(condition); }
|
*existing = condition;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.conditions.push(condition);
|
||||||
|
}
|
||||||
state.error = None;
|
state.error = None;
|
||||||
}
|
}
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::QSelectEditCondition(index) => {
|
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 {
|
if let Some(condition) = condition {
|
||||||
let scope = self.qselect.as_ref().unwrap().scope;
|
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() {
|
if let Some(state) = self.qselect.as_mut() {
|
||||||
state.available_properties = properties;
|
state.available_properties = properties;
|
||||||
state.type_filter = condition.type_filter;
|
state.type_filter = condition.type_filter;
|
||||||
@@ -7148,14 +7154,24 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
Message::QSelectApply => {
|
Message::QSelectApply => {
|
||||||
let validation_error = self.qselect.as_ref().and_then(|state| {
|
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())
|
Some(crate::t!("No objects are available in this scope.").into_owned())
|
||||||
} else if state.editing_condition.is_some() {
|
} 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() {
|
} else if state.conditions.is_empty() {
|
||||||
state.draft_condition().validation_error()
|
state.draft_condition().validation_error()
|
||||||
} else {
|
} 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 {
|
if let Some(error) = validation_error {
|
||||||
@@ -7172,9 +7188,13 @@ impl OpenCADStudio {
|
|||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
let conditions = if state.conditions.is_empty() {
|
let conditions = if state.conditions.is_empty() {
|
||||||
vec![state.draft_condition()]
|
vec![state.draft_condition()]
|
||||||
} else { state.conditions.clone() };
|
} else {
|
||||||
|
state.conditions.clone()
|
||||||
|
};
|
||||||
let matched = self.tabs[i].scene.qselect_conditions(
|
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),
|
state.append && matches!(state.scope, crate::app::QSelectScope::CurrentSpace),
|
||||||
);
|
);
|
||||||
self.command_line
|
self.command_line
|
||||||
@@ -7648,7 +7668,8 @@ impl OpenCADStudio {
|
|||||||
crate::ui::window::graphic_attributes::LINETYPE_SCALE_FIELD,
|
crate::ui::window::graphic_attributes::LINETYPE_SCALE_FIELD,
|
||||||
);
|
);
|
||||||
if focused.as_ref() == Some(&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 iced::widget::operation::select_all(scale_field);
|
||||||
}
|
}
|
||||||
return Task::none();
|
return Task::none();
|
||||||
|
|||||||
@@ -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<Box<QSelectState>>,
|
||||||
|
tab_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QSelectSampleCommand {
|
||||||
|
fn finish(&mut self, handle: Option<Handle>) -> 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<QSelectCondition>, incoming: Vec<QSelectCondition>) {
|
||||||
|
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<crate::app::Message> {
|
||||||
|
// 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<Handle>,
|
||||||
|
) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+93
-4
@@ -1488,8 +1488,13 @@ pub(super) fn qselect_overlay<'a>(
|
|||||||
candidate_count,
|
candidate_count,
|
||||||
crate::ui::modal::ModalSizing::FILL,
|
crate::ui::modal::ModalSizing::FILL,
|
||||||
);
|
);
|
||||||
let content =
|
// Long imported values must not make the natural dialog span the viewport.
|
||||||
crate::ui::modal::intrinsic(measurement, content, iced::Size::INFINITE, modal_resize);
|
let content = crate::ui::modal::intrinsic(
|
||||||
|
measurement,
|
||||||
|
content,
|
||||||
|
iced::Size::new(720.0, f32::INFINITY),
|
||||||
|
modal_resize,
|
||||||
|
);
|
||||||
|
|
||||||
crate::ui::modal::modal(
|
crate::ui::modal::modal(
|
||||||
Space::new().width(Fill).height(Fill),
|
Space::new().width(Fill).height(Fill),
|
||||||
@@ -1616,7 +1621,7 @@ fn qselect_content<'a>(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let error = state.error.clone().or(derived_error);
|
let error = state.error.clone().or(derived_error.clone());
|
||||||
|
|
||||||
let scope_options = vec![
|
let scope_options = vec![
|
||||||
crate::app::QSelectScope::CurrentSpace,
|
crate::app::QSelectScope::CurrentSpace,
|
||||||
@@ -1635,10 +1640,72 @@ fn qselect_content<'a>(
|
|||||||
let cancel = dialog_button(t!("Cancel"), Message::QSelectClose, false);
|
let cancel = dialog_button(t!("Cancel"), Message::QSelectClose, false);
|
||||||
let apply = dialog_button_styled_opt(
|
let apply = dialog_button_styled_opt(
|
||||||
t!("Apply"),
|
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,
|
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![
|
let panel_body = column![
|
||||||
section_label(t!("Scope")),
|
section_label(t!("Scope")),
|
||||||
Space::new().height(5),
|
Space::new().height(5),
|
||||||
@@ -1706,6 +1773,28 @@ fn qselect_content<'a>(
|
|||||||
Space::new().height(10),
|
Space::new().height(10),
|
||||||
container(rule::horizontal(1)).width(flex_width),
|
container(rule::horizontal(1)).width(flex_width),
|
||||||
Space::new().height(10),
|
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")),
|
section_label(t!("Result")),
|
||||||
Space::new().height(5),
|
Space::new().height(5),
|
||||||
column![
|
column![
|
||||||
|
|||||||
+11
-1
@@ -382,7 +382,11 @@ impl CadCommand for FreeTextValuePromptCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||||
Some(CmdResult::Dispatch(format!("{} {}", self.name, text.trim())))
|
Some(CmdResult::Dispatch(format!(
|
||||||
|
"{} {}",
|
||||||
|
self.name,
|
||||||
|
text.trim()
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
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.
|
/// Returned by every `CadCommand` method to tell main.rs what to do.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub enum CmdResult {
|
pub enum CmdResult {
|
||||||
|
/// Return the suspended filter after a read-only sample pick or cancellation.
|
||||||
|
QSelectSample {
|
||||||
|
state: Box<crate::app::QSelectState>,
|
||||||
|
tab_id: u64,
|
||||||
|
handle: Option<Handle>,
|
||||||
|
},
|
||||||
/// Command is still waiting for the next point; show updated prompt.
|
/// Command is still waiting for the next point; show updated prompt.
|
||||||
NeedPoint,
|
NeedPoint,
|
||||||
/// Update the committed-segment wire (normal colour) and keep collecting points.
|
/// Update the committed-segment wire (normal colour) and keep collecting points.
|
||||||
|
|||||||
+174
-30
@@ -426,6 +426,27 @@ impl Scene {
|
|||||||
value: &str,
|
value: &str,
|
||||||
mode: crate::app::QSelectMode,
|
mode: crate::app::QSelectMode,
|
||||||
append: bool,
|
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 {
|
) -> usize {
|
||||||
use crate::app::{QSelectMode, QSelectOp};
|
use crate::app::{QSelectMode, QSelectOp};
|
||||||
use crate::entities::traits::entity_type_name;
|
use crate::entities::traits::entity_type_name;
|
||||||
@@ -436,33 +457,44 @@ impl Scene {
|
|||||||
let Some(e) = self.document.get_entity(h) else {
|
let Some(e) = self.document.get_entity(h) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let type_ok = type_name.is_none_or(|t| entity_type_name(e) == t);
|
let matches_filter = conditions.iter().all(|condition| {
|
||||||
let prop_ok = if !type_ok {
|
let type_ok = condition
|
||||||
true
|
.type_filter
|
||||||
} else {
|
.as_deref()
|
||||||
match (property_field, op) {
|
.is_none_or(|t| entity_type_name(e) == t);
|
||||||
(None, _) | (_, QSelectOp::Any) => true,
|
let prop_ok = if !type_ok {
|
||||||
(Some(field), op) => match self.entity_property_value(e, field) {
|
true
|
||||||
Some(actual) => match op {
|
} else {
|
||||||
QSelectOp::Eq => actual.eq_ignore_ascii_case(value),
|
match (
|
||||||
QSelectOp::Neq => !actual.eq_ignore_ascii_case(value),
|
condition
|
||||||
QSelectOp::Gt | QSelectOp::Lt => {
|
.property
|
||||||
match (
|
.as_ref()
|
||||||
crate::entities::common::parse_f64(&actual),
|
.map(|property| property.field.as_str()),
|
||||||
crate::entities::common::parse_f64(value),
|
condition.operator,
|
||||||
) {
|
) {
|
||||||
(Some(a), Some(b)) if matches!(op, QSelectOp::Gt) => a > b,
|
(None, _) | (_, QSelectOp::Any) => true,
|
||||||
(Some(a), Some(b)) => a < b,
|
(Some(field), op) => match self.entity_property_value(e, field) {
|
||||||
_ => false,
|
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,
|
}
|
||||||
},
|
};
|
||||||
}
|
type_ok && prop_ok
|
||||||
};
|
});
|
||||||
let matches_filter = type_ok && prop_ok;
|
|
||||||
let keep = match mode {
|
let keep = match mode {
|
||||||
QSelectMode::Include => matches_filter,
|
QSelectMode::Include => matches_filter,
|
||||||
QSelectMode::Exclude => !matches_filter,
|
QSelectMode::Exclude => !matches_filter,
|
||||||
@@ -582,6 +614,17 @@ impl Scene {
|
|||||||
&self,
|
&self,
|
||||||
type_name: Option<&str>,
|
type_name: Option<&str>,
|
||||||
scope: crate::app::QSelectScope,
|
scope: crate::app::QSelectScope,
|
||||||
|
) -> Vec<crate::app::QSelectPropertyChoice> {
|
||||||
|
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<Handle>,
|
||||||
) -> Vec<crate::app::QSelectPropertyChoice> {
|
) -> Vec<crate::app::QSelectPropertyChoice> {
|
||||||
use crate::app::{QSelectPropertyChoice, QSelectValueEditor};
|
use crate::app::{QSelectPropertyChoice, QSelectValueEditor};
|
||||||
use crate::entities::traits::{entity_type_name, EntityTypeOps};
|
use crate::entities::traits::{entity_type_name, EntityTypeOps};
|
||||||
@@ -680,11 +723,16 @@ impl Scene {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.name.clone())
|
.map(|s| s.name.clone())
|
||||||
.collect();
|
.collect();
|
||||||
let sample = candidate_handles
|
let sample = sample_handle
|
||||||
.iter()
|
.and_then(|handle| self.document.get_entity(handle))
|
||||||
.copied()
|
.filter(|entity| entity_type_name(entity) == t)
|
||||||
.filter_map(|h| self.document.get_entity(h))
|
.or_else(|| {
|
||||||
.find(|e| entity_type_name(e) == t);
|
candidate_handles
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter_map(|h| self.document.get_entity(h))
|
||||||
|
.find(|e| entity_type_name(e) == t)
|
||||||
|
});
|
||||||
if let Some(sample) = sample {
|
if let Some(sample) = sample {
|
||||||
let mut sections = vec![crate::scene::cache::properties::general_section(sample)];
|
let mut sections = vec![crate::scene::cache::properties::general_section(sample)];
|
||||||
if let Some(section) =
|
if let Some(section) =
|
||||||
@@ -836,6 +884,58 @@ impl Scene {
|
|||||||
out
|
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<crate::app::QSelectCondition> {
|
||||||
|
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.
|
/// Reads a property value from an entity for QSELECT comparison.
|
||||||
/// Returns the canonical string used as the left-hand side of the
|
/// Returns the canonical string used as the left-hand side of the
|
||||||
/// operator test. Common properties have hand-rolled formatting so
|
/// operator test. Common properties have hand-rolled formatting so
|
||||||
@@ -1172,6 +1272,50 @@ impl Scene {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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]
|
#[test]
|
||||||
fn pe_url_of_reads_standard_hyperlink_xdata() {
|
fn pe_url_of_reads_standard_hyperlink_xdata() {
|
||||||
use codec::entities::Point;
|
use codec::entities::Point;
|
||||||
|
|||||||
Reference in new issue
Block a user