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>)> { { 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> = 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 { 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 = 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 { 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 = 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; 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> = 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 { 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) { self.selected_constraint = None; let handles: Vec = selected.iter().copied().collect(); let selected: HashSet = 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 { 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 { 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 { 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 = self.selected.iter().copied().collect(); let all: Vec = 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 { 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 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, } } QSelectOp::Any => true, }, None => false, } } } }; let matches_filter = 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> { 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 = 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> = 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 { use crate::entities::traits::{entity_type_name, EntityTypeOps}; use crate::scene::model::object::PropValue; use crate::app::{QSelectPropertyChoice, QSelectValueEditor}; let candidate_handles: Vec = 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 = 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 = self .document .text_styles .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); 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 = 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 } /// 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 { 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 = 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> = 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 §ion in &manager.sections { managers_by_section.entry(section).or_default().push(*handle); } } } } let mut handle_set: HashSet = 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 = 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 §ion_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 = 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>) -> Vec { 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 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 { 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 { 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); } }