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)) =