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