Files
OneCAD-Draft/src/scene/selection.rs
T
Hakan SevenandClaude Fable 5 b5dbef5776 feat(props): editable block Name row + collapsible XYZ groups
- Block reference Name (Misc) is editable: the dropdown re-points the
  insert to another definition; typing a new name renames the definition
  everywhere (record re-key, Block marker, every INSERT reference).
  Anonymous (*) and xref blocks stay read-only, with the same guards
  added to Scene::rename_block.
- Custom single-control dropdown (text field + caret) built on the
  shared floating_below overlay so the list always opens downward,
  unlike iced's space-based menu placement.
- Consecutive "<Base> X/Y/Z" rows (Position, Scale, Start, Center, ...)
  collapse into one expandable summary row across all entities; the
  expand state persists across rebuilds and selection changes.
- The BEDIT block tab no longer offers the layout Rename/Delete
  context menu.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 11:11:10 +03:00

394 lines
16 KiB
Rust

// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
use super::*;
impl Scene {
// ── Selection ─────────────────────────────────────────────────────────
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
if exclusive {
self.selected.clear();
}
self.selected.insert(handle);
self.bump_selection();
}
pub fn deselect_all(&mut self) {
self.selected.clear();
self.bump_selection();
}
/// Remove a single entity from the selection (Shift+click subtractive pick).
pub fn deselect_entity(&mut self, handle: Handle) {
if self.selected.remove(&handle) {
self.bump_selection();
}
}
pub fn selected_entities(&self) -> Vec<(Handle, &EntityType)> {
self.selected
.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()
}
/// 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<(&'static 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);
added += 1;
}
}
}
if added > 0 {
self.bump_selection();
}
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();
for h in all {
if !prev.contains(&h) {
self.selected.insert(h);
}
}
self.bump_selection();
self.selected.len()
}
/// Replaces (or extends, when `append` is true) the current
/// selection with every entity in the active layout that matches
/// the filter. Returns the number of newly-matching entities.
///
/// `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,
type_name: Option<&str>,
property_field: Option<&str>,
op: crate::app::QSelectOp,
value: &str,
append: bool,
) -> usize {
use crate::app::QSelectOp;
use crate::entities::traits::entity_type_name;
if !append {
self.selected.clear();
}
let handles = self.current_layout_entity_handles();
let mut matched = 0;
for h in handles {
let Some(e) = self.document.get_entity(h) else {
continue;
};
// Never quick-select objects on a locked layer.
if self
.document
.layers
.get(&e.common().layer)
.map(|l| l.is_locked())
.unwrap_or(false)
{
continue;
}
if let Some(t) = type_name {
if entity_type_name(e) != t {
continue;
}
}
let prop_ok = match (property_field, op) {
(None, _) | (_, QSelectOp::Any) => true,
(Some(field), op) => {
let Some(actual) = self.entity_property_value(e, field) else {
continue;
};
match op {
QSelectOp::Eq => actual.eq_ignore_ascii_case(value),
QSelectOp::Neq => !actual.eq_ignore_ascii_case(value),
QSelectOp::Gt | QSelectOp::Lt => {
let (Ok(a), Ok(b)) =
(actual.parse::<f64>(), value.parse::<f64>())
else {
continue;
};
if matches!(op, QSelectOp::Gt) {
a > b
} else {
a < b
}
}
QSelectOp::Any => true,
}
}
};
if prop_ok {
self.selected.insert(h);
matched += 1;
}
}
self.bump_selection();
matched
}
/// Returns the sorted set of entity-type names present in the active
/// layout. Used to populate the Quick Select "Object type" dropdown
/// with only the types that actually exist in the drawing.
pub fn entity_type_names_in_layout(&self) -> Vec<&'static str> {
use crate::entities::traits::entity_type_name;
let mut names: std::collections::BTreeSet<&'static str> =
std::collections::BTreeSet::new();
for h in self.current_layout_entity_handles() {
if let Some(e) = self.document.get_entity(h) {
names.insert(entity_type_name(e));
}
}
names.into_iter().collect()
}
/// 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 the list of `(field, label)` pairs the Quick Select
/// "Properties" dropdown should show given the current type filter:
///
/// * Common properties (Layer, Color, Linetype, Lineweight) are
/// always included.
/// * When `type_name` names a specific entity type present in the
/// active layout, the first entity of that type contributes its
/// `geometry_properties()` rows (Start X, Length, Radius, …) so
/// type-specific filtering works.
pub fn qselect_properties(
&self,
type_name: Option<&str>,
) -> Vec<(String, String)> {
use crate::entities::traits::{entity_type_name, EntityTypeOps};
let mut out: Vec<(String, String)> = vec![
("layer".to_string(), "Layer".to_string()),
("color".to_string(), "Color".to_string()),
("linetype".to_string(), "Linetype".to_string()),
("lineweight".to_string(), "Lineweight".to_string()),
];
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 = self
.current_layout_entity_handles()
.into_iter()
.filter_map(|h| self.document.get_entity(h))
.find(|e| entity_type_name(e) == t);
if let Some(sample) = sample {
for section in sample.geometry_properties(&text_style_names) {
for prop in section.props {
// Skip rows that don't sensibly compare via
// `entity_property_value` (read-only labels are
// fine — users can still match against them).
out.push((prop.field.to_string(), prop.label.clone()));
}
}
}
}
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: &acadrust::EntityType,
field: &str,
) -> Option<String> {
use crate::entities::traits::EntityTypeOps;
use crate::scene::model::object::PropValue;
match field {
"layer" => Some(entity.common().layer.clone()),
"color" => Some(Self::format_color(entity.common().color)),
"linetype" => Some(entity.common().linetype.clone()),
"lineweight" => Some(Self::format_lineweight(entity.common().line_weight)),
_ => {
let text_style_names: Vec<String> = self
.document
.text_styles
.iter()
.map(|s| s.name.clone())
.collect();
let prop = entity
.geometry_properties(&text_style_names)
.into_iter()
.flat_map(|s| s.props)
.find(|p| p.field == field)?;
Some(match prop.value {
PropValue::ReadOnly(s) | PropValue::EditText(s) => s,
PropValue::LayerChoice(s) => s,
PropValue::Choice { selected, .. } => selected,
PropValue::EditChoice { value, .. } => value,
PropValue::ColorChoice(c) => Self::format_color(c),
PropValue::LwChoice(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 => return None,
})
}
}
}
fn format_color(c: acadrust::types::Color) -> String {
use acadrust::types::Color;
match c {
Color::ByLayer => "ByLayer".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: acadrust::types::LineWeight) -> String {
use acadrust::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]) {
let mut erased: Vec<(Handle, ChangeKind)> = Vec::new();
for &h in handles {
// Objects on a locked layer can't be erased.
if self.is_layer_locked(h) {
continue;
}
// Delta-undo: capture the removed entity so an undo can re-insert it.
if self.is_recording_undo() {
let before = self.document.get_entity(h).cloned();
self.record_undo_before(h, before);
}
self.document.remove_entity(h);
self.selected.remove(&h);
self.hatches.remove(&h);
self.meshes.remove(&h);
self.solid_models.remove(&h);
erased.push((h, ChangeKind::Removed));
}
// Remove erased handles from all groups; delete groups that become empty.
let group_dict_handle = self.document.header.acad_group_dict_handle;
let mut groups_changed = false;
let to_remove: Vec<Handle> = self
.document
.objects
.values_mut()
.filter_map(|obj| match obj {
ObjectType::Group(g) => {
let before = g.entities.len();
g.entities.retain(|h| !handles.contains(h));
if g.entities.len() != before {
groups_changed = true;
}
if g.entities.is_empty() {
Some(g.handle)
} else {
None
}
}
_ => None,
})
.collect();
// Delta-undo: editing a group (or deleting an emptied one) mutates
// document.objects — non-entity state a pure-entity delta can't restore.
if groups_changed {
self.poison_undo_recording();
}
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).
self.bump_entities(&erased);
}
}