From bc1fb2a88d92fff417b5044f9cb19dcf97f7b7db Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Mon, 20 Jul 2026 02:50:14 +0300 Subject: [PATCH] Bonsai: add one click copy of annotations to another drawing (#8719) * Bonsai: move annotations between drawings when reassigning their group Assigning an IfcAnnotation to a group that represents another drawing previously left the annotation in both drawings at once: it stayed in its old drawing group, its Blender object stayed in the old drawing collection, and it kept the old camera depth, so the reassignment appeared to do nothing useful. Issue #2966 documents the seven step manual workaround users needed instead. The assign group operator now detects when the target group represents a drawing (via the new tool.Drawing.get_group_drawing, the inverse of get_drawing_group), unassigns the annotation from its previous drawing group, moves its object into the new drawing collection, and places it on the new drawing camera plane. The target camera is imported on demand when it has not been loaded yet, matching the pattern used by the activate drawing operator. Generated with the assistance of an AI coding tool. * Bonsai: add one click copy of annotations to another drawing (#2966) Duplicating an annotation into a different drawing used to require a seven step manual process: loading groups in scene properties, copying the object, fixing its group assignment by hand, and repositioning it onto the target camera plane. A plain Blender duplicate is not enough because the copy keeps pointing at the same IFC entity, and the Shift D override, while it does create a genuine new entity through root.copy_class, leaves the duplicate in the source drawing group, collection, and camera depth. The new copy annotation to drawing operator packages the proven recipe already used by duplicate drawing into one action: duplicate through tool.Geometry.duplicate_ifc_objects, unassign the copy from the source drawing group, assign it to the chosen target group, place it on the target camera plane at the same world XY, and file it into the target drawing collection. The originals are left untouched and the user's selection is restored. The target camera is imported on demand when it has not been loaded yet. The operator shows a target drawing dropdown and is reachable from the annotation tool sidebar when an annotation is selected, and from the drawings panel. Annotations already in the target drawing are skipped and reported. The orchestration lives in core.drawing.copy_annotations_to_drawing with prophecy tests covering the copy, the skip, and the camera import branches. Verified live in headless Blender 5.1: the copy is a new IfcAnnotation with its own GlobalId and IfcTextLiteral, both texts are editable independently, and everything survives save and reload with each annotation loading in its own drawing. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/drawing/__init__.py | 1 + .../bonsai/bim/module/drawing/operator.py | 84 +++++++++++++++++++ src/bonsai/bonsai/bim/module/drawing/ui.py | 2 + .../bonsai/bim/module/drawing/workspace.py | 3 + .../bonsai/bim/module/group/operator.py | 40 ++++++++- src/bonsai/bonsai/core/drawing.py | 31 +++++++ src/bonsai/bonsai/core/tool.py | 4 + src/bonsai/bonsai/tool/drawing.py | 11 +++ src/bonsai/test/core/test_drawing.py | 53 ++++++++++++ src/bonsai/test/tool/test_drawing.py | 19 +++++ 10 files changed, 247 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index d4245cb1db..12b63d9372 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -47,6 +47,7 @@ classes = ( operator.CleanWireframes, operator.ContractSheet, operator.ConvertSVGToDXF, + operator.CopyAnnotationToDrawing, operator.CopyTextToSelection, operator.CreateDrawing, operator.CreateSheets, diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 362015d67b..35d93db7d2 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -228,6 +228,90 @@ class DuplicateDrawing(bpy.types.Operator, tool.Ifc.Operator): ) +def get_copy_annotation_target_drawings(self, context): + global COPY_ANNOTATION_TARGET_DRAWINGS_ENUM + drawings = [e for e in tool.Ifc.get().by_type("IfcAnnotation") if e.ObjectType == "DRAWING"] + drawings.sort(key=lambda d: d.Name or "") + COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = [(str(d.id()), d.Name or "Unnamed", "") for d in drawings] + return COPY_ANNOTATION_TARGET_DRAWINGS_ENUM + + +COPY_ANNOTATION_TARGET_DRAWINGS_ENUM = [] + + +class CopyAnnotationToDrawing(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.copy_annotation_to_drawing" + bl_label = "Copy Annotation To Drawing" + bl_description = ( + "Copy the selected annotations to another drawing.\n\n" + "The copies become independent annotations assigned to the chosen drawing, " + "placed in its view plane. The originals stay in their current drawing" + ) + bl_options = {"REGISTER", "UNDO"} + target_drawing: bpy.props.EnumProperty(name="Target Drawing", items=get_copy_annotation_target_drawings) + + if TYPE_CHECKING: + target_drawing: str + + @classmethod + def poll(cls, context): + if not tool.Ifc.get(): + cls.poll_message_set("No IFC project loaded.") + return False + if not cls.get_selected_annotations(context): + cls.poll_message_set("No annotation selected.") + return False + return True + + @classmethod + def get_selected_annotations(cls, context) -> list[ifcopenshell.entity_instance]: + return [ + element + for obj in context.selected_objects + if (element := tool.Ifc.get_entity(obj)) + and element.is_a("IfcAnnotation") + and element.ObjectType != "DRAWING" + ] + + def invoke(self, context, event): + assert context.window_manager + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + assert self.layout + row = self.layout.row() + row.prop(self, "target_drawing") + + def _execute(self, context): + if not self.target_drawing: + self.report({"ERROR"}, "No target drawing selected.") + return {"CANCELLED"} + target_drawing = tool.Ifc.get().by_id(int(self.target_drawing)) + annotations = self.get_selected_annotations(context) + previous_selection = [obj for a in annotations if (obj := tool.Ifc.get_object(a))] + previous_active = context.view_layer.objects.active + copied = core.copy_annotations_to_drawing( + tool.Ifc, + tool.Collector, + tool.Drawing, + tool.Geometry, + annotations=annotations, + target_drawing=target_drawing, + ) + for obj in context.selected_objects: + obj.select_set(False) + for obj in previous_selection: + if obj.name in context.view_layer.objects: + obj.select_set(True) + if previous_active and previous_active.name in context.view_layer.objects: + context.view_layer.objects.active = previous_active + skipped = len(annotations) - len(copied) + message = f"Copied {len(copied)} annotations to {target_drawing.Name or 'Unnamed'}." + if skipped: + message += f" Skipped {skipped} already in that drawing." + self.report({"INFO"}, message) + + class CreateDrawing(bpy.types.Operator): """Creates/refreshes a .svg drawing diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 6c4f74ffe2..5c690e7282 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -332,6 +332,8 @@ class BIM_PT_drawings(Panel): row3.separator(factor=0.5, type="SPACE") + row3.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="") + row3.operator("bim.select_all_drawings", icon="CHECKBOX_HLT", text="") row3.operator("bim.create_drawing", text="", icon="OUTPUT") row3.operator("bim.convert_svg_to_dxf", text="", icon="SEQ_PREVIEW").view = active_drawing.name diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index bc7463073e..69532e6dfd 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -225,6 +225,9 @@ class AnnotationToolUI: def draw_edit_object_interface(cls, context): if DecoratorData.get_text_data(bpy.context.active_object): add_layout_hotkey_operator(cls.layout, "Edit Text", "S_E", "") + if bpy.ops.bim.copy_annotation_to_drawing.poll(): + row = cls.layout.row(align=True) + row.operator("bim.copy_annotation_to_drawing", icon="PASTEDOWN", text="Copy To Drawing") @classmethod def draw_type_selection_interface(cls): diff --git a/src/bonsai/bonsai/bim/module/group/operator.py b/src/bonsai/bonsai/bim/module/group/operator.py index f58ba72763..763da8eecf 100644 --- a/src/bonsai/bonsai/bim/module/group/operator.py +++ b/src/bonsai/bonsai/bim/module/group/operator.py @@ -163,14 +163,52 @@ class AssignGroup(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): if not self.is_assigning: return bpy.ops.bim.unassign_group(group=self.group) + ifc_file = tool.Ifc.get() + group = ifc_file.by_id(self.group) products = [ element for o in tool.Blender.get_selected_objects(include_active=False) if (element := tool.Ifc.get_entity(o)) ] - ifcopenshell.api.group.assign_group(tool.Ifc.get(), products=products, group=tool.Ifc.get().by_id(self.group)) + relocated_annotations = self.unassign_from_previous_drawing(ifc_file, group, products) + ifcopenshell.api.group.assign_group(ifc_file, products=products, group=group) + self.relocate_annotations_to_drawing(relocated_annotations, group) self.report({"INFO"}, f"Assigned {len(products)} objects to group.") + def unassign_from_previous_drawing(self, ifc_file, group, products) -> list[ifcopenshell.entity_instance]: + """Assigning an annotation to a group that represents a drawing means the + annotation should belong to that drawing only, so it needs to leave + whichever drawing it was previously part of, instead of ending up + visible in both at once. + """ + new_drawing = tool.Drawing.get_group_drawing(group) + if not new_drawing: + return [] + relocated = [] + for product in products: + if not product.is_a("IfcAnnotation") or product.ObjectType == "DRAWING": + continue + old_drawing = tool.Drawing.get_annotation_drawing(product) + if not old_drawing or old_drawing.id() == new_drawing.id(): + continue + if old_group := tool.Drawing.get_drawing_group(old_drawing): + ifcopenshell.api.group.unassign_group(ifc_file, products=[product], group=old_group) + relocated.append(product) + return relocated + + def relocate_annotations_to_drawing(self, products, group) -> None: + """Move the relocated annotations into the new drawing's collection and + depth, now that they have actually been assigned to its group. + """ + if not products: + return + new_drawing = tool.Drawing.get_group_drawing(group) + new_camera = tool.Ifc.get_object(new_drawing) or tool.Drawing.import_drawing(new_drawing) + for product in products: + if obj := tool.Ifc.get_object(product): + tool.Drawing.ensure_annotation_in_drawing_plane(obj, camera=new_camera) + tool.Collector.assign(obj) + class UnassignGroup(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_group" diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index c6a7231273..2a1f760570 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -411,6 +411,37 @@ def duplicate_drawing( return new_drawing +def copy_annotations_to_drawing( + ifc: type[tool.Ifc], + collector: type[tool.Collector], + drawing_tool: type[tool.Drawing], + geometry: type[tool.Geometry], + annotations: list[ifcopenshell.entity_instance], + target_drawing: ifcopenshell.entity_instance, +) -> list[ifcopenshell.entity_instance]: + """Duplicate annotations into another drawing, leaving the originals untouched.""" + target_group = drawing_tool.get_drawing_group(target_drawing) + if not target_group: + return [] + annotations = [a for a in annotations if drawing_tool.get_annotation_drawing(a) != target_drawing] + annotation_objs = [obj for a in annotations if (obj := ifc.get_object(a))] + if not annotation_objs: + return [] + camera = ifc.get_object(target_drawing) or drawing_tool.import_drawing(target_drawing) + old_to_new, _ = geometry.duplicate_ifc_objects(annotation_objs) + copied: list[ifcopenshell.entity_instance] = [] + for new_elements in old_to_new.values(): + for new_element in new_elements: + if old_group := drawing_tool.get_drawing_group(new_element): + ifc.run("group.unassign_group", group=old_group, products=[new_element]) + ifc.run("group.assign_group", group=target_group, products=[new_element]) + new_obj = ifc.get_object(new_element) + drawing_tool.ensure_annotation_in_drawing_plane(new_obj, camera) + collector.assign(new_obj, should_clean_users_collection=True) + copied.append(new_element) + return copied + + def remove_drawing( ifc: type[tool.Ifc], drawing_tool: type[tool.Drawing], drawing: ifcopenshell.entity_instance ) -> None: diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 90ddc006f0..51120ad088 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -354,6 +354,7 @@ class Drawing: def enable_editing_schedules(cls): pass def enable_editing_sheets(cls): pass def enable_editing_text(cls, obj): pass + def ensure_annotation_in_drawing_plane(cls, obj, camera=None): pass def ensure_drawings_parent_document(cls): pass def ensure_drawings_parent_group(cls): pass def ensure_unique_drawing_name(cls, name): pass @@ -367,6 +368,7 @@ class Drawing: def generate_reference_attributes(cls, reference, **attributes): pass def generate_sheet_identification(cls): pass def get_annotation_context(cls, target_view, object_type=None): pass + def get_annotation_drawing(cls, element): pass def get_annotation_representation(cls, element_type): pass def get_assigned_product(cls, element): pass def get_assigned_product_workaround(cls, element): pass @@ -384,6 +386,7 @@ class Drawing: def get_drawing_group(cls, drawing): pass def get_drawing_references(cls, drawing): pass def get_drawing_target_view(cls, drawing): pass + def get_group_drawing(cls, group): pass def get_group_elements(cls, group): pass def get_ifc_representation_class(cls, object_type): pass def get_name(cls, element): pass @@ -397,6 +400,7 @@ class Drawing: def get_unit_system(cls): pass def import_assigned_product(cls, obj): pass def import_documents(cls, document_type): pass + def import_drawing(cls, drawing): pass def import_drawings(cls): pass def import_sheets(cls): pass def import_text_attributes(cls, obj): pass diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 4dca951c46..aedc1219d9 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -756,6 +756,17 @@ class Drawing(bonsai.core.tool.Drawing): if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING": return rel.RelatingGroup + @classmethod + def get_group_drawing(cls, group: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: + """Get the drawing that owns this group, if the group represents a drawing.""" + if group.ObjectType != "DRAWING": + return None + for rel in group.IsGroupedBy or []: + for related_object in rel.RelatedObjects: + if related_object.is_a("IfcAnnotation") and related_object.ObjectType == "DRAWING": + return related_object + return None + @classmethod def get_drawing_document(cls, drawing: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: for rel in drawing.HasAssociations: diff --git a/src/bonsai/test/core/test_drawing.py b/src/bonsai/test/core/test_drawing.py index 439597056f..914ddbbb54 100644 --- a/src/bonsai/test/core/test_drawing.py +++ b/src/bonsai/test/core/test_drawing.py @@ -453,6 +453,59 @@ class TestDuplicateDrawing: subject.duplicate_drawing(ifc, blender, drawing, geometry, drawing="drawing", should_duplicate_annotations=True) +class TestCopyAnnotationsToDrawing: + def test_run(self, ifc: Prophecy, collector: Prophecy, drawing: Prophecy, geometry: Prophecy): + drawing.get_drawing_group("target_drawing").should_be_called().will_return("target_group") + drawing.get_annotation_drawing("annotation").should_be_called().will_return("source_drawing") + ifc.get_object("annotation").should_be_called().will_return("annotation_obj") + ifc.get_object("target_drawing").should_be_called().will_return("camera") + geometry.duplicate_ifc_objects(["annotation_obj"]).should_be_called().will_return( + ({"annotation": ["new_annotation"]}, None) + ) + drawing.get_drawing_group("new_annotation").should_be_called().will_return("source_group") + ifc.run("group.unassign_group", group="source_group", products=["new_annotation"]).should_be_called() + ifc.run("group.assign_group", group="target_group", products=["new_annotation"]).should_be_called() + ifc.get_object("new_annotation").should_be_called().will_return("new_annotation_obj") + drawing.ensure_annotation_in_drawing_plane("new_annotation_obj", "camera").should_be_called() + collector.assign("new_annotation_obj", should_clean_users_collection=True).should_be_called() + assert subject.copy_annotations_to_drawing( + ifc, collector, drawing, geometry, annotations=["annotation"], target_drawing="target_drawing" + ) == ["new_annotation"] + + def test_skipping_annotations_already_in_the_target_drawing( + self, ifc: Prophecy, collector: Prophecy, drawing: Prophecy, geometry: Prophecy + ): + drawing.get_drawing_group("target_drawing").should_be_called().will_return("target_group") + drawing.get_annotation_drawing("annotation").should_be_called().will_return("target_drawing") + assert ( + subject.copy_annotations_to_drawing( + ifc, collector, drawing, geometry, annotations=["annotation"], target_drawing="target_drawing" + ) + == [] + ) + + def test_importing_the_target_camera_when_it_is_not_loaded( + self, ifc: Prophecy, collector: Prophecy, drawing: Prophecy, geometry: Prophecy + ): + drawing.get_drawing_group("target_drawing").should_be_called().will_return("target_group") + drawing.get_annotation_drawing("annotation").should_be_called().will_return("source_drawing") + ifc.get_object("annotation").should_be_called().will_return("annotation_obj") + ifc.get_object("target_drawing").should_be_called().will_return(None) + drawing.import_drawing("target_drawing").should_be_called().will_return("camera") + geometry.duplicate_ifc_objects(["annotation_obj"]).should_be_called().will_return( + ({"annotation": ["new_annotation"]}, None) + ) + drawing.get_drawing_group("new_annotation").should_be_called().will_return("source_group") + ifc.run("group.unassign_group", group="source_group", products=["new_annotation"]).should_be_called() + ifc.run("group.assign_group", group="target_group", products=["new_annotation"]).should_be_called() + ifc.get_object("new_annotation").should_be_called().will_return("new_annotation_obj") + drawing.ensure_annotation_in_drawing_plane("new_annotation_obj", "camera").should_be_called() + collector.assign("new_annotation_obj", should_clean_users_collection=True).should_be_called() + assert subject.copy_annotations_to_drawing( + ifc, collector, drawing, geometry, annotations=["annotation"], target_drawing="target_drawing" + ) == ["new_annotation"] + + class TestRemoveDrawing: def test_run(self, ifc, drawing): drawing.is_active_drawing("drawing").should_be_called().will_return(True) diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index a14b4d9d79..580b326822 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -506,6 +506,25 @@ class TestGetDrawingGroup(NewFile): assert subject.get_drawing_group(element) == group +class TestGetGroupDrawing(NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.createIfcAnnotation(ObjectType="DRAWING") + group = ifcopenshell.api.group.add_group(ifc) + group.ObjectType = "DRAWING" + ifcopenshell.api.group.assign_group(ifc, products=[drawing], group=group) + assert subject.get_group_drawing(group) == drawing + + def test_ignores_groups_that_are_not_drawings(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + drawing = ifc.createIfcAnnotation(ObjectType="DRAWING") + group = ifcopenshell.api.group.add_group(ifc) + ifcopenshell.api.group.assign_group(ifc, products=[drawing], group=group) + assert subject.get_group_drawing(group) is None + + class TestGetDrawingTargetView(NewFile): def test_run(self): ifc = ifcopenshell.file()