feat: Add advanced selection filter

This commit is contained in:
turbuczn committed 2026-10-12 22:27:19 +02:00
1 parent 7c61583ac2
commit 09cf99f85c
8 files changed
+621 -80

No files matched your search

+174 -30
View File
@@ -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<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> {
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<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.
/// 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;