Files
OneCAD-Draft/src/scene/selection.rs
T
Hakan SevenandClaude Opus 5.5 68330ce39e Merge pull request #1747 from schoeller/reference_only_entities
Reference-only entities (plugin API v9): a scene-scoped, in-memory
designation for plugin-owned display geometry, plus HostNotification
dispatch to native plugins so they can re-mark on DocumentOpened.

Resolves the new DeltaSnapshot site in block_editor.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:46:42 +03:00

1631 lines
65 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.is_reference_only(*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, true);
}
/// Like [`erase_entities`](Self::erase_entities) but without the
/// reference-only skip. The plugin owns reference-only entities and
/// removes them through the plugin host API; locked-layer policy stays
/// enforced either way.
pub fn erase_entities_plugin(&mut self, handles: &[Handle]) {
self.remove_entities(handles, true, false);
}
/// 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, false);
}
fn remove_entities(
&mut self,
handles: &[Handle],
respect_layer_locks: bool,
respect_reference_only: 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. Reference-only
// entities are plugin-owned: only the plugin-owned erase path
// may remove them.
if respect_layer_locks && self.is_layer_locked(h) {
continue;
}
if respect_reference_only && self.is_reference_only(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);
}
// The designation set is scene state, not document state: a
// designated entity's set entry joins the same first-touch
// recording so undo restores entity and designation together.
if self.reference_only.is_marked(h) {
self.record_undo_reference_designation_before(h);
}
}
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);
// The set entry dies with its entity (an absent entity is never
// reference-only). Undo restores it through the recorded channel.
self.reference_only.remove(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);
}
}