Files
OneCAD-Draft/src/scene/selection.rs
T
Hakan SevenandClaude Opus 5.5 fcf1cedf52 feat(select): Ctrl+click picks one segment of a polyline
Ctrl+click on a polyline selects the segment under the cursor (again to
drop it); several segments can be picked. Only the picked segments are
highlighted, and MOVE, ROTATE, SCALE and MIRROR then change just their
vertices, so the neighbouring segments stretch to follow. Any other
selected objects are transformed whole as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:14:56 +03:00

1603 lines
64 KiB
Rust

use super::*;
/// The first PE_URL string is the URL; later strings describe the link.
pub(crate) fn pe_url_of(entity: &EntityType) -> Option<&str> {
entity
.common()
.extended_data
.get_record("PE_URL")
.and_then(|record| {
record.values.iter().find_map(|value| match value {
codec::xdata::XDataValue::String(text) => Some(text.trim()),
_ => None,
})
})
.filter(|text| !text.is_empty())
}
/// Optional description stored after the URL in the standard PE_URL record.
pub(crate) fn pe_url_description_of(entity: &EntityType) -> Option<&str> {
let record = entity.common().extended_data.get_record("PE_URL")?;
record
.values
.iter()
.filter_map(|value| match value {
codec::xdata::XDataValue::String(text) => Some(text.trim()),
_ => None,
})
.nth(1)
.filter(|text| !text.is_empty())
}
impl Scene {
// ── Selection ─────────────────────────────────────────────────────────
/// Treat a classic LEADER and its attached annotation as one logical object.
/// Clicking/copying/deleting either side expands to the complete pair.
/// Every LEADER that points at `annotation`, resolved once per
/// `geometry_epoch` rather than by walking the document per handle.
fn leaders_by_annotation(&self) -> std::cell::Ref<'_, (u64, HashMap<Handle, Vec<Handle>>)> {
{
let cache = self.leaders_by_annotation_cache.borrow();
if cache
.as_ref()
.is_some_and(|(epoch, _)| *epoch == self.geometry_epoch)
{
drop(cache);
return std::cell::Ref::map(self.leaders_by_annotation_cache.borrow(), |c| {
c.as_ref().unwrap()
});
}
}
let mut by_annotation: HashMap<Handle, Vec<Handle>> = HashMap::default();
for entity in self.document.entities() {
if let EntityType::Leader(leader) = entity {
if !leader.annotation_handle.is_null() {
by_annotation
.entry(leader.annotation_handle)
.or_default()
.push(entity.common().handle);
}
}
}
*self.leaders_by_annotation_cache.borrow_mut() = Some((self.geometry_epoch, by_annotation));
std::cell::Ref::map(self.leaders_by_annotation_cache.borrow(), |c| {
c.as_ref().unwrap()
})
}
fn expanded_with_leaders(&self, handles: &[Handle]) -> Vec<Handle> {
let leaders = self.leaders_by_annotation();
let mut expanded = Vec::with_capacity(handles.len());
for &handle in handles {
let start = expanded.len();
expanded.push(handle);
if let Some(EntityType::Leader(leader)) = self.document.get_entity(handle) {
if !leader.annotation_handle.is_null() {
expanded.push(leader.annotation_handle);
}
}
if let Some(pointing) = leaders.1.get(&handle) {
expanded.extend(pointing.iter().copied());
}
expanded[start..].sort_unstable_by_key(Handle::value);
}
expanded
}
pub fn select_entities(&mut self, handles: &[Handle]) {
if handles.is_empty() {
return;
}
self.selected_constraint = None;
let expanded = self.expanded_with_leaders(handles);
let mut changed = false;
for handle in expanded {
if self.selected.insert(handle) {
self.selected_order.push(handle);
changed = true;
}
}
if changed {
self.bump_selection_set();
}
}
pub fn deselect_entities(&mut self, handles: &[Handle]) {
if handles.is_empty() {
return;
}
let doomed: HashSet<Handle> = self.expanded_with_leaders(handles).into_iter().collect();
let mut changed = false;
for handle in &doomed {
changed |= self.selected.remove(handle);
}
if changed {
self.selected_order
.retain(|handle| !doomed.contains(handle));
self.bump_selection_set();
}
}
pub(crate) fn handles_expanded_for_leader_annotations(
&self,
handles: &[Handle],
) -> Vec<Handle> {
let mut expanded = self.expanded_with_leaders(handles);
// A viewport's clip boundary goes with its viewport: picking the
// boundary selects both, erasing it erases the viewport.
let viewports: Vec<Handle> = expanded
.iter()
.filter_map(|h| self.document.get_entity(*h).map(|e| (*h, e)))
.flat_map(|(h, e)| {
e.common().reactors.iter().copied().filter(move |r| {
matches!(self.document.get_entity(*r),
Some(EntityType::Viewport(vp)) if vp.clip_boundary_handle == h)
})
})
.collect();
expanded.extend(viewports);
expanded.sort_unstable_by_key(|handle| handle.value());
expanded.dedup();
expanded
}
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
self.selected_constraint = None;
let handles = self.handles_expanded_for_leader_annotations(&[handle]);
let mut changed = false;
if exclusive {
changed = self.selected.len() != handles.len()
|| handles.iter().any(|handle| !self.selected.contains(handle));
self.selected.clear();
self.selected_order.clear();
}
for handle in handles {
if self.selected.insert(handle) {
self.selected_order.push(handle);
changed = true;
}
}
if changed {
self.bump_selection_set();
}
}
pub fn deselect_all(&mut self) {
self.selected_constraint = None;
self.sub_segments.clear();
if self.selected.is_empty() {
return;
}
self.selected.clear();
self.selected_order.clear();
self.bump_selection_set();
}
/// Script-facing replacement: preserve exactly the validated input order.
/// UI picks still use `select_entity` and its leader/annotation expansion.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn replace_selection_exact(&mut self, handles: &[Handle]) {
if self.selected_handles_in_order() == handles {
return;
}
self.selected_constraint = None;
self.selected = handles.iter().copied().collect();
self.selected_order = handles.to_vec();
self.bump_selection_set();
}
pub fn select_all_visible(&mut self) -> usize {
let block = self.interaction_block_handle();
let frozen: Option<HashSet<Handle>> = self
.interaction_viewport_frozen_layers()
.map(|layers| layers.iter().copied().collect());
let annotation_scale = self.displayed_annotation_scale_handle();
let all_visible = self.annotation_all_visible();
let handles = self
.document
.block_records
.iter()
.find(|record| record.handle == block)
.map(|record| record.entity_handles.clone())
.unwrap_or_default();
let selected = handles
.into_iter()
.filter(|handle| self.passes_selection_filter(*handle))
.filter(|handle| {
self.document.get_entity(*handle).is_some_and(|entity| {
self.resident_entity_visible(
entity,
block,
frozen.as_ref(),
annotation_scale,
all_visible,
)
})
})
.collect();
self.replace_selection(selected);
self.selected.len()
}
#[cfg(any(test, not(target_arch = "wasm32")))]
pub(crate) fn selection_fingerprint(&mut self) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
if self.selection_fingerprint_dirty {
let mut fingerprint = self.selected.len() as u64;
for handle in &self.selected {
let mut hasher = DefaultHasher::new();
handle.hash(&mut hasher);
fingerprint ^= hasher.finish();
}
self.selection_fingerprint_cache = fingerprint;
self.selection_fingerprint_dirty = false;
}
self.selection_fingerprint_cache
}
pub(crate) fn selected_handles_in_order(&self) -> Vec<Handle> {
let mut seen = HashSet::default();
let mut ordered: Vec<_> = self
.selected_order
.iter()
.copied()
.filter(|handle| self.selected.contains(handle) && seen.insert(*handle))
.collect();
let mut missing: Vec<_> = self
.selected
.iter()
.copied()
.filter(|handle| seen.insert(*handle))
.collect();
missing.sort_unstable_by_key(|handle| handle.value());
ordered.extend(missing);
ordered
}
/// Replace the complete selection and invalidate the GPU highlight overlay
/// only when its contents actually changed. History/file/command paths must
/// use this instead of assigning `selected` directly.
pub(crate) fn replace_selection(&mut self, selected: HashSet<Handle>) {
self.selected_constraint = None;
let handles: Vec<Handle> = selected.iter().copied().collect();
let selected: HashSet<Handle> = self
.handles_expanded_for_leader_annotations(&handles)
.into_iter()
.collect();
if self.selected != selected {
let mut seen = HashSet::default();
let mut order: Vec<_> = self
.selected_order
.iter()
.copied()
.filter(|handle| selected.contains(handle) && seen.insert(*handle))
.collect();
let mut added: Vec<_> = selected
.iter()
.copied()
.filter(|handle| seen.insert(*handle))
.collect();
added.sort_unstable_by_key(|handle| handle.value());
order.extend(added);
self.selected = selected;
self.selected_order = order;
self.bump_selection_set();
}
}
/// Remove a single entity from the selection (Shift+click subtractive pick).
pub fn deselect_entity(&mut self, handle: Handle) {
let handles = self.handles_expanded_for_leader_annotations(&[handle]);
let mut changed = false;
for handle in handles {
changed |= self.selected.remove(&handle);
self.selected_order.retain(|selected| *selected != handle);
}
if changed {
self.bump_selection_set();
}
}
pub fn selected_entities(&self) -> Vec<(Handle, &EntityType)> {
self.selected_handles_in_order()
.into_iter()
.filter_map(|h| self.document.get_entity(h).map(|e| (h, e)))
.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()
}
fn qselect_candidate_handles(&self, scope: crate::app::QSelectScope) -> Vec<Handle> {
match scope {
crate::app::QSelectScope::CurrentSpace => self.current_layout_entity_handles(),
crate::app::QSelectScope::CurrentSelection => self.selected_handles_in_order(),
}
}
pub fn qselect_candidate_count(&self, scope: crate::app::QSelectScope) -> usize {
self.qselect_candidate_handles(scope).len()
}
pub fn qselect_entity_type_names(&self, scope: crate::app::QSelectScope) -> Vec<String> {
use crate::entities::traits::entity_type_name;
let mut names = std::collections::BTreeSet::new();
for h in self.qselect_candidate_handles(scope) {
if let Some(entity) = self.document.get_entity(h) {
names.insert(entity_type_name(entity).to_string());
}
}
names.into_iter().collect()
}
/// 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: rustc_hash::FxHashSet<(&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);
self.selected_order.push(h);
added += 1;
}
}
}
if added > 0 {
self.bump_selection_set();
}
added
}
/// Replace the selection with its complement: every selectable object
/// in the active layout that isn't currently selected. The candidate
/// set is the visible wire set, so objects on off/frozen layers (which
/// can't be picked anyway) are excluded. Returns the new count.
pub fn invert_selection(&mut self) -> usize {
let prev: rustc_hash::FxHashSet<Handle> = self.selected.iter().copied().collect();
let all: Vec<Handle> = self
.entity_wires()
.iter()
.filter_map(|w| Self::handle_from_wire_name(&w.name))
.collect();
self.selected.clear();
self.selected_order.clear();
for h in all {
if !prev.contains(&h) {
self.selected.insert(h);
self.selected_order.push(h);
}
}
if self.selected != prev {
self.bump_selection_set();
}
self.selected.len()
}
/// Builds a selection from the chosen scope and filter. Exclude mode
/// keeps candidates that do not match. Append mode unions the result
/// with the selection that existed before the query.
///
/// `type_name` of `None` means "any type". `property_field` of
/// `None` skips the property test (only the type filter applies).
/// The operator's `Any` variant also skips the property test.
/// Numeric operators (`Gt` / `Lt`) parse both sides as `f64` and
/// reject anything non-numeric.
pub fn qselect(
&mut self,
scope: crate::app::QSelectScope,
type_name: Option<&str>,
property_field: Option<&str>,
op: crate::app::QSelectOp,
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;
let previous = self.selected.clone();
let handles = self.qselect_candidate_handles(scope);
let mut result = HashSet::default();
for h in handles {
let Some(e) = self.document.get_entity(h) else {
continue;
};
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,
},
None => false,
},
}
};
type_ok && prop_ok
});
let keep = match mode {
QSelectMode::Include => matches_filter,
QSelectMode::Exclude => !matches_filter,
};
if keep {
result.insert(h);
}
}
if append {
result.extend(previous);
}
self.replace_selection(result);
self.selected.len()
}
/// Entity type names in the current layout for the selection-filter menu.
/// Pure additions are folded into the cached set; other changes rebuild it.
pub fn entity_type_names_in_layout(&self) -> std::sync::Arc<Vec<String>> {
use crate::entities::traits::entity_type_name;
let block = self.current_layout_block_handle();
let mut cached_epoch = None;
{
let cache = self.layout_type_names_cache.borrow();
if let Some((epoch, cached_block, _, names)) = cache.as_ref() {
if *cached_block == block {
if *epoch == self.geometry_epoch {
return std::sync::Arc::clone(names);
}
cached_epoch = Some(*epoch);
}
}
}
// Incremental: fold the changes since the cached epoch into the set.
if let Some(since) = cached_epoch {
if let Some(deltas) = self.replay_since(since) {
if deltas.iter().all(|(_, kind)| *kind == ChangeKind::Added) {
let mut cache = self.layout_type_names_cache.borrow_mut();
if let Some((epoch, _, present, names)) = cache.as_mut() {
let mut added = false;
for (handle, _) in &deltas {
if let Some(entity) = self.document.get_entity(*handle) {
if entity.common().owner_handle == block {
// `contains` first: the common case is a
// type already present, and that path must
// not allocate.
let name = entity_type_name(entity);
if !present.contains(name) {
present.insert(name.to_string());
added = true;
}
}
}
}
// Only rebuild the list when the set actually moved;
// otherwise the existing `Arc` is still the answer.
if added {
*names = std::sync::Arc::new(present.iter().cloned().collect());
}
*epoch = self.geometry_epoch;
return std::sync::Arc::clone(names);
}
}
}
}
let mut present: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
if let Some(record) = self
.document
.block_records
.iter()
.find(|record| record.handle == block)
{
for &handle in &record.entity_handles {
if let Some(entity) = self.document.get_entity(handle) {
let name = entity_type_name(entity);
if !present.contains(name) {
present.insert(name.to_string());
}
}
}
}
let names: std::sync::Arc<Vec<String>> =
std::sync::Arc::new(present.iter().cloned().collect());
*self.layout_type_names_cache.borrow_mut() = Some((
self.geometry_epoch,
block,
present,
std::sync::Arc::clone(&names),
));
names
}
/// True when `handle`'s entity type is allowed by the selection filter.
/// The filter stores excluded type names; empty = everything allowed.
pub fn passes_selection_filter(&self, handle: Handle) -> bool {
if self.selection_filter.is_empty() {
return true;
}
match self.document.get_entity(handle) {
Some(e) => !self
.selection_filter
.contains(crate::entities::traits::entity_type_name(e)),
None => true,
}
}
/// True when the selection filter is excluding at least one type.
pub fn selection_filter_active(&self) -> bool {
!self.selection_filter.is_empty()
}
/// Returns common and type-specific filter properties with the editor
/// each value needs. Choice editors are enriched from both document
/// tables and values present in the selected candidate scope.
pub fn qselect_properties(
&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};
use crate::scene::model::object::PropValue;
let candidate_handles: Vec<Handle> = self
.qselect_candidate_handles(scope)
.into_iter()
.filter(|h| {
self.document
.get_entity(*h)
.is_some_and(|entity| type_name.is_none_or(|t| entity_type_name(entity) == t))
})
.collect();
let mut layer_options: Vec<String> = self
.document
.layers
.iter()
.map(|layer| layer.name.clone())
.collect();
layer_options.sort_by_key(|value| value.to_lowercase());
let mut linetype_options = vec!["ByLayer".to_string(), "ByBlock".to_string()];
linetype_options.extend(
self.document
.line_types
.iter()
.map(|linetype| linetype.name.clone()),
);
let choice =
|field: &str, label: String, editor: QSelectValueEditor| QSelectPropertyChoice {
field: field.to_string(),
label,
editor,
};
let mut out = vec![
choice(
"handle",
crate::t!("Handle").into_owned(),
QSelectValueEditor::Text,
),
choice(
"color",
crate::t!("Color").into_owned(),
QSelectValueEditor::Choice(vec!["ByLayer".into(), "ByBlock".into()]),
),
choice(
"layer",
crate::t!("Layer").into_owned(),
QSelectValueEditor::Choice(layer_options.clone()),
),
choice(
"linetype",
crate::t!("Linetype").into_owned(),
QSelectValueEditor::Choice(linetype_options.clone()),
),
choice(
"linetype_scale",
crate::t!("Linetype scale").into_owned(),
QSelectValueEditor::Number,
),
choice(
"plot_style",
crate::t!("Plot style").into_owned(),
QSelectValueEditor::Choice(vec![
"ByLayer".into(),
"ByBlock".into(),
"ByColor".into(),
]),
),
choice(
"lineweight",
crate::t!("Lineweight").into_owned(),
QSelectValueEditor::Choice(vec![
"ByLayer".into(),
"ByBlock".into(),
"Default".into(),
]),
),
choice(
"transparency",
crate::t!("Transparency").into_owned(),
QSelectValueEditor::Choice(vec!["ByLayer".into()]),
),
choice(
"hyperlink",
crate::t!("Hyperlink").into_owned(),
QSelectValueEditor::Text,
),
];
if let Some(t) = type_name {
let text_style_names: Vec<String> = self
.document
.text_styles
.iter()
.map(|s| s.name.clone())
.collect();
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) =
crate::scene::cache::properties::visualization_section(sample)
{
sections.push(section);
}
sections.extend(sample.geometry_properties(&text_style_names));
for section in sections {
for prop in section.props {
if out.iter().any(|item| item.field == prop.field) {
continue;
}
let editor = if prop.field == "material" {
QSelectValueEditor::Choice(vec![
"ByLayer".into(),
"ByBlock".into(),
"Custom".into(),
])
} else {
match prop.value {
PropValue::PlainText(_) | PropValue::Hyperlink(_) => {
QSelectValueEditor::Text
}
PropValue::ReadOnly(ref value)
| PropValue::ReadOnlyWithTooltip { ref value, .. }
| PropValue::EditText(ref value) => {
let field = prop.field.to_ascii_lowercase();
let textual = [
"name",
"text",
"content",
"style",
"tag",
"prompt",
"value",
"description",
"format",
"font",
"path",
"file",
"url",
]
.iter()
.any(|part| field.contains(part));
if !textual
&& crate::entities::common::parse_f64(value).is_some()
{
QSelectValueEditor::Number
} else {
QSelectValueEditor::Text
}
}
PropValue::LayerChoice(_) => {
QSelectValueEditor::Choice(layer_options.clone())
}
PropValue::Choice { ref options, .. } => {
QSelectValueEditor::Choice(options.clone())
}
PropValue::EditChoice {
ref value,
ref options,
} => {
let mut values = options.clone();
if !values.iter().any(|item| item == value) {
values.push(value.clone());
}
QSelectValueEditor::Choice(values)
}
PropValue::ColorChoice(_) | PropValue::NamedColorChoice { .. } => {
QSelectValueEditor::Choice(vec![
"ByLayer".into(),
"ByBlock".into(),
])
}
PropValue::LwChoice(_) | PropValue::FieldLwChoice { .. } => {
QSelectValueEditor::Choice(vec![
"ByLayer".into(),
"ByBlock".into(),
"Default".into(),
])
}
PropValue::LinetypeChoice(_) => {
QSelectValueEditor::Choice(linetype_options.clone())
}
PropValue::HatchPatternChoice(value) => {
let mut patterns: Vec<String> =
crate::scene::model::hatch_patterns::catalog()
.iter()
.map(|entry| entry.name.clone())
.collect();
if !patterns.iter().any(|item| item == &value) {
patterns.push(value);
}
QSelectValueEditor::Choice(patterns)
}
PropValue::BoolToggle { .. } => {
QSelectValueEditor::Choice(vec!["false".into(), "true".into()])
}
PropValue::AttrText { .. } => QSelectValueEditor::Text,
PropValue::Stepper { .. }
| PropValue::ColorVaries
| PropValue::LwVaries
| PropValue::FieldLwVaries { .. }
| PropValue::EntityLink { .. }
| PropValue::ParamRow { .. }
| PropValue::ParamAddRow
| PropValue::ParamsVisibilityToggle(_) => continue,
}
};
out.push(choice(prop.field, prop.label, editor));
}
}
}
}
for property in &mut out {
let QSelectValueEditor::Choice(options) = &mut property.editor else {
continue;
};
let common_choice = matches!(
property.field.as_str(),
"color"
| "layer"
| "linetype"
| "plot_style"
| "lineweight"
| "transparency"
| "material"
);
if !common_choice && !options.is_empty() {
continue;
}
for handle in &candidate_handles {
let Some(entity) = self.document.get_entity(*handle) else {
continue;
};
if let Some(value) = self.entity_property_value(entity, &property.field) {
if !options
.iter()
.any(|option| option.eq_ignore_ascii_case(&value))
{
options.push(value);
}
}
}
options.sort_by_key(|value| value.to_lowercase());
}
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
/// `"ByLayer"` / `"7"` / `"0.30mm"` are stable; everything else
/// goes through `geometry_properties()` and pulls the matching
/// row's value out.
pub fn entity_property_value(&self, entity: &codec::EntityType, field: &str) -> Option<String> {
use crate::entities::traits::EntityTypeOps;
use crate::scene::model::object::PropValue;
match field {
"handle" => Some(entity.common().handle.value().to_string()),
"layer" => Some(entity.common().layer.clone()),
"color" => Some(
entity
.common()
.color_name
.clone()
.unwrap_or_else(|| Self::format_color(entity.common().color)),
),
"linetype" => Some(if entity.common().linetype.is_empty() {
"ByLayer".to_string()
} else {
entity.common().linetype.clone()
}),
"linetype_scale" => Some(format!("{:.4}", entity.common().linetype_scale)),
"plot_style" => Some(
match entity.common().plotstyle_flags {
0 => "ByLayer",
1 => "ByBlock",
_ => "ByColor",
}
.to_string(),
),
"lineweight" => Some(Self::format_lineweight(entity.common().line_weight)),
"transparency" => Some(match entity.common().transparency {
codec::types::Transparency::ByLayer => "ByLayer".to_string(),
codec::types::Transparency::ByBlock => "ByBlock".to_string(),
codec::types::Transparency::Explicit(alpha) => {
((alpha as f64 / 255.0 * 100.0).round() as u32).to_string()
}
}),
"hyperlink" => Some(pe_url_of(entity).unwrap_or_default().to_owned()),
"material" => Some(
match entity.common().material_flags {
0 => "ByLayer",
1 => "ByBlock",
_ => "Custom",
}
.to_string(),
),
_ => {
let text_style_names: Vec<String> = self
.document
.text_styles
.iter()
.map(|s| s.name.clone())
.collect();
let mut sections = vec![crate::scene::cache::properties::general_section(entity)];
if let Some(section) =
crate::scene::cache::properties::visualization_section(entity)
{
sections.push(section);
}
sections.extend(entity.geometry_properties(&text_style_names));
let prop = sections
.into_iter()
.flat_map(|s| s.props)
.find(|p| p.field == field)?;
Some(match prop.value {
PropValue::ReadOnly(s)
| PropValue::ReadOnlyWithTooltip { value: s, .. }
| PropValue::EditText(s)
| PropValue::PlainText(s)
| PropValue::Hyperlink(s) => s,
PropValue::LayerChoice(s) => s,
PropValue::Choice { selected, .. } => selected,
PropValue::EditChoice { value, .. } => value,
PropValue::ColorChoice(c) => Self::format_color(c),
PropValue::NamedColorChoice { name, .. } => name,
PropValue::LwChoice(lw) | PropValue::FieldLwChoice { value: lw, .. } => {
Self::format_lineweight(lw)
}
PropValue::LinetypeChoice(s) => s,
PropValue::HatchPatternChoice(s) => s,
PropValue::BoolToggle { value, .. } => value.to_string(),
PropValue::AttrText { value, .. } => value,
PropValue::Stepper { display, .. } => display,
PropValue::ColorVaries
| PropValue::LwVaries
| PropValue::FieldLwVaries { .. }
| PropValue::EntityLink { .. }
| PropValue::ParamRow { .. }
| PropValue::ParamAddRow
| PropValue::ParamsVisibilityToggle(_) => return None,
})
}
}
}
fn format_color(c: codec::types::Color) -> String {
use codec::types::Color;
match c {
Color::ByLayer => "ByLayer".to_string(),
Color::None => "None".to_string(),
Color::ByBlock => "ByBlock".to_string(),
Color::Index(i) => i.to_string(),
Color::Rgb { r, g, b } => format!("{},{},{}", r, g, b),
}
}
fn format_lineweight(lw: codec::types::LineWeight) -> String {
use codec::types::LineWeight;
match lw {
LineWeight::ByLayer => "ByLayer".to_string(),
LineWeight::ByBlock => "ByBlock".to_string(),
LineWeight::Default => "Default".to_string(),
LineWeight::Value(v) => format!("{:.2}mm", v as f64 / 100.0),
}
}
// ── Erase ─────────────────────────────────────────────────────────────
pub fn erase_entities(&mut self, handles: &[Handle]) {
self.remove_entities(handles, true);
}
/// Roll back entities created by the current command after a downstream
/// registration failure. These entities were never successful command
/// results, so locked-layer edit policy must not prevent their removal.
/// The shared removal path keeps first-touch undo state, history objects,
/// groups, selection, and derived caches coherent.
pub(crate) fn rollback_new_entities(&mut self, handles: &[Handle]) {
self.remove_entities(handles, false);
}
fn remove_entities(&mut self, handles: &[Handle], respect_layer_locks: bool) {
let erase_handles = self.handles_expanded_for_leader_annotations(handles);
// One pass over document.objects instead of a scan per erased handle.
let mut managers_by_section: HashMap<Handle, Vec<Handle>> = HashMap::default();
for (handle, object) in self.document.objects.iter() {
if let ObjectType::ClassObject(object) = object {
if let codec::objects::ClassObjectData::SectionManager(manager) = &object.data {
for &section in &manager.sections {
managers_by_section
.entry(section)
.or_default()
.push(*handle);
}
}
}
}
let mut handle_set: HashSet<Handle> = HashSet::default();
let mut erased: Vec<(Handle, ChangeKind)> = Vec::new();
let mut selection_changed = false;
let mut hover_changed = false;
for &h in &erase_handles {
// Objects on a locked layer can't be erased.
if respect_layer_locks && self.is_layer_locked(h) {
continue;
}
let settings_handle = self.document.get_entity(h).and_then(|entity| match entity {
EntityType::Extended(extended) => match &extended.data {
codec::entities::ExtendedEntityData::SectionObject(data)
if !data.settings_handle.is_null() =>
{
Some(data.settings_handle)
}
_ => None,
},
_ => None,
});
let section_managers: Vec<Handle> =
managers_by_section.get(&h).cloned().unwrap_or_default();
// Delta-undo: capture the removed entity so an undo can re-insert it.
if self.is_recording_undo() {
let before = self.document.get_entity_arc(h);
self.record_undo_before(h, before);
if let Some(settings_handle) = settings_handle {
let before = self.document.objects.get(&settings_handle).cloned();
self.record_undo_object_before(settings_handle, before);
}
for &manager_handle in &section_managers {
let before = self.document.objects.get(&manager_handle).cloned();
self.record_undo_object_before(manager_handle, before);
}
}
self.delete_solid_history(h);
if let Some(settings_handle) = settings_handle {
self.document.objects.remove(&settings_handle);
}
for manager_handle in section_managers {
if let Some(ObjectType::ClassObject(object)) =
self.document.objects.get_mut(&manager_handle)
{
if let codec::objects::ClassObjectData::SectionManager(manager) =
&mut object.data
{
manager.sections.retain(|section| *section != h);
}
}
}
self.remember_removed_cache_categories(h);
self.document.remove_entity_arc(h);
selection_changed |= self.selected.remove(&h);
self.selected_order.retain(|selected| *selected != h);
if self.hover_highlight == Some(h) {
self.hover_highlight = None;
hover_changed = true;
}
hover_changed |= self.constraint_hover_highlights.remove(&h);
if self
.constraint_hover_refs
.iter()
.any(|reference| reference.entity == h)
{
self.constraint_hover_refs.clear();
self.constraint_hover_wires.clear();
self.constraint_hover_highlights.clear();
hover_changed = true;
}
self.hatches.remove(&h);
self.images.remove(&h);
self.meshes.remove(&h);
self.block_meshes.remove(&h);
self.solid_models.remove(&h);
handle_set.insert(h);
erased.push((h, ChangeKind::Removed));
}
if selection_changed {
self.bump_selection_set();
} else if hover_changed {
self.bump_selection();
}
// Capture exactly the group objects that this erase will rewrite, plus
// the group dictionary when an emptied group will be removed. Do this
// before taking mutable object-map borrows below.
if self.is_recording_undo() && !handle_set.is_empty() {
let affected_groups: Vec<(Handle, ObjectType)> = self
.document
.objects
.iter()
.filter_map(|(&handle, object)| match object {
ObjectType::Group(group)
if group.entities.iter().any(|h| handle_set.contains(h)) =>
{
Some((handle, object.clone()))
}
_ => None,
})
.collect();
if !affected_groups.is_empty() {
let removes_group = affected_groups.iter().any(|(_, object)| {
matches!(
object,
ObjectType::Group(group)
if group.entities.iter().all(|h| handle_set.contains(h))
)
});
if removes_group {
let dictionary = self.document.header.acad_group_dict_handle;
let dictionary_before = self.document.objects.get(&dictionary).cloned();
self.record_undo_object_before(dictionary, dictionary_before);
}
for (handle, object) in affected_groups {
self.record_undo_object_before(handle, Some(object));
}
}
}
// Remove erased handles from all groups; delete groups that become empty.
let group_dict_handle = self.document.header.acad_group_dict_handle;
let to_remove: Vec<Handle> = self
.document
.objects
.values_mut()
.filter_map(|obj| match obj {
ObjectType::Group(g) => {
g.entities.retain(|h| !handle_set.contains(h));
if g.entities.is_empty() {
Some(g.handle)
} else {
None
}
}
_ => None,
})
.collect();
for gh in &to_remove {
if let Some(ObjectType::Dictionary(dict)) =
self.document.objects.get_mut(&group_dict_handle)
{
dict.entries.retain(|(_, h)| h != gh);
}
self.document.objects.remove(gh);
}
// Deleting top-level entities/inserts leaves block definitions intact.
// Report the exact erased handles so derived caches drop just those and
// the resident set removes only their wires (bump_entities drops them
// from the tessellation memos too).
if !erased.is_empty() {
self.invalidate_dependency_index();
self.bump_entities(&erased);
}
}
/// Restore erased Arc-backed entities without re-linking their still-present
/// block-record handles. Used by OOPS and history replay.
pub fn restore_erased_entities(&mut self, entities: Vec<Arc<EntityType>>) -> Vec<Handle> {
let mut restored = Vec::with_capacity(entities.len());
let mut changes = Vec::with_capacity(entities.len());
for entity in entities {
let handle = entity.common().handle;
if handle.is_null() || self.document.get_entity(handle).is_some() {
continue;
}
if self.is_recording_undo() {
self.record_undo_before(handle, None);
}
if self.document.restore_entity_arc(entity).is_some() {
self.reseed_derived_caches(handle);
restored.push(handle);
changes.push((handle, ChangeKind::Added));
}
}
if !changes.is_empty() {
self.invalidate_dependency_index();
self.bump_entities(&changes);
}
restored
}
}
#[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;
use codec::xdata::{ExtendedDataRecord, XDataValue};
let mut doc = CadDocument::new();
// An entity carrying a PE_URL hyperlink resolves to that link.
let mut linked = Point::new();
let mut rec = ExtendedDataRecord::new("PE_URL");
rec.add_value(XDataValue::String("https://example.com/gui".to_string()));
linked.common.extended_data.add_record(rec);
let linked_handle = doc
.add_entity(EntityType::Point(linked))
.expect("linked entity added");
assert_eq!(
pe_url_of(doc.get_entity(linked_handle).unwrap()),
Some("https://example.com/gui")
);
// No PE_URL record -> None.
let plain_handle = doc
.add_entity(EntityType::Point(Point::new()))
.expect("plain entity added");
assert_eq!(pe_url_of(doc.get_entity(plain_handle).unwrap()), None);
// Empty string in the record -> None.
let mut empty = Point::new();
let mut empty_rec = ExtendedDataRecord::new("PE_URL");
empty_rec.add_value(XDataValue::String(String::new()));
empty_rec.add_value(XDataValue::String("https://description.invalid/".into()));
empty.common.extended_data.add_record(empty_rec);
let empty_handle = doc
.add_entity(EntityType::Point(empty))
.expect("empty-link entity added");
assert_eq!(pe_url_of(doc.get_entity(empty_handle).unwrap()), None);
}
use super::*;
#[test]
fn bulk_selection_matches_selecting_one_at_a_time() {
use codec::entities::Line;
use codec::types::Vector3;
let build = || {
let mut scene = Scene::new();
let handles: Vec<_> = (0..40)
.map(|k| {
let x = k as f64;
scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(x, 0.0, 0.0),
Vector3::new(x + 1.0, 0.0, 0.0),
)))
})
.collect();
(scene, handles)
};
// Picked in an order that is neither creation nor handle order.
let picked = |handles: &[Handle]| -> Vec<Handle> {
let mut order: Vec<_> = handles.iter().copied().collect();
order.reverse();
order.retain(|h| h.value() % 3 != 0);
order
};
let (mut one_at_a_time, handles) = build();
let order = picked(&handles);
for &handle in &order {
one_at_a_time.select_entity(handle, false);
}
let (mut in_bulk, handles) = build();
in_bulk.select_entities(&picked(&handles));
assert_eq!(
in_bulk.selected_handles_in_order(),
one_at_a_time.selected_handles_in_order(),
"the bulk path must select the same entities in the same order",
);
// And removing a subset must agree too.
let drop: Vec<_> = order.iter().copied().take(7).collect();
for &handle in &drop {
one_at_a_time.deselect_entity(handle);
}
in_bulk.deselect_entities(&drop);
assert_eq!(
in_bulk.selected_handles_in_order(),
one_at_a_time.selected_handles_in_order(),
"the bulk removal must leave the same selection in the same order",
);
}
#[test]
fn the_leader_index_matches_a_document_walk() {
use codec::entities::Leader;
use codec::types::Vector3;
let mut scene = Scene::new();
let line = |x: f64| {
EntityType::Line(codec::entities::Line::from_points(
Vector3::new(x, 0.0, 0.0),
Vector3::new(x + 1.0, 0.0, 0.0),
))
};
let annotation = scene.add_entity(line(0.0));
let unrelated = scene.add_entity(line(5.0));
let mut leader_on = |target: Handle| {
let mut leader = Leader::default();
leader.annotation_handle = target;
scene.add_entity(EntityType::Leader(leader))
};
// Two leaders share one annotation; a third points nowhere.
let first = leader_on(annotation);
let second = leader_on(annotation);
let dangling = leader_on(Handle::NULL);
// The walk this replaced, written out so the index is compared against
// behaviour rather than against itself.
let by_walk = |handle: Handle| -> Vec<Handle> {
let mut out = vec![handle];
if let Some(EntityType::Leader(leader)) = scene.document.get_entity(handle) {
if !leader.annotation_handle.is_null() {
out.push(leader.annotation_handle);
}
}
out.extend(scene.document.entities().filter_map(|entity| match entity {
EntityType::Leader(leader)
if !leader.annotation_handle.is_null()
&& leader.annotation_handle == handle =>
{
Some(entity.common().handle)
}
_ => None,
}));
out.sort_unstable_by_key(Handle::value);
out.dedup();
out
};
for handle in [annotation, unrelated, first, second, dangling] {
assert_eq!(
scene.handles_expanded_for_leader_annotations(&[handle]),
by_walk(handle),
"the index must answer exactly what the walk answered",
);
}
let expanded = scene.handles_expanded_for_leader_annotations(&[annotation]);
assert!(
expanded.contains(&first) && expanded.contains(&second),
"both leaders on one annotation must come back, not just one",
);
for handle in [first, annotation, second] {
scene.deselect_all();
scene.select_entity(handle, false);
let expected = scene.selected_handles_in_order();
scene.deselect_all();
scene.select_entities(&[handle]);
assert_eq!(scene.selected_handles_in_order(), expected);
}
}
#[test]
fn adding_a_type_already_present_reuses_the_list() {
use codec::entities::{Circle, EntityType, Line};
use codec::types::Vector3;
use std::sync::Arc;
let line = || {
EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
))
};
let mut scene = Scene::new();
scene.add_entity(line());
let first = scene.entity_type_names_in_layout();
scene.add_entity(line());
let second = scene.entity_type_names_in_layout();
assert!(
Arc::ptr_eq(&first, &second),
"a second line changes no type name, so the list must be reused",
);
scene.add_entity(EntityType::Circle(Circle::new()));
let third = scene.entity_type_names_in_layout();
assert_eq!(
third.as_slice(),
["Circle", "Line"],
"a new type must appear"
);
// The incremental answer has to be the answer a full walk gives.
scene.layout_type_names_cache.borrow_mut().take();
assert_eq!(
scene.entity_type_names_in_layout().as_slice(),
third.as_slice(),
"folding edits in must match rebuilding from scratch",
);
}
#[test]
fn changing_an_entity_type_rebuilds_the_type_names() {
use codec::entities::{Circle, EntityType, Line};
use codec::types::Vector3;
let mut scene = Scene::new();
let handle = scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
)));
assert_eq!(scene.entity_type_names_in_layout().as_slice(), ["Line"]);
let mut circle = Circle::new();
circle.common.handle = handle;
assert!(scene.update_entity(EntityType::Circle(circle)));
assert_eq!(scene.entity_type_names_in_layout().as_slice(), ["Circle"]);
}
#[test]
fn layout_type_cache_reuses_and_invalidates_on_edits_undo_and_layout() {
use codec::entities::{Circle, EntityType, Line};
use codec::types::Vector3;
use std::sync::Arc;
let mut scene = Scene::new();
let empty = scene.entity_type_names_in_layout();
assert!(empty.is_empty());
assert!(Arc::ptr_eq(&empty, &scene.entity_type_names_in_layout()));
scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1.0, 0.0, 0.0),
)));
let lines = scene.entity_type_names_in_layout();
assert_eq!(lines.as_slice(), ["Line"]);
assert!(Arc::ptr_eq(&lines, &scene.entity_type_names_in_layout()));
let circle = scene.add_entity(EntityType::Circle(Circle::new()));
let before = scene.document.get_entity_arc(circle);
assert_eq!(
scene.entity_type_names_in_layout().as_slice(),
["Circle", "Line"]
);
scene.erase_entities(&[circle]);
assert_eq!(scene.entity_type_names_in_layout().as_slice(), ["Line"]);
let changes = scene.apply_entity_delta(&[(circle, before, None)], true);
scene.bump_entities(&changes);
assert_eq!(
scene.entity_type_names_in_layout().as_slice(),
["Circle", "Line"]
);
scene.set_current_layout("Layout1".to_owned());
assert!(scene.entity_type_names_in_layout().is_empty());
scene.set_current_layout("Model".to_owned());
assert_eq!(
scene.entity_type_names_in_layout().as_slice(),
["Circle", "Line"]
);
}
#[test]
fn selection_fingerprint_tracks_final_set_only() {
let mut scene = Scene::default();
let first = Handle::new(1);
let second = Handle::new(2);
scene.select_entity(first, false);
scene.select_entity(second, false);
let fingerprint = scene.selection_fingerprint();
assert!(!scene.selection_fingerprint_dirty);
scene.deselect_all();
scene.select_entity(second, false);
scene.select_entity(first, false);
assert!(scene.selection_fingerprint_dirty);
assert_eq!(scene.selection_fingerprint(), fingerprint);
scene.set_hover_highlight(Some(Handle::new(3)));
assert!(!scene.selection_fingerprint_dirty);
assert_eq!(scene.selection_fingerprint(), fingerprint);
}
}