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

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