From 2078fc02f890b7f0179cdede0d7c5101ea898709 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:49:34 +0300 Subject: [PATCH 1/2] XCLIP and CLIP: clip boundaries on block references, CLIP routed by the picked object Co-Authored-By: Claude Opus 5.5 --- src/app/command_driver.rs | 54 ++++ src/app/commands/blocks.rs | 34 ++- src/app/commands/mod.rs | 1 + src/app/commands/xclip.rs | 96 +++++++ src/command.rs | 15 ++ src/modules/insert/mod.rs | 2 +- src/modules/insert/pdf_clip.rs | 26 +- src/modules/insert/xclip.rs | 480 ++++++++++++++++++++++++++++++++- src/scene/convert/tess.rs | 3 +- src/scene/pick/xclip.rs | 252 +++++++++++++++++ 10 files changed, 947 insertions(+), 16 deletions(-) create mode 100644 src/app/commands/xclip.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bd818e65a..48433ec3b 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -479,6 +479,42 @@ impl OpenCADStudio { StepInput::SelectionComplete(kept) } + /// Keeps only block references in a completed selection when the active + /// command asks for that (XCLIP), deselecting the rest and saying how many + /// were ineligible. + fn filter_block_reference_selection(&mut self, input: StepInput) -> StepInput { + let StepInput::SelectionComplete(handles) = input else { + return input; + }; + let i = self.active_tab; + if !self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.selection_keeps_block_references()) + { + return StepInput::SelectionComplete(handles); + } + let (kept, dropped): (Vec, Vec) = handles.into_iter().partition(|h| { + matches!( + self.tabs[i].scene.document.get_entity(*h), + Some(acadrust::EntityType::Insert(_)) + ) + }); + for handle in &dropped { + self.tabs[i].scene.deselect_entity(*handle); + } + if !dropped.is_empty() { + let count = dropped.len(); + self.command_line.push_info(&if count == 1 { + "1 was ineligible for clipping.".to_string() + } else { + format!("{count} were ineligible for clipping.") + }); + self.refresh_properties(); + } + StepInput::SelectionComplete(kept) + } + /// `feed_command`, also reporting whether the step actually took the input. /// Only a `Text` token can come back unclaimed (`on_text_input` returning /// `None`), which is what lets the caller read it as something else — a @@ -488,6 +524,18 @@ impl OpenCADStudio { // the rest leave the selection and the count of what went is reported, // as the reference does while gathering. let input = self.filter_associative_dimension_selection(input); + let input = self.filter_block_reference_selection(input); + // XCLIP / CLIP learn which chosen references carry a clip already. + { + let i = self.active_tab; + let tab = &mut self.tabs[i]; + if let Some(command) = tab.active_cmd.as_mut() { + let document = &tab.scene.document; + command.inject_clipped(&|h| { + crate::scene::pick::xclip::filter_handle(document, h).is_some() + }); + } + } // Selection keywords (P / PREVIOUS, L / LAST) consume the token // before it reaches the command (#426). if let StepInput::Text(s) = &input { @@ -3892,6 +3940,12 @@ impl OpenCADStudio { } self.refresh_properties(); } + CmdResult::XClip { inserts, action } => { + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.apply_xclip(i, inserts, action); + } CmdResult::OpenPdfImportSettings => self.open_pdf_import_settings(), CmdResult::PdfImportFile(import) => { self.tabs[i].scene.clear_preview_wire(); diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index bbe978234..5ff710ac9 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -715,9 +715,39 @@ impl OpenCADStudio { Err(_) => self.command_line.push_error(&format!("{path} not found.")), } } - "PDFCLIP" | "CLIP" => { + "PDFCLIP" => { use crate::command::CadCommand; - let command = crate::modules::insert::pdf_clip::PdfClipCommand::new(cmd == "CLIP"); + let command = crate::modules::insert::pdf_clip::PdfClipCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "CLIP" => { + use crate::command::CadCommand; + let command = crate::modules::insert::xclip::ClipCommand::new(); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + // XCLIP takes block references chosen beforehand; others are + // left out. + "XCLIP" => { + use crate::command::CadCommand; + use crate::modules::insert::xclip::XclipCommand; + let inserts: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .filter(|(_, e)| matches!(e, acadrust::EntityType::Insert(_))) + .map(|(h, _)| *h) + .collect(); + let command = if inserts.is_empty() { + XclipCommand::new() + } else { + let clipped = inserts.iter().any(|h| { + crate::scene::pick::xclip::filter_handle(&self.tabs[i].scene.document, *h) + .is_some() + }); + XclipCommand::for_inserts(inserts, clipped) + }; self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index ce97b48b5..58f2164da 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -16,6 +16,7 @@ mod layers; pub(crate) mod pdf_import; mod pdf_underlay; mod pdf_dialogs; +mod xclip; mod plotvars; mod styleprops; mod view; diff --git a/src/app/commands/xclip.rs b/src/app/commands/xclip.rs new file mode 100644 index 000000000..f2d664a97 --- /dev/null +++ b/src/app/commands/xclip.rs @@ -0,0 +1,96 @@ +//! XCLIP's edits on block references, as one undo step. + +use super::*; +use crate::modules::insert::xclip::XclipAction; +use crate::scene::pick::xclip as clip; + +impl OpenCADStudio { + /// A block reference's extents in WCS: its block's objects through the + /// insert transform. + fn insert_extents(&self, i: usize, insert: acadrust::Handle) -> ([f64; 2], [f64; 2]) { + let document = &self.tabs[i].scene.document; + let Some(acadrust::EntityType::Insert(ins)) = document.get_entity(insert) else { + return ([0.0; 2], [0.0; 2]); + }; + let xform = ins.get_transform(); + let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]); + for e in document.entities_in_block(&ins.block_name) { + let (a, b) = crate::scene::convert::tess::entity_bounds_in(document, e); + for x in [a[0], b[0]] { + for y in [a[1], b[1]] { + let w = xform.apply(acadrust::types::Vector3::new(x, y, a[2])); + lo = [lo[0].min(w.x), lo[1].min(w.y)]; + hi = [hi[0].max(w.x), hi[1].max(w.y)]; + } + } + } + if lo[0] > hi[0] { + let p = xform.apply(acadrust::types::Vector3::new(0.0, 0.0, 0.0)); + return ([p.x, p.y], [p.x, p.y]); + } + (lo, hi) + } + + pub(in crate::app) fn apply_xclip( + &mut self, + i: usize, + inserts: Vec, + action: XclipAction, + ) { + self.push_undo_snapshot(i, "XCLIP"); + let extents: Vec<_> = inserts.iter().map(|h| self.insert_extents(i, *h)).collect(); + let current_layer = self.tabs[i].scene.document.header.current_layer_name.clone(); + let scene = &mut self.tabs[i].scene; + let mut polylines = Vec::new(); + for (n, insert) in inserts.iter().enumerate() { + let doc = &mut scene.document; + match &action { + XclipAction::Enable(on) => { + clip::set_insert_clip_enabled(doc, *insert, *on); + } + XclipAction::Delete => { + clip::remove_insert_clip(doc, *insert); + } + XclipAction::Depth { front, back } => { + clip::set_insert_clip_depth(doc, *insert, *front, *back); + } + XclipAction::New { boundary, inverted } => { + clip::set_insert_clip(doc, *insert, boundary, *inverted, extents[n]); + } + XclipAction::Polyline => { + let Some(spatial) = clip::filter_handle(doc, *insert) else { + continue; + }; + let (Some(acadrust::objects::ObjectType::SpatialFilter(filter)), Some(acadrust::EntityType::Insert(ins))) = + (doc.objects.get(&spatial), doc.get_entity(*insert)) + else { + continue; + }; + let outline = clip::clip_outline_world(doc, filter, &ins.get_transform()); + let mut pl = acadrust::entities::LwPolyline::from_points( + outline + .iter() + .map(|p| acadrust::types::Vector2::new(p[0], p[1])) + .collect(), + ); + pl.is_closed = true; + if !current_layer.is_empty() { + pl.common.layer = current_layer.clone(); + } + polylines.push(acadrust::EntityType::LwPolyline(pl)); + } + } + scene.reseed_derived_caches(*insert); + } + for pl in polylines { + scene.add_entity(pl); + } + let changes: Vec<_> = inserts + .iter() + .map(|h| (*h, crate::scene::ChangeKind::Modified)) + .collect(); + scene.bump_entities(&changes); + self.tabs[i].dirty = true; + self.refresh_properties(); + } +} diff --git a/src/command.rs b/src/command.rs index 276d74313..950776ba9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1559,6 +1559,11 @@ pub enum CmdResult { /// Attach PDF pages: each page's underlay with its file (as stored) and /// page; the host creates or reuses the definitions and commits them all /// in one undo step, then ends the command. + /// XCLIP on block references; ends the command. + XClip { + inserts: Vec, + action: crate::modules::insert::xclip::XclipAction, + }, /// Open the PDF Import Settings dialog; the command keeps its prompt. OpenPdfImportSettings, /// Import a page of a file (PDFIMPORT File); ends the command. @@ -2805,6 +2810,16 @@ pub trait CadCommand: Send { false } + /// The command takes block references only (XCLIP): the host drops the + /// rest and says how many were ineligible. + fn selection_keeps_block_references(&self) -> bool { + false + } + + /// Before each input the host says which block references carry a clip + /// boundary. + fn inject_clipped(&mut self, _clipped: &dyn Fn(Handle) -> bool) {} + /// Called after a selection action completes while `is_selection_gathering` is true. /// `handles` is the full set of currently selected entities. /// Return `Relaunch` to fire the pending command, or `NeedPoint` to keep gathering. diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index c7490c6ae..1f7ee46f0 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -26,7 +26,7 @@ pub(crate) mod wblock; mod xadjust; pub(crate) mod xattach; pub(crate) mod xref_cmd; -mod xclip; +pub(crate) mod xclip; use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem}; diff --git a/src/modules/insert/pdf_clip.rs b/src/modules/insert/pdf_clip.rs index 1d21ed186..3f8e74f60 100644 --- a/src/modules/insert/pdf_clip.rs +++ b/src/modules/insert/pdf_clip.rs @@ -1,6 +1,7 @@ -// PDFCLIP / CLIP — clip a PDF underlay to a boundary. +// PDFCLIP — clip a PDF underlay to a boundary (CLIP continues here for a +// PDF underlay). // -// Select PDF to clip: (CLIP: Select Object to clip:) +// Select PDF to clip: // Enter PDF clipping option [ON/OFF/Delete/New boundary] : // Delete old boundary? [Yes/No] : (when one exists) // Outside mode - Objects outside boundary will be hidden. @@ -31,8 +32,6 @@ enum Step { } pub struct PdfClipCommand { - /// CLIP asks for any object; PDFCLIP for a PDF. - generic: bool, step: Step, handle: Handle, underlay: Option, @@ -46,9 +45,8 @@ const INSIDE: &str = "Inside mode - Objects inside boundary will be hidden."; const BOUNDARY: &str = "Specify clipping boundary or select invert option:"; impl PdfClipCommand { - pub fn new(generic: bool) -> Self { + pub fn new() -> Self { Self { - generic, step: Step::Select, handle: Handle::NULL, underlay: None, @@ -62,7 +60,7 @@ impl PdfClipCommand { /// Create Clipping Boundary): the New boundary branch at once. Returns the /// command and the result of its first step. pub fn new_boundary(handle: Handle, underlay: Underlay) -> (Self, CmdResult) { - let mut command = Self::new(false); + let mut command = Self::new(); command.handle = handle; command.underlay = Some(underlay); command.step = Step::Option; @@ -70,6 +68,15 @@ impl PdfClipCommand { (command, first) } + /// An underlay CLIP picked: straight to the clipping option. + pub fn for_underlay(handle: Handle, underlay: Underlay) -> Self { + let mut command = Self::new(); + command.handle = handle; + command.underlay = Some(underlay); + command.step = Step::Option; + command + } + fn finish(&self, underlay: Underlay) -> CmdResult { CmdResult::UpdateEntityAndFinish { handle: self.handle, @@ -167,12 +174,11 @@ impl PdfClipCommand { impl CadCommand for PdfClipCommand { fn name(&self) -> &'static str { - if self.generic { "CLIP" } else { "PDFCLIP" } + "PDFCLIP" } fn prompt(&self) -> String { match self.step { - Step::Select if self.generic => "Select Object to clip:".to_string(), Step::Select => "Select PDF to clip:".to_string(), Step::Option => { "Enter PDF clipping option [ON/OFF/Delete/New boundary] :".to_string() @@ -343,5 +349,5 @@ impl CadCommand for PdfClipCommand { } inventory::submit!(crate::command::CommandRegistration { - names: &["PDFCLIP", "CLIP"] + names: &["PDFCLIP"] }); diff --git a/src/modules/insert/xclip.rs b/src/modules/insert/xclip.rs index 589cf7a17..ef69548ad 100644 --- a/src/modules/insert/xclip.rs +++ b/src/modules/insert/xclip.rs @@ -1,10 +1,486 @@ +// XCLIP — clip block references (and drawing references) to a boundary. +// +// Select objects: (block references only; +// "N was ineligible for clipping.") +// Enter clipping option +// [ON/OFF/Clipdepth/Delete/generate Polyline/New boundary] : +// Delete old boundary(s)? [Yes/No] : (New, when one exists) +// Outside mode - Objects outside boundary will be hidden. +// Specify clipping boundary or select invert option: +// [Select polyline/Polygonal/Rectangular/Invert clip] : +// Specify first corner: / Specify opposite corner: +// Specify first point: / Specify next point or [Undo]: +// Specify front clip point or [Distance/Remove]: +// +// CLIP asks for one object: a block reference continues as XCLIP, a PDF +// underlay as PDFCLIP, anything else is "*Invalid selection*". + +use acadrust::entities::UnderlayType; +use acadrust::types::Handle; +use acadrust::EntityType; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; +use crate::modules::insert::pdf_clip::PdfClipCommand; use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; + pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/xclip.svg")); pub fn tool() -> ToolDef { ToolDef { - id: "XCLIP", + id: "CLIP", label: "Clip", icon: ICON, - event: ModuleEvent::Command("XCLIP".to_string()), + event: ModuleEvent::Command("CLIP".to_string()), } } + +/// What XCLIP does to the chosen block references. +#[derive(Clone, Debug, PartialEq)] +pub enum XclipAction { + Enable(bool), + Delete, + Polyline, + /// Boundary in WCS (two points = rectangle corners). + New { boundary: Vec<[f64; 2]>, inverted: bool }, + /// Front and back clipping planes; `None` removes one. + Depth { front: Option, back: Option }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Select, + Option, + DeleteOld, + Mode, + RectFirst, + RectSecond, + PolyPoints, + Polyline, + Front, + FrontDistance, + Back, + BackDistance, +} + +const OPTION: &str = "Enter clipping option\n[ON/OFF/Clipdepth/Delete/generate Polyline/New boundary] :"; +const OUTSIDE: &str = "Outside mode - Objects outside boundary will be hidden."; +const INSIDE: &str = "Inside mode - Objects inside boundary will be hidden."; +const BOUNDARY: &str = "Specify clipping boundary or select invert option:"; + +pub struct XclipCommand { + step: Step, + inserts: Vec, + /// Some chosen reference already has a clip boundary. + clipped: bool, + inverted: bool, + points: Vec, + picked: Option, + front: Option, +} + +impl XclipCommand { + pub fn new() -> Self { + Self { + step: Step::Select, + inserts: Vec::new(), + clipped: false, + inverted: false, + points: Vec::new(), + picked: None, + front: None, + } + } + + /// The references chosen before the command (or by CLIP), with whether + /// any of them is clipped already: straight to the option prompt. + pub fn for_inserts(inserts: Vec, clipped: bool) -> Self { + Self { + step: Step::Option, + inserts, + clipped, + ..Self::new() + } + } + + fn act(&self, action: XclipAction) -> CmdResult { + CmdResult::XClip { + inserts: self.inserts.clone(), + action, + } + } + + fn enter_mode(&mut self) -> CmdResult { + self.step = Step::Mode; + let mode = if self.inverted { INSIDE } else { OUTSIDE }; + CmdResult::ReportMeasurement(format!("{mode}\n{BOUNDARY}")) + } + + fn new_boundary(&self, world: Vec) -> CmdResult { + self.act(XclipAction::New { + boundary: world.iter().map(|p| [p.x, p.y]).collect(), + inverted: self.inverted, + }) + } + + fn option(&mut self, text: &str) -> CmdResult { + let token = text.trim().to_ascii_uppercase(); + let needs_boundary = matches!( + token.as_str(), + "ON" | "OFF" | "D" | "DELETE" | "P" | "POLYLINE" | "GENERATE POLYLINE" | "C" | "CLIPDEPTH" + ); + if needs_boundary && !self.clipped { + return CmdResult::ReportError("No clip boundary found.".to_string()); + } + match token.as_str() { + "ON" => self.act(XclipAction::Enable(true)), + "OFF" => self.act(XclipAction::Enable(false)), + "D" | "DELETE" => self.act(XclipAction::Delete), + "P" | "POLYLINE" | "GENERATE POLYLINE" => self.act(XclipAction::Polyline), + "C" | "CLIPDEPTH" => { + self.step = Step::Front; + CmdResult::NeedPoint + } + "" | "N" | "NEW" | "NEW BOUNDARY" => { + if self.clipped { + self.step = Step::DeleteOld; + CmdResult::NeedPoint + } else { + self.enter_mode() + } + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + } + } + + fn mode(&mut self, text: &str) -> CmdResult { + match text.trim().to_ascii_uppercase().as_str() { + "" | "R" | "RECTANGULAR" => { + self.step = Step::RectFirst; + CmdResult::NeedPoint + } + "P" | "POLYGONAL" => { + self.points.clear(); + self.step = Step::PolyPoints; + CmdResult::NeedPoint + } + "S" | "SELECT" | "SELECT POLYLINE" => { + self.step = Step::Polyline; + CmdResult::NeedPoint + } + "I" | "INVERT" | "INVERT CLIP" => { + self.inverted = !self.inverted; + self.enter_mode() + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + } + } + + fn depth(&mut self, text: &str) -> CmdResult { + match (self.step, text.trim().to_ascii_uppercase().as_str()) { + (_, "R" | "REMOVE") => self.act(XclipAction::Depth { front: None, back: None }), + (Step::Front, "D" | "DISTANCE") => { + self.step = Step::FrontDistance; + CmdResult::NeedPoint + } + (Step::Back, "D" | "DISTANCE") => { + self.step = Step::BackDistance; + CmdResult::NeedPoint + } + (Step::FrontDistance | Step::BackDistance, value) => match value.parse::() { + Ok(d) => self.take_depth(d), + Err(_) => CmdResult::ReportError("Requires numeric distance.".to_string()), + }, + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + } + } + + fn take_depth(&mut self, d: f64) -> CmdResult { + match self.step { + Step::Front | Step::FrontDistance => { + self.front = Some(d); + self.step = Step::Back; + CmdResult::NeedPoint + } + _ => self.act(XclipAction::Depth { front: self.front, back: Some(d) }), + } + } + + fn rectangle(a: DVec3, b: DVec3) -> Vec { + vec![a, DVec3::new(b.x, a.y, a.z), b, DVec3::new(a.x, b.y, a.z)] + } +} + +impl CadCommand for XclipCommand { + fn name(&self) -> &'static str { + "XCLIP" + } + + fn prompt(&self) -> String { + match self.step { + Step::Select => "Select objects:".to_string(), + Step::Option => OPTION.to_string(), + Step::DeleteOld => "Delete old boundary(s)? [Yes/No] :".to_string(), + Step::Mode => { + "[Select polyline/Polygonal/Rectangular/Invert clip] :".to_string() + } + Step::RectFirst => "Specify first corner:".to_string(), + Step::RectSecond => "Specify opposite corner:".to_string(), + Step::PolyPoints if self.points.is_empty() => "Specify first point:".to_string(), + Step::PolyPoints => "Specify next point or [Undo]:".to_string(), + Step::Polyline => "Select polyline:".to_string(), + Step::Front => "Specify front clip point or [Distance/Remove]:".to_string(), + Step::Back => "Specify back clip point or [Distance/Remove]:".to_string(), + Step::FrontDistance | Step::BackDistance => { + "Specify distance from boundary:".to_string() + } + } + } + + fn options(&self) -> Vec { + match self.step { + Step::Option => vec![ + CmdOption::new("ON", "ON"), + CmdOption::new("OFF", "OFF"), + CmdOption::new("Clipdepth", "C"), + CmdOption::new("Delete", "D"), + CmdOption::new("generate Polyline", "P"), + CmdOption::new("New boundary", "N"), + ], + Step::DeleteOld => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")], + Step::Mode => vec![ + CmdOption::new("Select polyline", "S"), + CmdOption::new("Polygonal", "P"), + CmdOption::new("Rectangular", "R"), + CmdOption::new("Invert clip", "I"), + ], + Step::PolyPoints if !self.points.is_empty() => vec![CmdOption::new("Undo", "U")], + Step::Front | Step::Back => { + vec![CmdOption::new("Distance", "D"), CmdOption::new("Remove", "R")] + } + _ => Vec::new(), + } + } + + fn input_kind(&self) -> InputKind { + match self.step { + Step::Option | Step::DeleteOld | Step::Mode => InputKind::SingleToken, + _ => InputKind::Point, + } + } + + fn wants_text_input(&self) -> bool { + self.step != Step::Select + } + + fn is_selection_gathering(&self) -> bool { + self.step == Step::Select + } + + fn selection_keeps_block_references(&self) -> bool { + self.step == Step::Select + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.inserts = handles; + CmdResult::NeedPoint + } + + fn inject_clipped(&mut self, clipped: &dyn Fn(Handle) -> bool) { + self.clipped = self.inserts.iter().any(|h| clipped(*h)); + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!( + self.step, + Step::PolyPoints | Step::Front | Step::Back | Step::FrontDistance | Step::BackDistance + ) + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::Polyline + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, _handle: Handle, _pt: DVec3) -> CmdResult { + match self.picked.take() { + Some(EntityType::LwPolyline(pl)) if pl.is_closed && pl.vertices.len() >= 3 => { + let z = pl.elevation; + self.new_boundary( + pl.vertices + .iter() + .map(|v| DVec3::new(v.location.x, v.location.y, z)) + .collect(), + ) + } + _ => CmdResult::ReportError("Invalid object selected.".to_string()), + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + Step::RectFirst => { + self.points = vec![pt]; + self.step = Step::RectSecond; + CmdResult::NeedPoint + } + Step::RectSecond => self.new_boundary(vec![self.points[0], pt]), + Step::PolyPoints => { + self.points.push(pt); + CmdResult::NeedPoint + } + // A clip point sets the plane at its height above the boundary. + Step::Front | Step::Back => self.take_depth(pt.z), + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + Some(match self.step { + Step::Option => self.option(text), + Step::DeleteOld => match text.trim().to_ascii_uppercase().as_str() { + "" | "Y" | "YES" => self.enter_mode(), + "N" | "NO" => CmdResult::Cancel, + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + Step::Mode => self.mode(text), + Step::PolyPoints => match text.trim().to_ascii_uppercase().as_str() { + "U" | "UNDO" => { + self.points.pop(); + CmdResult::NeedPoint + } + _ => return None, + }, + Step::Front | Step::Back | Step::FrontDistance | Step::BackDistance => self.depth(text), + _ => return None, + }) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Select if self.inserts.is_empty() => { + CmdResult::CancelWithMessage("None found.".to_string()) + } + Step::Select => { + self.step = Step::Option; + CmdResult::NeedPoint + } + Step::Option => self.option(""), + Step::DeleteOld => self.enter_mode(), + Step::Mode => self.mode(""), + Step::PolyPoints if self.points.len() >= 3 => { + let points = self.points.clone(); + self.new_boundary(points) + } + Step::PolyPoints => CmdResult::NeedPoint, + _ => CmdResult::Cancel, + } + } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + let ring: Vec = match self.step { + Step::RectSecond => Self::rectangle(self.points[0], pt), + Step::PolyPoints if !self.points.is_empty() => { + let mut ring = self.points.clone(); + ring.push(pt); + ring + } + _ => return None, + }; + let mut points: Vec<[f32; 3]> = + ring.iter().map(|p| [p.x as f32, p.y as f32, p.z as f32]).collect(); + points.push(points[0]); + Some(WireModel::solid("xclip_boundary".into(), points, WireModel::CYAN, false)) + } +} + +/// CLIP: one object, then that object's clip command. +pub struct ClipCommand { + inner: Option>, + picked: Option, +} + +impl ClipCommand { + pub fn new() -> Self { + Self { + inner: None, + picked: None, + } + } +} + +impl CadCommand for ClipCommand { + fn name(&self) -> &'static str { + self.inner.as_ref().map_or("CLIP", |c| c.name()) + } + fn prompt(&self) -> String { + self.inner + .as_ref() + .map_or_else(|| "Select Object to clip:".to_string(), |c| c.prompt()) + } + fn options(&self) -> Vec { + self.inner.as_ref().map_or_else(Vec::new, |c| c.options()) + } + fn input_kind(&self) -> InputKind { + self.inner.as_ref().map_or(InputKind::Point, |c| c.input_kind()) + } + fn point_step_accepts_keywords(&self) -> bool { + self.inner.as_ref().is_some_and(|c| c.point_step_accepts_keywords()) + } + fn needs_entity_pick(&self) -> bool { + self.inner.as_ref().is_none_or(|c| c.needs_entity_pick()) + } + fn inject_before_entity_pick(&self) -> bool { + true + } + fn inject_picked_entity(&mut self, entity: EntityType) { + match self.inner.as_mut() { + Some(c) => c.inject_picked_entity(entity), + None => self.picked = Some(entity), + } + } + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { + if let Some(c) = self.inner.as_mut() { + return c.on_entity_pick(handle, pt); + } + match self.picked.take() { + Some(EntityType::Insert(_)) => { + self.inner = Some(Box::new(XclipCommand::for_inserts(vec![handle], false))); + CmdResult::NeedPoint + } + Some(EntityType::Underlay(u)) if u.underlay_type == UnderlayType::Pdf => { + self.inner = Some(Box::new(PdfClipCommand::for_underlay(handle, u))); + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("*Invalid selection*".to_string()), + } + } + fn on_point(&mut self, pt: DVec3) -> CmdResult { + self.inner.as_mut().map_or(CmdResult::NeedPoint, |c| c.on_point(pt)) + } + fn inject_clipped(&mut self, clipped: &dyn Fn(Handle) -> bool) { + if let Some(c) = self.inner.as_mut() { + c.inject_clipped(clipped); + } + } + fn on_text_input(&mut self, text: &str) -> Option { + self.inner.as_mut().and_then(|c| c.on_text_input(text)) + } + fn on_enter(&mut self) -> CmdResult { + self.inner.as_mut().map_or(CmdResult::Cancel, |c| c.on_enter()) + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + self.inner.as_mut().and_then(|c| c.on_mouse_move(pt)) + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["XCLIP", "CLIP"] +}); diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 81128aa0d..753c8bf6e 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -169,9 +169,10 @@ pub(crate) fn expand_block_object( } let frame_mode = crate::scene::frame::mode(document, crate::scene::frame::FrameKind::Xclip); + let outline = pick::xclip::clip_outline_world(document, filter, &transform); if polygon.len() >= 3 { let mut frame = pick::xclip::frame_wire( - &polygon, + &outline, owner.value().to_string(), style.color, selected, diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 0378dc7a7..0abcf7332 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -45,6 +45,258 @@ pub fn insert_spatial_filter<'a>( } } +// ── Editing (XCLIP) ───────────────────────────────────────────────────────── + +/// The filter's handle, when the insert has one (enabled or not). +pub fn filter_handle(doc: &CadDocument, insert: Handle) -> Option { + let Some(acadrust::EntityType::Insert(ins)) = doc.get_entity(insert) else { + return None; + }; + let acad_filter = dict_entry(doc, ins.common.xdictionary_handle?, "ACAD_FILTER")?; + let spatial = dict_entry(doc, acad_filter, "SPATIAL")?; + matches!(doc.objects.get(&spatial), Some(ObjectType::SpatialFilter(_))).then_some(spatial) +} + +const ROUNDTRIP: &str = "ACAD_XREC_ROUNDTRIP"; +const INVERTED: &str = "ACAD_INVERTEDCLIP_ROUNDTRIP"; +const INVERTED_COMPARE: &str = "ACAD_INVERTEDCLIP_ROUNDTRIP_COMPARE"; + +/// The boundary an inverted clip was drawn with (WCS), kept beside the +/// filter's ring in an ACAD_XREC_ROUNDTRIP record as the reference keeps it. +pub fn inverted_boundary(doc: &CadDocument, filter: &SpatialFilter) -> Option> { + let xdict = match doc.objects.get(&filter.handle)? { + ObjectType::SpatialFilter(_) => filter_xdictionary(doc, filter.handle)?, + _ => return None, + }; + let record = dict_entry(doc, xdict, ROUNDTRIP)?; + let Some(ObjectType::XRecord(x)) = doc.objects.get(&record) else { + return None; + }; + let mut points = Vec::new(); + let mut inside = false; + for entry in &x.entries { + match &entry.value { + acadrust::objects::XRecordValue::String(s) if entry.code == 102 => { + inside = s == INVERTED; + } + acadrust::objects::XRecordValue::Point3D(px, py, _) if inside && entry.code == 10 => { + points.push([*px, *py]); + } + _ => {} + } + } + (points.len() >= 2).then_some(points) +} + +/// Filters keep their extension dictionary in the document's owner map: the +/// dictionary owned by the filter. +fn filter_xdictionary(doc: &CadDocument, filter: Handle) -> Option { + doc.objects.iter().find_map(|(h, o)| match o { + ObjectType::Dictionary(d) if d.owner == filter => Some(*h), + _ => None, + }) +} + +/// The inverted boundary drawn as the XCLIP frame and generated polyline, +/// else the stored one; rectangles as four corners (WCS through the stored +/// transforms). +pub fn clip_outline_world(doc: &CadDocument, filter: &SpatialFilter, xform: &Transform) -> Vec<[f64; 2]> { + match inverted_boundary(doc, filter) { + Some(points) => { + let mut outline = filter.clone(); + outline.boundary_points = points + .iter() + .map(|p| acadrust::types::Vector2::new(p[0], p[1])) + .collect(); + world_clip_polygon_for_transform(&outline, xform) + } + None => world_clip_polygon_for_transform(filter, xform), + } +} + +/// The ring that shows only what lies outside `inner` (WCS, drawn order): +/// the block's extents grown by 5 % on each side, joined to the boundary by +/// a hairline slit — the polygon the reference stores for an inverted clip. +// ponytail: the slit runs left from the boundary's last corner; a concave +// boundary it would cross clips wrongly there. +pub fn inverted_ring(inner: &[[f64; 2]], extents: ([f64; 2], [f64; 2])) -> Vec<[f64; 2]> { + let (lo, hi) = extents; + let size = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-9); + let pad = size * 0.05; + let (x0, y0, x1, y1) = ( + lo[0].min(inner.iter().map(|p| p[0]).fold(f64::MAX, f64::min)) - pad, + lo[1].min(inner.iter().map(|p| p[1]).fold(f64::MAX, f64::min)) - pad, + hi[0].max(inner.iter().map(|p| p[0]).fold(f64::MIN, f64::max)) + pad, + hi[1].max(inner.iter().map(|p| p[1]).fold(f64::MIN, f64::max)) + pad, + ); + let eps = size * 4e-6; + let last = *inner.last().expect("a boundary has points"); + let mut ring = inner.to_vec(); + ring.extend([ + [x0, last[1]], + [x0, y1], + [x1, y1], + [x1, y0], + [x0, y0], + [x0, last[1] - eps], + [last[0], last[1] - eps], + ]); + ring +} + +/// A rectangle's two corners as its four, in drawing order. +pub fn rectangle_corners(a: [f64; 2], b: [f64; 2]) -> Vec<[f64; 2]> { + vec![a, [b[0], a[1]], b, [a[0], b[1]]] +} + +/// Remove an insert's clip (the filter and what it owns). +pub fn remove_insert_clip(doc: &mut CadDocument, insert: Handle) -> bool { + let Some(spatial) = filter_handle(doc, insert) else { + return false; + }; + if let Some(xdict) = filter_xdictionary(doc, spatial) { + let children: Vec = match doc.objects.get(&xdict) { + Some(ObjectType::Dictionary(d)) => d.entries.iter().map(|(_, h)| *h).collect(), + _ => Vec::new(), + }; + for child in children { + doc.objects.remove(&child); + } + doc.objects.remove(&xdict); + } + doc.objects.remove(&spatial); + for object in doc.objects.values_mut() { + if let ObjectType::Dictionary(d) = object { + d.entries.retain(|(_, h)| *h != spatial); + } + } + true +} + +/// Turn an insert's clip display on or off; false when it has none. +pub fn set_insert_clip_enabled(doc: &mut CadDocument, insert: Handle, on: bool) -> bool { + let Some(spatial) = filter_handle(doc, insert) else { + return false; + }; + if let Some(ObjectType::SpatialFilter(f)) = doc.objects.get_mut(&spatial) { + f.display_enabled = on; + } + true +} + +/// Front and back clipping planes (None = off) of an insert's clip. +pub fn set_insert_clip_depth(doc: &mut CadDocument, insert: Handle, front: Option, back: Option) -> bool { + let Some(spatial) = filter_handle(doc, insert) else { + return false; + }; + if let Some(ObjectType::SpatialFilter(f)) = doc.objects.get_mut(&spatial) { + f.front_clip = front; + f.back_clip = back; + } + true +} + +/// A new clip boundary on an insert, replacing any old one. `boundary` is in +/// WCS (two points = rectangle corners); `inverted` hides what is inside, +/// stored as the reference stores it (the ring plus the drawn boundary). +pub fn set_insert_clip( + doc: &mut CadDocument, + insert: Handle, + boundary: &[[f64; 2]], + inverted: bool, + extents: ([f64; 2], [f64; 2]), +) -> bool { + use acadrust::objects::{Dictionary, XRecord, XRecordEntry, XRecordValue}; + use acadrust::types::{Matrix4, Vector2}; + let Some(acadrust::EntityType::Insert(ins)) = doc.get_entity(insert) else { + return false; + }; + let t = ins.get_transform().matrix.m; + let forward = glam::DMat4::from_cols_array_2d(&[ + [t[0][0], t[1][0], t[2][0], t[3][0]], + [t[0][1], t[1][1], t[2][1], t[3][1]], + [t[0][2], t[1][2], t[2][2], t[3][2]], + [t[0][3], t[1][3], t[2][3], t[3][3]], + ]); + let inv = forward.inverse().to_cols_array_2d(); + let inverse = Matrix4 { + m: [ + [inv[0][0], inv[1][0], inv[2][0], inv[3][0]], + [inv[0][1], inv[1][1], inv[2][1], inv[3][1]], + [inv[0][2], inv[1][2], inv[2][2], inv[3][2]], + [inv[0][3], inv[1][3], inv[2][3], inv[3][3]], + ], + }; + let existing_xdict = ins.common.xdictionary_handle; + remove_insert_clip(doc, insert); + + // insert → extension dictionary → ACAD_FILTER → SPATIAL + let xdict = match existing_xdict.filter(|h| matches!(doc.objects.get(h), Some(ObjectType::Dictionary(_)))) { + Some(h) => h, + None => { + let h = doc.allocate_handle(); + let mut d = Dictionary::new(); + (d.handle, d.owner, d.hard_owner) = (h, insert, true); + doc.objects.insert(h, ObjectType::Dictionary(d)); + if let Some(entity) = doc.get_entity_mut(insert) { + entity.common_mut().xdictionary_handle = Some(h); + } + h + } + }; + let acad_filter = match dict_entry(doc, xdict, "ACAD_FILTER") { + Some(h) => h, + None => { + let h = doc.allocate_handle(); + let mut d = Dictionary::new(); + (d.handle, d.owner, d.hard_owner) = (h, xdict, true); + doc.objects.insert(h, ObjectType::Dictionary(d)); + if let Some(ObjectType::Dictionary(parent)) = doc.objects.get_mut(&xdict) { + parent.add_entry("ACAD_FILTER", h); + } + h + } + }; + let stored: Vec<[f64; 2]> = if inverted { + let inner = if boundary.len() == 2 { + rectangle_corners(boundary[0], boundary[1]) + } else { + boundary.to_vec() + }; + inverted_ring(&inner, extents) + } else { + boundary.to_vec() + }; + let spatial = doc.allocate_handle(); + let mut filter = SpatialFilter::new(); + filter.handle = spatial; + filter.owner = acad_filter; + filter.boundary_points = stored.iter().map(|p| Vector2::new(p[0], p[1])).collect(); + filter.inverse_block_transform = inverse; + filter.clip_bound_transform = Matrix4::identity(); + doc.objects.insert(spatial, ObjectType::SpatialFilter(filter)); + if let Some(ObjectType::Dictionary(parent)) = doc.objects.get_mut(&acad_filter) { + parent.add_entry("SPATIAL", spatial); + } + if inverted { + let fdict = doc.allocate_handle(); + let record = doc.allocate_handle(); + let mut d = Dictionary::new(); + (d.handle, d.owner, d.hard_owner) = (fdict, spatial, true); + d.add_entry(ROUNDTRIP, record); + doc.objects.insert(fdict, ObjectType::Dictionary(d)); + let mut x = XRecord::new(); + (x.handle, x.owner) = (record, fdict); + let point = |p: &[f64; 2]| XRecordEntry::new(10, XRecordValue::Point3D(p[0], p[1], 0.0)); + x.entries.push(XRecordEntry::string(102, INVERTED)); + x.entries.extend(boundary.iter().map(point)); + x.entries.push(XRecordEntry::string(102, INVERTED_COMPARE)); + x.entries.extend(stored.iter().map(point)); + doc.objects.insert(record, ObjectType::XRecord(x)); + } + true +} + fn dict_entry(doc: &CadDocument, dict: Handle, key: &str) -> Option { match doc.objects.get(&dict)? { ObjectType::Dictionary(d) => { From 32467adcef66ddd4dcb092bd8dd6052eb1bb623d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:02:16 +0300 Subject: [PATCH 2/2] XCLIP: roundtrip record keeps its cloning flag Co-Authored-By: Claude Opus 5.5 --- src/scene/pick/xclip.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 0abcf7332..984ffc7be 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -287,6 +287,7 @@ pub fn set_insert_clip( doc.objects.insert(fdict, ObjectType::Dictionary(d)); let mut x = XRecord::new(); (x.handle, x.owner) = (record, fdict); + x.cloning_flags = acadrust::objects::DictionaryCloningFlags::KeepExisting; let point = |p: &[f64; 2]| XRecordEntry::new(10, XRecordValue::Point3D(p[0], p[1], 0.0)); x.entries.push(XRecordEntry::string(102, INVERTED)); x.entries.extend(boundary.iter().map(point));