feat(clipboard): paste menu with paste-to-original and paste-as-block

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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-18 16:09:47 +03:00
commit 47f74a9b98
7 changed files with 183 additions and 10 deletions

View file

@ -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;

View file

@ -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<Message> {
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

View file

@ -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 {

View file

@ -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"] });

View file

@ -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(),
],

View file

@ -37,6 +37,10 @@ pub struct InsertBlockCommand {
step: Step,
/// Pending Insert entity stored while attr-filling is in progress.
pending_insert: Option<Insert>,
/// 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<WireModel>, 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<WireModel>, 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<WireModel> {
vec![]
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
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)>) {

View file

@ -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<EntityType>,
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<HatchModel> {
let layout_block = self.current_layout_block_handle();
let hatch_offset = if self.current_layout == "Model" {