mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
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:
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user