fix(clipboard): paste anchor at bounding-box lower-left, not center (#285)

COPYCLIP/CUTCLIP used the mean of each entity's bounding-box centre as the
paste anchor. Standard clipboard behaviour puts the lower-left corner of the
box enclosing all copied items under the cursor. Union each entity's bbox min
corner instead, and rename clipboard_centroid -> clipboard_base (COPYBASE also
stores a picked point there, so "centroid" was inaccurate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-10 11:16:13 +03:00
commit c249a99d4c
6 changed files with 40 additions and 38 deletions

View file

@ -790,7 +790,7 @@ impl OpenCADStudio {
if self.clipboard.is_empty() { if self.clipboard.is_empty() {
self.command_line.push_error("Clipboard is empty."); self.command_line.push_error("Clipboard is empty.");
} else { } else {
let delta = base_pt - self.clipboard_centroid; let delta = base_pt - self.clipboard_base;
let translate = crate::command::EntityTransform::Translate(delta); let translate = crate::command::EntityTransform::Translate(delta);
self.push_undo_snapshot(i, "PASTECLIP"); self.push_undo_snapshot(i, "PASTECLIP");
let count = self.clipboard.len(); let count = self.clipboard.len();

View file

@ -70,7 +70,7 @@ impl OpenCADStudio {
.iter() .iter()
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned()) .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned())
.collect(); .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, &self.tabs[i].scene.document,
&handles, &handles,
); );
@ -87,7 +87,7 @@ impl OpenCADStudio {
} }
// COPYBASE — copy the selection to the clipboard with a picked base // 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" => { "COPYBASE" => {
let handles: Vec<_> = self.tabs[i] let handles: Vec<_> = self.tabs[i]
.scene .scene
@ -127,7 +127,7 @@ impl OpenCADStudio {
.iter() .iter()
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned()) .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned())
.collect(); .collect();
self.clipboard_centroid = base; self.clipboard_base = base;
self.clipboard = entities; self.clipboard = entities;
self.clipboard_deps = super::super::ClipboardDeps::capture( self.clipboard_deps = super::super::ClipboardDeps::capture(
&self.tabs[i].scene.document, &self.tabs[i].scene.document,
@ -159,7 +159,7 @@ impl OpenCADStudio {
.iter() .iter()
.filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned()) .filter_map(|&h| self.tabs[i].scene.document.get_entity(h).cloned())
.collect(); .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, &self.tabs[i].scene.document,
&handles, &handles,
); );
@ -184,11 +184,11 @@ impl OpenCADStudio {
self.command_line.push_error("Clipboard is empty."); self.command_line.push_error("Clipboard is empty.");
} else { } else {
let wires = self.tabs[i].scene.wires_for_entities(&self.clipboard); 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; use crate::modules::draw::clipboard::paste::PasteCommand;
// The ghost anchor is a display-only offset; the precise // The ghost anchor is a display-only offset; the precise
// paste delta is computed in f64 at commit time. // 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.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd)); self.tabs[i].active_cmd = Some(Box::new(cmd));
} }
@ -232,7 +232,7 @@ impl OpenCADStudio {
// block's nested insert keeps its clip. (#xclip-paste) // block's nested insert keeps its clip. (#xclip-paste)
let ext_roots = self.recreate_clipboard_ext_roots(i); let ext_roots = self.recreate_clipboard_ext_roots(i);
let name = self.unique_block_name("Block"); let name = self.unique_block_name("Block");
let base = self.clipboard_centroid; let base = self.clipboard_base;
let mut entities = self.clipboard.clone(); let mut entities = self.clipboard.clone();
for (idx, root) in ext_roots { for (idx, root) in ext_roots {
if let Some(e) = entities.get_mut(idx) { if let Some(e) = entities.get_mut(idx) {
@ -246,7 +246,7 @@ impl OpenCADStudio {
Ok(()) => { Ok(()) => {
// Block defined; now place it interactively so the // Block defined; now place it interactively so the
// user picks the drop point (insertion uses 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. // clipboard wires rubber-band under the cursor.
self.tabs[i].scene.populate_meshes_from_document(); self.tabs[i].scene.populate_meshes_from_document();
self.tabs[i].dirty = true; self.tabs[i].dirty = true;

View file

@ -252,32 +252,31 @@ pub(super) fn polar_constrain_near(
// ── Clipboard / selection helpers ────────────────────────────────────────── // ── Clipboard / selection helpers ──────────────────────────────────────────
/// Cheap copy/paste anchor: the mean of each entity's bounding-box centre. /// Copy/paste anchor: the lower-left corner of the bounding box that encloses
/// Replaces averaging every tessellated wire vertex, which cost O(total /// every copied entity. Standard clipboard behaviour puts this corner under the
/// geometry) and stalled a whole-drawing copy. The anchor only sets which point /// cursor at paste time, so the whole selection drops down-and-right of the pick.
/// of the selection sits under the cursor, so a per-entity bbox centre is an ///
/// equally valid, far cheaper origin. /// Unions each entity's own bounding box (O(entities)) rather than averaging
pub(super) fn entities_centroid_by_bbox( /// 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, doc: &acadrust::CadDocument,
handles: &[acadrust::Handle], handles: &[acadrust::Handle],
) -> glam::DVec3 { ) -> glam::DVec3 {
let mut sum = glam::DVec3::ZERO; let mut min = glam::DVec3::splat(f64::INFINITY);
let mut count = 0usize; let mut any = false;
for &h in handles { for &h in handles {
let Some(e) = doc.get_entity(h) else { continue }; let Some(e) = doc.get_entity(h) else { continue };
let bb = e.as_entity().bounding_box(); let bb = e.as_entity().bounding_box();
let c = glam::DVec3::new( let lo = glam::DVec3::new(bb.min.x, bb.min.y, bb.min.z);
(bb.min.x + bb.max.x) * 0.5, if lo.x.is_finite() && lo.y.is_finite() && lo.z.is_finite() {
(bb.min.y + bb.max.y) * 0.5, min = min.min(lo);
(bb.min.z + bb.max.z) * 0.5, any = true;
);
if c.x.is_finite() && c.y.is_finite() && c.z.is_finite() {
sum += c;
count += 1;
} }
} }
if count > 0 { if any {
sum / count as f64 min
} else { } else {
glam::DVec3::ZERO glam::DVec3::ZERO
} }

View file

@ -500,8 +500,10 @@ pub(super) struct OpenCADStudio {
clipboard: Vec<acadrust::EntityType>, clipboard: Vec<acadrust::EntityType>,
/// Entities removed by the most recent ERASE, kept so OOPS can restore them. /// Entities removed by the most recent ERASE, kept so OOPS can restore them.
oops_cache: Vec<acadrust::EntityType>, oops_cache: Vec<acadrust::EntityType>,
/// Centroid of the clipboard entities (world XY plane). /// Paste anchor: lower-left corner of the clipboard entities' bounding box
clipboard_centroid: glam::DVec3, /// (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 /// Table records (layer / linetype / text + dim style) the clipboard
/// entities reference, captured from the source drawing at copy time so a /// entities reference, captured from the source drawing at copy time so a
/// paste into a *different* drawing can recreate any that are missing — /// paste into a *different* drawing can recreate any that are missing —
@ -2152,7 +2154,7 @@ impl OpenCADStudio {
update_notice_body: None, update_notice_body: None,
clipboard: Vec::new(), clipboard: Vec::new(),
oops_cache: Vec::new(), oops_cache: Vec::new(),
clipboard_centroid: glam::DVec3::ZERO, clipboard_base: glam::DVec3::ZERO,
clipboard_deps: ClipboardDeps::default(), clipboard_deps: ClipboardDeps::default(),
shift_down: false, shift_down: false,
ctrl_down: false, ctrl_down: false,

View file

@ -1,6 +1,6 @@
// COPYBASE — copy the current selection to the clipboard using a base point the // 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 // user picks (rather than the selection's lower-left corner, as COPYCLIP does).
// command only collects the base point; the host performs the copy when it // The command only collects the base point; the host performs the copy when it
// receives the dispatched `COPYBASE_AT <x> <y> <z>` token. // receives the dispatched `COPYBASE_AT <x> <y> <z>` token.
use glam::DVec3; use glam::DVec3;

View file

@ -32,8 +32,9 @@ pub struct PasteCommand {
/// just this 5-point rectangle instead — O(1) per frame. `None` = ghost the /// just this 5-point rectangle instead — O(1) per frame. `None` = ghost the
/// full wires. /// full wires.
bbox_wire: Option<WireModel>, bbox_wire: Option<WireModel>,
/// Centroid of the clipboard entities (offset origin for translation). /// Paste anchor — lower-left corner of the clipboard entities (offset origin
centroid: Vec3, /// for translation; the point that tracks the cursor).
base: Vec3,
} }
impl PasteCommand { impl PasteCommand {
@ -44,17 +45,17 @@ impl PasteCommand {
const MAX_PREVIEW_WIRES: usize = 20_000; const MAX_PREVIEW_WIRES: usize = 20_000;
const MAX_PREVIEW_POINTS: usize = 300_000; const MAX_PREVIEW_POINTS: usize = 300_000;
pub fn new(wires: Vec<WireModel>, centroid: Vec3) -> Self { pub fn new(wires: Vec<WireModel>, base: Vec3) -> Self {
let total_points: usize = wires.iter().map(|w| w.points.len()).sum(); let total_points: usize = wires.iter().map(|w| w.points.len()).sum();
let too_heavy = let too_heavy =
wires.len() > Self::MAX_PREVIEW_WIRES || total_points > Self::MAX_PREVIEW_POINTS; wires.len() > Self::MAX_PREVIEW_WIRES || total_points > Self::MAX_PREVIEW_POINTS;
if too_heavy { if too_heavy {
if let Some(bbox_wire) = Self::bbox_outline(&wires) { if let Some(bbox_wire) = Self::bbox_outline(&wires) {
// Drop the full wires — the box is all the ghost needs now. // 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 /// 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<WireModel> { fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> {
let pt = pt.as_vec3(); 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. // Large clipboard: ghost just the bounding box — O(1) per frame.
if let Some(bbox) = &self.bbox_wire { if let Some(bbox) = &self.bbox_wire {
return vec![bbox.translated(delta)]; return vec![bbox.translated(delta)];