From f1a348260ffd90905b99cecaeb70d561920aa43d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 23 Jul 2026 15:09:06 +0300 Subject: [PATCH] fix(group): preserve groups when pasting, cross-drawing too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #446 kept a fully-copied group grouped for the in-drawing COPY/ARRAY path, but clipboard paste (Ctrl+C / Ctrl+V, PASTECLIP) runs through finalize_paste, a separate route that never touched groups — so pasted copies came out ungrouped, and a cross-drawing paste had no group data at all. Unify all copy routes on one shared Scene::recreate_groups(sources, handle_map): copy_complete_groups now gathers the live source groups and delegates to it, while paste snapshots the complete groups into the clipboard at copy time (ClipboardDeps.groups) and recreates them over the pasted handles. A group whose whole membership is copied now stays grouped whether the copy lands in the same drawing, via the clipboard, or in a different file; partially-selected groups still stay ungrouped. Closes #440 Co-Authored-By: Claude Opus 4.8 --- src/app/command_driver.rs | 16 +++++++++++++++ src/app/mod.rs | 26 ++++++++++++++++++++++++ src/scene/group_layer.rs | 42 +++++++++++++++++++++++++++++++-------- 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 08f2c818d..1b337df50 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -2492,6 +2492,22 @@ impl OpenCADStudio { }) .collect(); self.merge_clipboard_ext_objects(i, &by_index); + // Recreate any group whose whole membership was copied, so a pasted + // group stays grouped — cross-drawing too, since the groups were + // snapshotted into the clipboard at copy time. `by_index` is aligned + // with `self.clipboard`, so the source handle of each pasted entity maps + // its clipboard clone to its new handle. Same shared `recreate_groups` + // the in-drawing COPY path uses. (#440) + if !self.clipboard_deps.groups.is_empty() { + let mut handle_map = rustc_hash::FxHashMap::default(); + for (src, &new) in self.clipboard.iter().zip(by_index.iter()) { + if !new.is_null() { + handle_map.insert(src.common().handle, new); + } + } + let groups = self.clipboard_deps.groups.clone(); + self.tabs[i].scene.recreate_groups(groups, &handle_map); + } // Incremental: `add_entity` already tessellated every pasted top-level // solid, and existing document solids are still cached — so only newly // introduced block-definition solids need building. The full rebuild diff --git a/src/app/mod.rs b/src/app/mod.rs index d366dd001..a9eeae223 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -886,6 +886,12 @@ pub struct ClipboardDeps { /// `xdictionary` graph is snapshotted so a cross-drawing paste recreates it /// — without this a pasted clipped block loses its clip and renders whole. pub ext_objects: Vec, + /// Groups whose *entire* membership is in the copied selection, snapshotted + /// from the source drawing. On paste each is recreated over the pasted + /// handles so a copied group stays grouped — cross-drawing too, the same way + /// the in-drawing COPY path preserves it. Partly-selected groups are not + /// captured (copying one member should not spawn a fragment). See #440. + pub groups: Vec, } /// A captured block definition: its base point and the entities it owns @@ -1001,6 +1007,25 @@ impl ClipboardDeps { .filter_map(|n| Self::snapshot_block(doc, n)) .collect(); + // Groups fully contained in the top-level selection. Groups reference + // top-level entities (not block internals), so match against `entities` + // only. Mirrors the in-drawing `copy_complete_groups` membership test. + let copied_handles: rustc_hash::FxHashSet = + entities.iter().map(|e| e.common().handle).collect(); + let groups: Vec = doc + .objects + .values() + .filter_map(|obj| match obj { + acadrust::objects::ObjectType::Group(g) + if !g.entities.is_empty() + && g.entities.iter().all(|h| copied_handles.contains(h)) => + { + Some(g.clone()) + } + _ => None, + }) + .collect(); + ClipboardDeps { layers: layers .iter() @@ -1021,6 +1046,7 @@ impl ClipboardDeps { blocks, dim_blocks, ext_objects, + groups, } } diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index 3e7d5f161..58b5a514b 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -36,12 +36,18 @@ impl Scene { gh } - /// Recreate every group whose full membership was copied. + /// Recreate every *live* group whose full membership was copied. /// /// Partial group copies intentionally remain ungrouped: copying one member /// out of a group should not create a new one-member fragment. When the /// whole source group is in `handle_map`, the new handles get their own /// Group object so later selection/editing treats the copy as a group too. + /// + /// The in-drawing COPY / ARRAY path: the source groups still live in this + /// document, so gather them here and hand them to [`Scene::recreate_groups`]. + /// The clipboard paste path snapshots its groups instead (they may come from + /// another drawing) and calls `recreate_groups` directly — one shared body + /// so all copy routes preserve groups identically. pub fn copy_complete_groups( &mut self, handle_map: &rustc_hash::FxHashMap, @@ -49,8 +55,6 @@ impl Scene { if handle_map.is_empty() { return 0; } - - let group_dict_handle = self.document.header.acad_group_dict_handle; let sources: Vec<_> = self .document .objects @@ -65,19 +69,41 @@ impl Scene { _ => None, }) .collect(); + self.recreate_groups(sources, handle_map) + } + /// Recreate each group in `sources` in this document, remapping its member + /// handles through `handle_map` (source → new). Each recreated group gets a + /// fresh handle, a unique `NAME_COPYn`, and a group-dictionary entry. + /// Members absent from the map are dropped; a group left with no members is + /// skipped. + /// + /// Shared by the in-drawing COPY/ARRAY path ([`Scene::copy_complete_groups`], + /// live source groups) and the clipboard paste path (groups snapshotted into + /// the clipboard at copy time), so a fully-copied group stays grouped whether + /// the copy lands in the same drawing or a different file. + pub fn recreate_groups( + &mut self, + sources: Vec, + handle_map: &rustc_hash::FxHashMap, + ) -> usize { + let group_dict_handle = self.document.header.acad_group_dict_handle; let mut copied = 0; for source in sources { + let entities: Vec = source + .entities + .iter() + .filter_map(|h| handle_map.get(h).copied()) + .collect(); + if entities.is_empty() { + continue; + } let name = self.unique_group_copy_name(&source.name); let mut group = source; group.handle = self.document.allocate_handle(); group.owner = group_dict_handle; group.name = name.clone(); - group.entities = group - .entities - .iter() - .filter_map(|h| handle_map.get(h).copied()) - .collect(); + group.entities = entities; let gh = group.handle; self.document.objects.insert(gh, ObjectType::Group(group)); if let Some(ObjectType::Dictionary(dict)) =