diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 2d02999ba..39ef387da 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -790,7 +790,7 @@ impl OpenCADStudio { if self.clipboard.is_empty() { self.command_line.push_error("Clipboard is empty."); } else { - let delta = base_pt - self.clipboard_centroid; + let delta = base_pt - self.clipboard_base; let translate = crate::command::EntityTransform::Translate(delta); self.push_undo_snapshot(i, "PASTECLIP"); let count = self.clipboard.len(); diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 94a1c69de..bffb43c44 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -70,7 +70,7 @@ impl OpenCADStudio { .iter() .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned()) .collect(); - self.clipboard_centroid = super::super::helpers::entities_centroid_by_bbox( + self.clipboard_base = super::super::helpers::entities_lower_left_by_bbox( &self.tabs[i].scene.document, &handles, ); @@ -87,7 +87,7 @@ impl OpenCADStudio { } // COPYBASE — copy the selection to the clipboard with a picked base - // point (vs COPYCLIP, which uses the selection centroid). + // point (vs COPYCLIP, which uses the selection's lower-left corner). "COPYBASE" => { let handles: Vec<_> = self.tabs[i] .scene @@ -127,7 +127,7 @@ impl OpenCADStudio { .iter() .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned()) .collect(); - self.clipboard_centroid = base; + self.clipboard_base = base; self.clipboard = entities; self.clipboard_deps = super::super::ClipboardDeps::capture( &self.tabs[i].scene.document, @@ -159,7 +159,7 @@ impl OpenCADStudio { .iter() .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned()) .collect(); - self.clipboard_centroid = super::super::helpers::entities_centroid_by_bbox( + self.clipboard_base = super::super::helpers::entities_lower_left_by_bbox( &self.tabs[i].scene.document, &handles, ); @@ -184,11 +184,11 @@ impl OpenCADStudio { self.command_line.push_error("Clipboard is empty."); } else { let wires = self.tabs[i].scene.wires_for_entities(&self.clipboard); - let centroid = self.clipboard_centroid; + let base = self.clipboard_base; use crate::modules::draw::clipboard::paste::PasteCommand; // The ghost anchor is a display-only offset; the precise // paste delta is computed in f64 at commit time. - let cmd = PasteCommand::new(wires, centroid.as_vec3()); + let cmd = PasteCommand::new(wires, base.as_vec3()); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } @@ -232,7 +232,7 @@ impl OpenCADStudio { // block's nested insert keeps its clip. (#xclip-paste) let ext_roots = self.recreate_clipboard_ext_roots(i); let name = self.unique_block_name("Block"); - let base = self.clipboard_centroid; + let base = self.clipboard_base; let mut entities = self.clipboard.clone(); for (idx, root) in ext_roots { if let Some(e) = entities.get_mut(idx) { @@ -246,7 +246,7 @@ impl OpenCADStudio { Ok(()) => { // Block defined; now place it interactively so the // user picks the drop point (insertion uses the - // clipboard centroid as the block's base). The + // clipboard lower-left corner as the block's base). The // clipboard wires rubber-band under the cursor. self.tabs[i].scene.populate_meshes_from_document(); self.tabs[i].dirty = true; diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 3fc841edd..1be547dda 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -252,32 +252,31 @@ pub(super) fn polar_constrain_near( // ── Clipboard / selection helpers ────────────────────────────────────────── -/// Cheap copy/paste anchor: the mean of each entity's bounding-box centre. -/// Replaces averaging every tessellated wire vertex, which cost O(total -/// geometry) and stalled a whole-drawing copy. The anchor only sets which point -/// of the selection sits under the cursor, so a per-entity bbox centre is an -/// equally valid, far cheaper origin. -pub(super) fn entities_centroid_by_bbox( +/// Copy/paste anchor: the lower-left corner of the bounding box that encloses +/// every copied entity. Standard clipboard behaviour puts this corner under the +/// cursor at paste time, so the whole selection drops down-and-right of the pick. +/// +/// Unions each entity's own bounding box (O(entities)) rather than averaging +/// every tessellated wire vertex, which cost O(total geometry) and stalled a +/// whole-drawing copy. The per-entity `min` corners give the exact enclosing +/// box's lower-left. +pub(super) fn entities_lower_left_by_bbox( doc: &acadrust::CadDocument, handles: &[acadrust::Handle], ) -> glam::DVec3 { - let mut sum = glam::DVec3::ZERO; - let mut count = 0usize; + let mut min = glam::DVec3::splat(f64::INFINITY); + let mut any = false; for &h in handles { let Some(e) = doc.get_entity(h) else { continue }; let bb = e.as_entity().bounding_box(); - let c = glam::DVec3::new( - (bb.min.x + bb.max.x) * 0.5, - (bb.min.y + bb.max.y) * 0.5, - (bb.min.z + bb.max.z) * 0.5, - ); - if c.x.is_finite() && c.y.is_finite() && c.z.is_finite() { - sum += c; - count += 1; + let lo = glam::DVec3::new(bb.min.x, bb.min.y, bb.min.z); + if lo.x.is_finite() && lo.y.is_finite() && lo.z.is_finite() { + min = min.min(lo); + any = true; } } - if count > 0 { - sum / count as f64 + if any { + min } else { glam::DVec3::ZERO } diff --git a/src/app/mod.rs b/src/app/mod.rs index 8b76b545d..65674f1d9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -500,8 +500,10 @@ pub(super) struct OpenCADStudio { clipboard: Vec, /// Entities removed by the most recent ERASE, kept so OOPS can restore them. oops_cache: Vec, - /// Centroid of the clipboard entities (world XY plane). - clipboard_centroid: glam::DVec3, + /// Paste anchor: lower-left corner of the clipboard entities' bounding box + /// (or the point picked by COPYBASE). This point lands under the cursor at + /// paste time. + clipboard_base: glam::DVec3, /// Table records (layer / linetype / text + dim style) the clipboard /// entities reference, captured from the source drawing at copy time so a /// paste into a *different* drawing can recreate any that are missing — @@ -2152,7 +2154,7 @@ impl OpenCADStudio { update_notice_body: None, clipboard: Vec::new(), oops_cache: Vec::new(), - clipboard_centroid: glam::DVec3::ZERO, + clipboard_base: glam::DVec3::ZERO, clipboard_deps: ClipboardDeps::default(), shift_down: false, ctrl_down: false, diff --git a/src/modules/draw/clipboard/copy_base.rs b/src/modules/draw/clipboard/copy_base.rs index b0c4af4b8..b1dfcda00 100644 --- a/src/modules/draw/clipboard/copy_base.rs +++ b/src/modules/draw/clipboard/copy_base.rs @@ -1,6 +1,6 @@ // COPYBASE — copy the current selection to the clipboard using a base point the -// user picks (rather than the selection's centroid, as COPYCLIP does). The -// command only collects the base point; the host performs the copy when it +// user picks (rather than the selection's lower-left corner, as COPYCLIP does). +// The command only collects the base point; the host performs the copy when it // receives the dispatched `COPYBASE_AT ` token. use glam::DVec3; diff --git a/src/modules/draw/clipboard/paste.rs b/src/modules/draw/clipboard/paste.rs index 1bf9de62c..c5a6ce976 100644 --- a/src/modules/draw/clipboard/paste.rs +++ b/src/modules/draw/clipboard/paste.rs @@ -32,8 +32,9 @@ pub struct PasteCommand { /// just this 5-point rectangle instead — O(1) per frame. `None` = ghost the /// full wires. bbox_wire: Option, - /// Centroid of the clipboard entities (offset origin for translation). - centroid: Vec3, + /// Paste anchor — lower-left corner of the clipboard entities (offset origin + /// for translation; the point that tracks the cursor). + base: Vec3, } impl PasteCommand { @@ -44,17 +45,17 @@ impl PasteCommand { const MAX_PREVIEW_WIRES: usize = 20_000; const MAX_PREVIEW_POINTS: usize = 300_000; - pub fn new(wires: Vec, centroid: Vec3) -> Self { + pub fn new(wires: Vec, base: Vec3) -> Self { let total_points: usize = wires.iter().map(|w| w.points.len()).sum(); let too_heavy = wires.len() > Self::MAX_PREVIEW_WIRES || total_points > Self::MAX_PREVIEW_POINTS; if too_heavy { if let Some(bbox_wire) = Self::bbox_outline(&wires) { // Drop the full wires — the box is all the ghost needs now. - return Self { wires: Vec::new(), bbox_wire: Some(bbox_wire), centroid }; + return Self { wires: Vec::new(), bbox_wire: Some(bbox_wire), base }; } } - Self { wires, bbox_wire: None, centroid } + Self { wires, bbox_wire: None, base } } /// Build a closed rectangle outline around the XY extent of `wires`, in the @@ -119,7 +120,7 @@ impl CadCommand for PasteCommand { fn on_preview_wires(&mut self, pt: DVec3) -> Vec { let pt = pt.as_vec3(); - let delta = pt - self.centroid; + let delta = pt - self.base; // Large clipboard: ghost just the bounding box — O(1) per frame. if let Some(bbox) = &self.bbox_wire { return vec![bbox.translated(delta)];