refactor(paste): one finalize path; carry XCLIP through all three pastes

Consolidate the duplicated paste logic and extend XCLIP-carry to every path.

- finalize_paste(): single shared step for PASTECLIP and PASTEORIG —
  merge deps + block defs + xdictionary graphs, add clones (optionally
  transformed), tessellate solids. A new cross-drawing concern is now wired
  once instead of re-implemented per command (the gap that lost the clip on
  PASTEORIG in the first place).
- recreate_clipboard_ext_roots() + recreate_ext_subtree(): PASTEBLOCK now
  recreates each entity's xdictionary graph and stamps the new root onto the
  wrapped entity before defining the block, so the block's nested insert
  keeps its spatial filter.
- block_cache: a nested insert's clip is baked into NestedRef.clip_poly at
  build time (the spatial filter isn't reachable at expand time) and applied
  on expansion — the clipped wires are finalized in an isolated batch set,
  clipped, and carried out via Batches.extra_wires. Single-instance only;
  arrays of clipped nested inserts fall through unclipped.

All three paste paths (PASTECLIP / PASTEORIG / PASTEBLOCK) now preserve an
XCLIP clip across a cross-drawing paste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-24 09:53:28 +03:00
commit ef55b49db5
3 changed files with 166 additions and 86 deletions

View file

@ -620,36 +620,12 @@ impl OpenCADStudio {
base_pt - self.clipboard_centroid.as_dvec3() + wo_corr; base_pt - self.clipboard_centroid.as_dvec3() + wo_corr;
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");
// Recreate any layer / linetype / style the copied entities
// need but this drawing lacks (cross-drawing paste). (#129)
self.merge_clipboard_deps(i);
// …and any block definition the pasted INSERTs reference,
// else a block reference renders empty. (#135)
self.merge_clipboard_blocks(i);
let count = self.clipboard.len(); let count = self.clipboard.len();
// Index-aligned with `clipboard` (NULL = add failed) so the let by_index = self.finalize_paste(i, Some(translate));
// captured extension-dictionary subtrees can be matched back
// to their pasted entity by index.
let by_index: Vec<Handle> = self
.clipboard
.clone()
.into_iter()
.map(|mut entity| {
crate::scene::view::dispatch::apply_transform(&mut entity, &translate);
self.tabs[i].scene.add_entity_clone(entity)
})
.collect();
// Recreate each pasted entity's xdictionary graph (XCLIP
// spatial filters etc.) in this document. (#xclip-paste)
self.merge_clipboard_ext_objects(i, &by_index);
self.tabs[i].scene.deselect_all(); self.tabs[i].scene.deselect_all();
for h in by_index.iter().copied().filter(|h| !h.is_null()) { for h in by_index.iter().copied().filter(|h| !h.is_null()) {
self.tabs[i].scene.select_entity(h, false); self.tabs[i].scene.select_entity(h, false);
} }
// Tessellate any pasted ACIS solids — top-level ones and
// those inside a recreated block definition — so they
// render instead of staying invisible. (#135)
self.tabs[i].scene.populate_meshes_from_document();
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
// Surface any layers the paste brought in (cross-drawing) // Surface any layers the paste brought in (cross-drawing)
// in the layer manager and the layer dropdown. // in the layer manager and the layer dropdown.
@ -1872,6 +1848,36 @@ impl OpenCADStudio {
} }
} }
/// Shared paste finalize for every paste path (PASTECLIP, PASTEORIG):
/// recreate the clipboard's dependency records and block definitions, add
/// each entity with fresh handles (optionally transformed), recreate each
/// entity's xdictionary graph (XCLIP filters etc.), and tessellate pasted
/// solids. Returns the new handles, index-aligned with the clipboard
/// (NULL where an add failed). Keeping this in one place means a new
/// cross-drawing concern is wired once, not re-implemented per command.
pub(super) fn finalize_paste(
&mut self,
i: usize,
translate: Option<crate::command::EntityTransform>,
) -> Vec<Handle> {
self.merge_clipboard_deps(i);
self.merge_clipboard_blocks(i);
let by_index: Vec<Handle> = self
.clipboard
.clone()
.into_iter()
.map(|mut entity| {
if let Some(t) = &translate {
crate::scene::view::dispatch::apply_transform(&mut entity, t);
}
self.tabs[i].scene.add_entity_clone(entity)
})
.collect();
self.merge_clipboard_ext_objects(i, &by_index);
self.tabs[i].scene.populate_meshes_from_document();
by_index
}
/// Recreate the extension-dictionary object graph (XCLIP spatial filters, /// Recreate the extension-dictionary object graph (XCLIP spatial filters,
/// attached XRecords, …) captured for each copied entity, cloning every /// attached XRecords, …) captured for each copied entity, cloning every
/// object into this document with fresh handles, remapping all internal /// object into this document with fresh handles, remapping all internal
@ -1879,37 +1885,19 @@ impl OpenCADStudio {
/// the new root. `by_index` is the paste's new entity handles, aligned with /// the new root. `by_index` is the paste's new entity handles, aligned with
/// the clipboard order (NULL where the add failed). No-op without captures. /// the clipboard order (NULL where the add failed). No-op without captures.
pub(super) fn merge_clipboard_ext_objects(&mut self, i: usize, by_index: &[Handle]) { pub(super) fn merge_clipboard_ext_objects(&mut self, i: usize, by_index: &[Handle]) {
use std::collections::HashMap;
if self.clipboard_deps.ext_objects.is_empty() { if self.clipboard_deps.ext_objects.is_empty() {
return; return;
} }
let captures = self.clipboard_deps.ext_objects.clone(); let captures = self.clipboard_deps.ext_objects.clone();
let doc = &mut self.tabs[i].scene.document; let doc = &mut self.tabs[i].scene.document;
for cap in captures { for cap in &captures {
let Some(&new_entity) = by_index.get(cap.entity_index) else { let Some(&new_entity) = by_index.get(cap.entity_index) else {
continue; continue;
}; };
if new_entity.is_null() { if new_entity.is_null() {
continue; continue;
} }
// old handle → fresh handle for the whole subtree, plus the entity if let Some(new_root) = recreate_ext_subtree(doc, cap, Some(new_entity)) {
// itself so an object owned by the entity remaps onto the new copy.
let mut remap: HashMap<Handle, Handle> = HashMap::new();
remap.insert(cap.src_entity_handle, new_entity);
for (old, _) in &cap.objects {
// `allocate_handle` advances the counter; `next_handle()` only
// peeks, so reusing it here would hand every object the same
// handle and they'd overwrite each other in `doc.objects`.
remap.insert(*old, doc.allocate_handle());
}
for (old, obj) in &cap.objects {
let mut obj = obj.clone();
let new_h = remap[old];
remap_object(&mut obj, new_h, &remap);
doc.objects.insert(new_h, obj);
}
// Point the pasted entity at the cloned root dictionary.
if let Some(new_root) = remap.get(&cap.root).copied() {
if let Some(e) = doc.get_entity_mut(new_entity) { if let Some(e) = doc.get_entity_mut(new_entity) {
e.common_mut().xdictionary_handle = Some(new_root); e.common_mut().xdictionary_handle = Some(new_root);
} }
@ -1920,6 +1908,30 @@ impl OpenCADStudio {
self.tabs[i].scene.bump_geometry(); self.tabs[i].scene.bump_geometry();
} }
/// Recreate the captured xdictionary subtrees in this document (fresh
/// handles, remapped references) WITHOUT an added host entity, returning
/// `entity_index → new xdictionary root`. Used by PASTEBLOCK, which folds
/// the clipboard into a new block definition: the caller stamps each new
/// root onto the matching entity's `xdictionary_handle` before defining the
/// block, so the block's nested insert keeps its XCLIP filter.
pub(super) fn recreate_clipboard_ext_roots(
&mut self,
i: usize,
) -> std::collections::HashMap<usize, Handle> {
let mut out = std::collections::HashMap::new();
if self.clipboard_deps.ext_objects.is_empty() {
return out;
}
let captures = self.clipboard_deps.ext_objects.clone();
let doc = &mut self.tabs[i].scene.document;
for cap in &captures {
if let Some(new_root) = recreate_ext_subtree(doc, cap, None) {
out.insert(cap.entity_index, new_root);
}
}
out
}
fn restore_pre_cmd_tangent(&mut self) { fn restore_pre_cmd_tangent(&mut self) {
if let Some(was_on) = self.pre_cmd_tangent.take() { if let Some(was_on) = self.pre_cmd_tangent.take() {
if !was_on { if !was_on {
@ -1934,6 +1946,33 @@ impl OpenCADStudio {
} }
} }
/// Clone one captured xdictionary subtree into `doc` with fresh handles,
/// remapping every internal reference (and the owning entity, when known),
/// returning the new root handle. `allocate_handle` advances the document's
/// handle counter — `next_handle()` only peeks, so reusing it would hand every
/// object the same handle and collapse the dictionary chain.
fn recreate_ext_subtree(
doc: &mut acadrust::CadDocument,
cap: &crate::app::ClipExtObjects,
entity_handle: Option<Handle>,
) -> Option<Handle> {
use std::collections::HashMap;
let mut remap: HashMap<Handle, Handle> = HashMap::new();
if let Some(eh) = entity_handle {
remap.insert(cap.src_entity_handle, eh);
}
for (old, _) in &cap.objects {
remap.insert(*old, doc.allocate_handle());
}
for (old, obj) in &cap.objects {
let mut obj = obj.clone();
let new_h = remap[old];
remap_object(&mut obj, new_h, &remap);
doc.objects.insert(new_h, obj);
}
remap.get(&cap.root).copied()
}
/// Rewrite a cloned extension-dictionary object onto fresh handles: set its own /// Rewrite a cloned extension-dictionary object onto fresh handles: set its own
/// handle to `new_handle` and remap its owner and any handle references it holds /// handle to `new_handle` and remap its owner and any handle references it holds
/// through `remap` (a handle still in the source space stays unchanged, which is /// through `remap` (a handle still in the source space stays unchanged, which is

View file

@ -623,27 +623,8 @@ impl OpenCADStudio {
} else { } else {
let count = self.clipboard.len(); let count = self.clipboard.len();
self.push_undo_snapshot(i, "PASTEORIG"); self.push_undo_snapshot(i, "PASTEORIG");
// Recreate any layer / style this drawing lacks, then add // No transform: entities keep their original coordinates.
// each entity with fresh handles (top-level + inline subs) let _ = self.finalize_paste(i, None);
// so a same-document paste can't duplicate handles. (#129)
self.merge_clipboard_deps(i);
// …and any block definition the pasted INSERTs reference,
// else a block reference renders empty in a drawing that
// lacks the definition. (#135 / #158)
self.merge_clipboard_blocks(i);
let by_index: Vec<acadrust::Handle> = self
.clipboard
.clone()
.into_iter()
.map(|entity| self.tabs[i].scene.add_entity_clone(entity))
.collect();
// Recreate each pasted entity's xdictionary graph (XCLIP
// spatial filters etc.) so a cross-drawing paste keeps its
// clip instead of showing the whole block. (#xclip-paste)
self.merge_clipboard_ext_objects(i, &by_index);
// Tessellate any pasted ACIS solids (top-level + inside a
// recreated block) so they render instead of staying blank.
self.tabs[i].scene.populate_meshes_from_document();
self.tabs[i].dirty = true; self.tabs[i].dirty = true;
self.refresh_layer_panel(); self.refresh_layer_panel();
self.refresh_properties(); self.refresh_properties();
@ -667,9 +648,18 @@ impl OpenCADStudio {
// reference, so nested blocks inside the new wrapper block // reference, so nested blocks inside the new wrapper block
// don't render empty. (#135 / #158) // don't render empty. (#135 / #158)
self.merge_clipboard_blocks(i); self.merge_clipboard_blocks(i);
// Recreate each entity's xdictionary graph (XCLIP filters)
// and stamp the new root onto the wrapped entity, so the
// block's nested insert keeps its clip. (#xclip-paste)
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_centroid;
let entities = self.clipboard.clone(); let mut entities = self.clipboard.clone();
for (idx, root) in ext_roots {
if let Some(e) = entities.get_mut(idx) {
e.common_mut().xdictionary_handle = Some(root);
}
}
match self match self
.tabs[i] .tabs[i]
.scene .scene

View file

@ -82,6 +82,12 @@ pub struct NestedRef {
pub lt_is_byblock: bool, pub lt_is_byblock: bool,
pub lw_is_byblock: bool, pub lw_is_byblock: bool,
pub instance_offsets: Vec<[f64; 3]>, pub instance_offsets: Vec<[f64; 3]>,
/// XCLIP boundary for this nested insert, in the parent defn's local frame
/// (`None` = unclipped). Baked at build time because the clip's spatial
/// filter lives in `doc.objects`, which isn't reachable at expand time; on
/// expansion it is mapped to world by the accumulated transform and the
/// nested wires are clipped to it.
pub clip_poly: Option<Vec<[f64; 2]>>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -413,6 +419,12 @@ fn build_nested_ref(
crate::scene::view::render::render_style_for(doc, &EntityType::Insert(nested_ins.clone())); crate::scene::view::render::render_style_for(doc, &EntityType::Insert(nested_ins.clone()));
let _ = bg_color; let _ = bg_color;
// Bake the XCLIP boundary (parent-defn-local) so the nested insert keeps
// its clip when the parent block is expanded — the spatial filter object
// isn't reachable at expand time.
let clip_poly = crate::scene::pick::xclip::insert_spatial_filter(doc, nested_ins)
.map(|sf| crate::scene::pick::xclip::world_clip_polygon_f64(sf, nested_ins, [0.0; 3]));
NestedRef { NestedRef {
block_name: nested_ins.block_name.clone(), block_name: nested_ins.block_name.clone(),
xform: nested_ins.get_transform(), xform: nested_ins.get_transform(),
@ -424,6 +436,7 @@ fn build_nested_ref(
lt_is_byblock: nested_ins.common.linetype.eq_ignore_ascii_case("byblock"), lt_is_byblock: nested_ins.common.linetype.eq_ignore_ascii_case("byblock"),
lw_is_byblock: matches!(nested_ins.common.line_weight, LineWeight::ByBlock), lw_is_byblock: matches!(nested_ins.common.line_weight, LineWeight::ByBlock),
instance_offsets: array_offsets(nested_ins), instance_offsets: array_offsets(nested_ins),
clip_poly,
} }
} }
@ -1063,6 +1076,11 @@ struct Batches {
by_style: HashMap<StyleKey, BatchEntry>, by_style: HashMap<StyleKey, BatchEntry>,
/// Batches that overflowed `MAX_POINTS_PER_BATCH` and have been closed. /// Batches that overflowed `MAX_POINTS_PER_BATCH` and have been closed.
closed: Vec<BatchEntry>, closed: Vec<BatchEntry>,
/// Already-finalized wires that bypass the point batcher — currently the
/// clipped output of an XCLIP'd nested insert, which is produced as whole
/// WireModels by `clip_wires`. Appended verbatim at `finalize` (only their
/// name/selected flag are stamped to match the host insert).
extra_wires: Vec<WireModel>,
} }
impl BatchEntry { impl BatchEntry {
@ -1098,7 +1116,9 @@ impl BatchEntry {
impl Batches { impl Batches {
fn finalize(self, name: &str, selected: bool, bg_color: [f32; 4]) -> Vec<WireModel> { fn finalize(self, name: &str, selected: bool, bg_color: [f32; 4]) -> Vec<WireModel> {
self.closed let extra = self.extra_wires;
let mut out: Vec<WireModel> = self
.closed
.into_iter() .into_iter()
.chain(self.by_style.into_values()) .chain(self.by_style.into_values())
.map(|b| { .map(|b| {
@ -1133,7 +1153,15 @@ impl Batches {
fill_tris_low: Vec::new(), fill_tris_low: Vec::new(),
} }
}) })
.collect() .collect();
// Clipped nested-insert wires are already whole WireModels; stamp the
// host insert's name/selected so picking maps them back correctly.
for mut w in extra {
w.name = name.to_string();
w.selected = selected;
out.push(w);
}
out
} }
} }
@ -1302,23 +1330,46 @@ fn expand_defn(
bg_color: ctx.bg_color, bg_color: ctx.bg_color,
}; };
visited.push(nref.block_name.clone()); visited.push(nref.block_name.clone());
for offset in &nref.instance_offsets { if let Some(cp) = &nref.clip_poly {
let composed = if offset == &[0.0; 3] { // XCLIP'd nested insert: expand into an isolated batch set,
nref.xform.then(accum_xform) // finalize to whole wires, clip them to the boundary mapped
} else { // into world by the accumulated transform, then carry the
let translation = Transform::from_translation(Vector3::new( // clipped wires out via `extra_wires`. Single instance only —
offset[0], offset[1], offset[2], // an array of clipped nested inserts is vanishingly rare, so
)); // the array case is not handled (boundary placement per
translation.then(&nref.xform).then(accum_xform) // instance would differ).
}; let composed = nref.xform.then(accum_xform);
expand_defn( let mut sub = Batches::default();
nested_defn, expand_defn(nested_defn, &composed, &inner_ctx, &mut sub, visited, depth + 1);
&composed, let mut wires = sub.finalize("", ctx.selected, ctx.bg_color);
&inner_ctx, let world_poly: Vec<[f64; 2]> = cp
out, .iter()
visited, .map(|&[x, y]| {
depth + 1, let w = accum_xform.apply(Vector3::new(x, y, 0.0));
); [w.x, w.y]
})
.collect();
crate::scene::pick::xclip::clip_wires(&mut wires, &world_poly);
out.extra_wires.append(&mut wires);
} else {
for offset in &nref.instance_offsets {
let composed = if offset == &[0.0; 3] {
nref.xform.then(accum_xform)
} else {
let translation = Transform::from_translation(Vector3::new(
offset[0], offset[1], offset[2],
));
translation.then(&nref.xform).then(accum_xform)
};
expand_defn(
nested_defn,
&composed,
&inner_ctx,
out,
visited,
depth + 1,
);
}
} }
visited.pop(); visited.pop();
} }