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.
This commit is contained in:
Petru Conduraru
2026-07-20 02:50:14 +03:00
committed by GitHub
parent c55a79b8b5
commit bc1fb2a88d
10 changed files with 247 additions and 1 deletions
@@ -47,6 +47,7 @@ classes = (
operator.CleanWireframes,
operator.ContractSheet,
operator.ConvertSVGToDXF,
operator.CopyAnnotationToDrawing,
operator.CopyTextToSelection,
operator.CreateDrawing,
operator.CreateSheets,
@@ -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
@@ -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
@@ -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):
+39 -1
View File
@@ -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"
+31
View File
@@ -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:
+4
View File
@@ -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
+11
View File
@@ -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:
+53
View File
@@ -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)
+19
View File
@@ -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()