From 47f74a9b98bd7d5c482cdcbeacd9875584ef2537 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 18 Jun 2026 16:09:47 +0300 Subject: [PATCH] feat(clipboard): paste menu with paste-to-original and paste-as-block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the ribbon Clipboard "Paste" button into a dropdown: - Paste — existing pick-a-point paste. - Paste to Original Coordinates — drops the clipboard at its source coordinates with no prompt. - Paste as Block — wraps the clipboard contents in a new block definition and starts an interactive insert (prompts for the drop point, with the geometry rubber-banding under the cursor). Also fixes two bugs the new commands exposed: - entities_centroid summed the NaN separator points wire models carry, so the paste base point came out NaN; non-finite points are now skipped. A NaN base previously froze block tessellation outright. - PASTEORIG added clipboard entities with their original handles (duplicates on same-document paste); it now goes through add_entity_clone and merges referenced records like the other pastes. Co-Authored-By: Claude Opus 4.8 --- src/app/cmd_result.rs | 2 +- src/app/commands.rs | 59 ++++++++++++++++++++++-- src/app/helpers.rs | 10 +++- src/modules/draw/clipboard/paste.rs | 15 +++++- src/modules/draw/mod.rs | 8 +++- src/modules/insert/insert_block.rs | 28 +++++++++++- src/scene/mod.rs | 71 +++++++++++++++++++++++++++++ 7 files changed, 183 insertions(+), 10 deletions(-) diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 60573c8a9..b65aecbc5 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -1682,7 +1682,7 @@ impl OpenCADStudio { /// this drawing doesn't already have. Each recreated record gets a fresh /// handle from the target document so it can't collide with an existing /// one. No-op for same-document pastes (the records already exist). (#129) - fn merge_clipboard_deps(&mut self, i: usize) { + pub(super) fn merge_clipboard_deps(&mut self, i: usize) { use acadrust::TableEntry; if self.clipboard_deps.is_empty() { return; diff --git a/src/app/commands.rs b/src/app/commands.rs index 29fc710d9..55e301bb7 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -5,6 +5,20 @@ use iced::Task; use std::path::PathBuf; impl OpenCADStudio { + /// First `"{prefix}{n}"` (n ≥ 1) not already used by a block record in the + /// active drawing. Used to auto-name a paste-as-block definition. + fn unique_block_name(&self, prefix: &str) -> String { + let i = self.active_tab; + let mut n = 1; + loop { + let name = format!("{prefix}{n}"); + if self.tabs[i].scene.document.block_records.get(&name).is_none() { + return name; + } + n += 1; + } + } + pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task { let i = self.active_tab; // Starting a command closes any open ribbon dropdown (e.g. a style @@ -586,7 +600,7 @@ impl OpenCADStudio { } } - // PASTEORIG — paste at original coordinates (no move to pick point) + // PASTEORIG — paste at the entities' original coordinates (no pick). "PASTEORIG" => { if self.clipboard.is_empty() { self.command_line @@ -594,10 +608,15 @@ impl OpenCADStudio { } else { let count = self.clipboard.len(); self.push_undo_snapshot(i, "PASTEORIG"); - for entity in &self.clipboard { - self.tabs[i].scene.add_entity(entity.clone()); + // Recreate any layer / style this drawing lacks, then add + // each entity with fresh handles (top-level + inline subs) + // so a same-document paste can't duplicate handles. (#129) + self.merge_clipboard_deps(i); + for entity in self.clipboard.clone() { + self.tabs[i].scene.add_entity_clone(entity); } self.tabs[i].dirty = true; + self.refresh_properties(); self.command_line.push_output(&format!( "PASTEORIG: {} object(s) pasted at original coordinates.", count @@ -605,6 +624,40 @@ impl OpenCADStudio { } } + // PASTEBLOCK — wrap the clipboard contents in a new block definition + // and place one insert of it at the clipboard's original location. + "PASTEBLOCK" => { + if self.clipboard.is_empty() { + self.command_line + .push_error("PASTEBLOCK: clipboard is empty."); + } else { + self.push_undo_snapshot(i, "PASTEBLOCK"); + self.merge_clipboard_deps(i); + let name = self.unique_block_name("Block"); + let base = self.clipboard_centroid; + let entities = self.clipboard.clone(); + match self + .tabs[i] + .scene + .define_block_from_owned_entities(entities, &name, base) + { + 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 wires rubber-band under the cursor. + self.tabs[i].dirty = true; + let wires = self.tabs[i].scene.wires_for_entities(&self.clipboard); + use crate::modules::insert::insert_block::InsertBlockCommand; + let cmd = InsertBlockCommand::new_for_block(name, wires, base); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + Err(e) => self.command_line.push_error(&format!("PASTEBLOCK: {e}")), + } + } + } + "BLOCK" => { let handles: Vec<_> = self.tabs[i] .scene diff --git a/src/app/helpers.rs b/src/app/helpers.rs index b914fe365..b86dad5d6 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -186,8 +186,14 @@ pub(super) fn entities_centroid(wires: &[WireModel]) -> glam::Vec3 { let mut count = 0usize; for w in wires { for p in &w.points { - sum += glam::Vec3::from(*p); - count += 1; + let v = glam::Vec3::from(*p); + // Wire models carry NaN points as separators between disjoint + // segments; summing them poisons the whole centroid into NaN, + // which then makes every paste base point NaN. (#129) + if v.is_finite() { + sum += v; + count += 1; + } } } if count > 0 { diff --git a/src/modules/draw/clipboard/paste.rs b/src/modules/draw/clipboard/paste.rs index 25e1b612a..669ab42f6 100644 --- a/src/modules/draw/clipboard/paste.rs +++ b/src/modules/draw/clipboard/paste.rs @@ -2,11 +2,22 @@ use crate::modules::{IconKind, ModuleEvent, ToolDef}; +/// Shared icon for the Paste button and its dropdown entries. +pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../../assets/icons/paste.svg")); + +/// Paste-menu entries: (command id, label, icon). The first is the default +/// fired by clicking the button body; the rest open from the ▾. +pub const MENU_ITEMS: &[(&str, &str, IconKind)] = &[ + ("PASTECLIP", "Paste", ICON), + ("PASTEORIG", "Paste to Original Coordinates", ICON), + ("PASTEBLOCK", "Paste as Block", ICON), +]; + pub fn tool() -> ToolDef { ToolDef { id: "PASTECLIP", label: "Paste", - icon: IconKind::Svg(include_bytes!("../../../../assets/icons/paste.svg")), + icon: ICON, event: ModuleEvent::Command("PASTECLIP".to_string()), } } @@ -62,3 +73,5 @@ impl CadCommand for PasteCommand { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["PASTECLIP", "PC"] }); // PasteCommand +inventory::submit!(crate::command::CommandRegistration { names: &["PASTEORIG"] }); +inventory::submit!(crate::command::CommandRegistration { names: &["PASTEBLOCK"] }); diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index 76fc7f872..c3004f9ea 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -214,7 +214,13 @@ impl CadModule for DrawModule { RibbonGroup { title: "Clipboard", tools: vec![ - RibbonItem::LargeTool(paste::tool()), + RibbonItem::LargeDropdown { + id: "PASTE_MENU", + label: "Paste", + icon: paste::ICON, + items: paste::MENU_ITEMS.to_vec(), + default: "PASTECLIP", + }, copy_clip::tool().into(), cut::tool().into(), ], diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index 4ed006500..385efc907 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -37,6 +37,10 @@ pub struct InsertBlockCommand { step: Step, /// Pending Insert entity stored while attr-filling is in progress. pending_insert: Option, + /// Optional drag preview: the block's wire geometry plus the base point it + /// is measured from, so `on_preview_wires` can rubber-band it to the + /// cursor. Set by paste-as-block; empty for a plain INSERT. + preview: Option<(Vec, Vec3)>, } impl InsertBlockCommand { @@ -45,6 +49,20 @@ impl InsertBlockCommand { available, step: Step::Name, pending_insert: None, + preview: None, + } + } + + /// Start the command already locked to `name`, skipping the name prompt and + /// going straight to "specify insertion point". `preview_wires` (measured + /// from `base`) rubber-band under the cursor. Used by paste-as-block, which + /// has just defined the block and only needs the drop point. + pub fn new_for_block(name: String, preview_wires: Vec, base: Vec3) -> Self { + Self { + available: vec![name.clone()], + step: Step::Point { name }, + pending_insert: None, + preview: Some((preview_wires, base)), } } } @@ -138,8 +156,14 @@ impl CadCommand for InsertBlockCommand { } } - fn on_preview_wires(&mut self, _pt: Vec3) -> Vec { - vec![] + fn on_preview_wires(&mut self, pt: Vec3) -> Vec { + match (&self.step, &self.preview) { + (Step::Point { .. }, Some((wires, base))) => { + let delta = pt - *base; + wires.iter().map(|w| w.translated(delta)).collect() + } + _ => vec![], + } } fn attreq_set_attdefs(&mut self, attdefs: Vec<(String, String, String)>) { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0ed2ac9b7..6db982f41 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3588,6 +3588,77 @@ impl Scene { Ok(self.add_entity(EntityType::Insert(insert))) } + /// Define a new block named `name` from `entities` (owned, not yet in the + /// document), with `base` as its insertion origin. Unlike + /// [`create_block_from_entities`] this does NOT place an insert — the + /// caller starts an interactive insert so paste-as-block can prompt for the + /// drop point. The geometry comes from the clipboard rather than live + /// entities, so there is nothing to stage or erase. (#129) + pub fn define_block_from_owned_entities( + &mut self, + entities: Vec, + name: &str, + base: glam::Vec3, + ) -> Result<(), String> { + let name = name.trim(); + if name.is_empty() { + return Err("Block name cannot be empty.".into()); + } + if name.starts_with('*') { + return Err("Block name cannot start with '*'.".into()); + } + if self.document.block_records.get(name).is_some() { + return Err(format!("Block \"{name}\" already exists.")); + } + if entities.is_empty() { + return Err("Nothing to make into a block.".into()); + } + + let next = self.document.next_handle(); + let br_handle = Handle::new(next); + let block_handle = Handle::new(next + 1); + let end_handle = Handle::new(next + 2); + + let mut block_record = acadrust::tables::BlockRecord::new(name); + block_record.handle = br_handle; + block_record.block_entity_handle = block_handle; + block_record.block_end_handle = end_handle; + self.document + .block_records + .add(block_record) + .map_err(|e| e.to_string())?; + + let mut block = Block::new(name, acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = br_handle; + self.document + .add_entity(EntityType::Block(block)) + .map_err(|e| e.to_string())?; + + let mut block_end = BlockEnd::new(); + block_end.common.handle = end_handle; + block_end.common.owner_handle = br_handle; + self.document + .add_entity(EntityType::BlockEnd(block_end)) + .map_err(|e| e.to_string())?; + + let local = EntityTransform::Translate(-base); + for mut entity in entities { + view::dispatch::apply_transform(&mut entity, &local); + entity = crate::modules::draw::modify::explode::normalize_entity_for_block(entity); + Self::reset_clone_subhandles(&mut self.document, &mut entity); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = br_handle; + self.document + .add_entity(entity) + .map_err(|e| e.to_string())?; + } + // Block defns don't render on their own, but the geometry cache must + // pick up the new definition so the interactive insert can preview it. + self.bump_geometry(); + Ok(()) + } + fn synced_hatch_models(&self) -> Vec { let layout_block = self.current_layout_block_handle(); let hatch_offset = if self.current_layout == "Model" {