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:
parent
446430d2b3
commit
47f74a9b98
7 changed files with 183 additions and 10 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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)>) {
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
Loading…
Reference in a new issue