From 5da87bfd3fb83bb69c371d08c672172b912e43b7 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 27 Jul 2026 21:58:21 -0500 Subject: [PATCH] Bonsai: allow a single IfcAnnotation to appear on multiple drawings (#9019) A manual IfcAnnotation can now be shared across several drawings as one element: editing it once updates it everywhere, and it renders into each drawing's SVG. Assignment is native IFC - the annotation becomes a member of multiple drawing IfcGroups - so SVG generation picks it up per-drawing with no writer changes. Auto-generated annotations (grid/section/elevation/level tags, via is_auto_annotation) are excluded since they are regenerated per drawing; manual annotations, dimensions included, are shareable. - core.add_annotation_to_drawing / remove_annotation_from_drawing - tool.Drawing.get_annotation_drawings (plural) - collector links a shared annotation into every assigned drawing collection - import_annotations_in_group re-collects already-imported shared annotations - bim.add_annotation_to_drawing / bim.remove_annotation_from_drawing operators - BIM_PT_annotation_drawings panel (add/remove drawings on an annotation) Co-Authored-By: Claude Opus 4.8 --- .../bonsai/bim/module/drawing/__init__.py | 3 + src/bonsai/bonsai/bim/module/drawing/data.py | 21 +++- .../bonsai/bim/module/drawing/operator.py | 96 +++++++++++++++++++ src/bonsai/bonsai/bim/module/drawing/ui.py | 45 +++++++++ src/bonsai/bonsai/core/drawing.py | 49 ++++++++++ src/bonsai/bonsai/core/tool.py | 3 + src/bonsai/bonsai/tool/collector.py | 20 +++- src/bonsai/bonsai/tool/drawing.py | 33 +++++-- 8 files changed, 260 insertions(+), 10 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index 7f24b8c2f5..2f35de2d86 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -29,8 +29,10 @@ classes = ( operator.ActivateDrawingStyle, operator.ActivateModel, operator.AddAnnotation, + operator.AddAnnotationToDrawing, operator.AddAnnotationType, operator.AssignManualDrawingReference, + operator.RemoveAnnotationFromDrawing, operator.AddDrawing, operator.AddDrawingStyle, operator.AddDrawingToSheet, @@ -133,6 +135,7 @@ classes = ( ui.BIM_PT_schedules, ui.BIM_PT_references, ui.BIM_PT_product_assignments, + ui.BIM_PT_annotation_drawings, ui.BIM_PT_text, ui.BIM_UL_drawinglist, ui.BIM_UL_sheets, diff --git a/src/bonsai/bonsai/bim/module/drawing/data.py b/src/bonsai/bonsai/bim/module/drawing/data.py index 03bb402fee..95ff1777d9 100644 --- a/src/bonsai/bonsai/bim/module/drawing/data.py +++ b/src/bonsai/bonsai/bim/module/drawing/data.py @@ -47,9 +47,28 @@ class ProductAssignmentsData: @classmethod def load(cls): - cls.data = {"relating_product": cls.relating_product()} + cls.data = { + "relating_product": cls.relating_product(), + "annotation_drawings": cls.annotation_drawings(), + "is_auto_annotation": cls.is_auto_annotation(), + } cls.is_loaded = True + @classmethod + def is_auto_annotation(cls): + element = tool.Ifc.get_entity(bpy.context.active_object) + if not element or not element.is_a("IfcAnnotation"): + return False + return tool.Drawing.is_auto_annotation(element) + + @classmethod + def annotation_drawings(cls): + """List of (drawing_id, drawing_name) the active annotation is assigned to.""" + element = tool.Ifc.get_entity(bpy.context.active_object) + if not element or not element.is_a("IfcAnnotation") or element.ObjectType == "DRAWING": + return [] + return [(d.id(), d.Name or f"Drawing {d.id()}") for d in tool.Drawing.get_annotation_drawings(element)] + @classmethod def relating_product(cls): element = tool.Ifc.get_entity(bpy.context.active_object) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 282ef50103..92238f7c92 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -1896,6 +1896,102 @@ class AssignManualDrawingReference(bpy.types.Operator, tool.Ifc.Operator): area.tag_redraw() +def _get_unassigned_drawing_enum_items(self, context): + """Drawings the active annotation is NOT yet assigned to.""" + items = [] + if (ifc := tool.Ifc.get()) and context.active_object: + element = tool.Ifc.get_entity(context.active_object) + assigned = set(tool.Drawing.get_annotation_drawings(element)) if element else set() + for d in ifc.by_type("IfcAnnotation"): + if d.ObjectType == "DRAWING" and d not in assigned: + items.append((str(d.id()), d.Name or f"Drawing {d.id()}", "")) + if not items: + items.append(("0", "No available drawings", "")) + return items + + +class AddAnnotationToDrawing(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_annotation_to_drawing" + bl_label = "Add Annotation To Drawing" + bl_description = "Assign the selected annotation(s) to an additional drawing so they render on it too" + bl_options = {"REGISTER", "UNDO"} + drawing_id: bpy.props.EnumProperty(name="Drawing", items=_get_unassigned_drawing_enum_items) + + @classmethod + def poll(cls, context): + if not tool.Ifc.get() or not context.active_object: + return False + element = tool.Ifc.get_entity(context.active_object) + if not element or not element.is_a("IfcAnnotation") or element.ObjectType == "DRAWING": + cls.poll_message_set("Active object is not an annotation.") + return False + if tool.Drawing.is_auto_annotation(element): + cls.poll_message_set("Auto-generated annotations cannot be shared across drawings.") + return False + return True + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + prop_with_search( + self.layout, self, "drawing_id", should_click_ok=True, search_threshold=0, + original_operator_path=f"{__name__}.AddAnnotationToDrawing", + ) + + def _execute(self, context): + if self.drawing_id == "0": + self.report({"WARNING"}, "No drawing selected.") + return + drawing = tool.Ifc.get().by_id(int(self.drawing_id)) + elements = [ + element + for o in tool.Blender.get_selected_objects() + if (element := tool.Ifc.get_entity(o)) and element.is_a("IfcAnnotation") and element.ObjectType != "DRAWING" + ] + assigned = 0 + skipped = 0 + for element in elements: + if core.add_annotation_to_drawing(tool.Ifc, tool.Collector, tool.Drawing, element=element, drawing=drawing): + assigned += 1 + else: + skipped += 1 + msg = f"Assigned {assigned} annotation(s) to {drawing.Name or 'drawing'}." + if skipped: + msg += f" Skipped {skipped} (auto-generated or already assigned)." + self.report({"INFO"}, msg) + for area in context.screen.areas: + if area.type == "PROPERTIES": + area.tag_redraw() + + +class RemoveAnnotationFromDrawing(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.remove_annotation_from_drawing" + bl_label = "Remove Annotation From Drawing" + bl_description = "Remove the active annotation from this drawing (it stays on its other drawings)" + bl_options = {"REGISTER", "UNDO"} + drawing_id: bpy.props.IntProperty(options={"SKIP_SAVE"}) + + @classmethod + def poll(cls, context): + if not tool.Ifc.get() or not context.active_object: + return False + element = tool.Ifc.get_entity(context.active_object) + return bool(element and element.is_a("IfcAnnotation") and element.ObjectType != "DRAWING") + + def _execute(self, context): + element = tool.Ifc.get_entity(context.active_object) + drawing = tool.Ifc.get().by_id(self.drawing_id) + if not core.remove_annotation_from_drawing( + tool.Ifc, tool.Collector, tool.Drawing, element=element, drawing=drawing + ): + self.report({"WARNING"}, "An annotation must remain on at least one drawing.") + return + for area in context.screen.areas: + if area.type == "PROPERTIES": + area.tag_redraw() + + class AddSheet(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_sheet" bl_label = "Add Sheet" diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index e3a2deb447..8e6ad047e1 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -564,6 +564,51 @@ class BIM_PT_product_assignments(Panel): +class BIM_PT_annotation_drawings(Panel): + bl_label = "Drawings" + bl_idname = "BIM_PT_annotation_drawings" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "object" + bl_order = 2 + bl_parent_id = "BIM_PT_tab_object_metadata" + + @classmethod + def poll(cls, context): + if not tool.Ifc.get() or not context.active_object: + return False + element = tool.Ifc.get_entity(context.active_object) + if not element or not element.is_a("IfcAnnotation") or element.ObjectType == "DRAWING": + return False + # Manual drawing-reference tags point at a single target drawing via a + # different mechanism, so multi-drawing sharing doesn't apply to them. + return not tool.Drawing.is_manual_drawing_reference(element) + + def draw(self, context): + if not ProductAssignmentsData.is_loaded: + ProductAssignmentsData.load() + + assert self.layout + + if ProductAssignmentsData.data["is_auto_annotation"]: + self.layout.label(text="Auto-generated annotation (single drawing).", icon="INFO") + return + + row = self.layout.row(align=True) + row.label(text="Shown on drawings:", icon="IMAGE_DATA") + row.operator("bim.add_annotation_to_drawing", icon="ADD", text="") + + drawings = ProductAssignmentsData.data["annotation_drawings"] + allow_remove = len(drawings) > 1 + for drawing_id, drawing_name in drawings: + row = self.layout.row(align=True) + row.label(text=drawing_name, icon="OUTLINER_OB_CAMERA") + sub = row.column() + sub.enabled = allow_remove + op = sub.operator("bim.remove_annotation_from_drawing", icon="X", text="") + op.drawing_id = drawing_id + + def get_category_icon(category_name): """Get appropriate icon for each category""" icons = { diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index a606a13086..2ac7e99719 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -503,6 +503,55 @@ def add_annotation( return obj +def add_annotation_to_drawing( + ifc: type[tool.Ifc], + collector: type[tool.Collector], + drawing_tool: type[tool.Drawing], + element: ifcopenshell.entity_instance, + drawing: ifcopenshell.entity_instance, +) -> bool: + """Assign a manual annotation to an additional drawing so it renders on it too. + + Auto-generated annotations (grid/section/elevation tags, etc.) are tied to a + single drawing's regeneration cycle and cannot be shared - they are skipped. + Returns True if the annotation was assigned, False if it was skipped/already there. + """ + if drawing_tool.is_auto_annotation(element): + return False + group = drawing_tool.get_drawing_group(drawing) + if not group or drawing in drawing_tool.get_annotation_drawings(element): + return False + ifc.run("group.assign_group", group=group, products=[element]) + # Relink the Blender object so it becomes visible in the newly-assigned + # drawing's collection as well. + if obj := ifc.get_object(element): + collector.assign(obj) + return True + + +def remove_annotation_from_drawing( + ifc: type[tool.Ifc], + collector: type[tool.Collector], + drawing_tool: type[tool.Drawing], + element: ifcopenshell.entity_instance, + drawing: ifcopenshell.entity_instance, +) -> bool: + """Remove a manual annotation from a drawing's group. + + An annotation must always belong to at least one drawing, so removal from its + last remaining drawing is refused. Returns True if removed, False otherwise. + """ + if len(drawing_tool.get_annotation_drawings(element)) <= 1: + return False + group = drawing_tool.get_drawing_group(drawing) + if not group: + return False + ifc.run("group.unassign_group", group=group, products=[element]) + if obj := ifc.get_object(element): + collector.assign(obj) + return True + + def assign_manual_drawing_reference( ifc: type[tool.Ifc], drawing_tool: type[tool.Drawing], diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index e6a60c9deb..870c516d6a 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -358,6 +358,8 @@ 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_drawings(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 @@ -393,6 +395,7 @@ class Drawing: def import_text_attributes(cls, obj): pass def is_active_drawing(cls, drawing): pass def is_annotation_object_type(cls, element, object_types): pass + def is_auto_annotation(cls, element): pass def is_camera_orthographic(cls): pass def is_drawing_active(cls): pass def is_editing_sheets(cls): pass diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index 1e6653acd1..903d40ea87 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -119,8 +119,12 @@ class Collector(bonsai.core.tool.Collector): cls.link_collection_object_safe(collection, obj) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection) - elif element.is_a("IfcAnnotation") and (drawing_obj := cls.get_annotation_drawing_obj(element)): - cls.link_collection_object_safe(tool.Blender.get_object_bim_props(drawing_obj).collection, obj) + elif element.is_a("IfcAnnotation") and (drawing_objs := cls.get_annotation_drawing_objs(element)): + # An annotation may be shared across several drawings; link it into + # each drawing's collection so it is visible/editable from all of them. + for drawing_obj in drawing_objs: + if target_collection := tool.Blender.get_object_bim_props(drawing_obj).collection: + cls.link_collection_object_safe(target_collection, obj) elif container := ifcopenshell.util.element.get_container(element): while container.is_a("IfcSpace"): container = ifcopenshell.util.element.get_aggregate(container) @@ -169,6 +173,18 @@ class Collector(bonsai.core.tool.Collector): if related_object.is_a("IfcAnnotation") and related_object.ObjectType == "DRAWING": return tool.Ifc.get_object(related_object) + @classmethod + def get_annotation_drawing_objs(cls, element: ifcopenshell.entity_instance) -> list[bpy.types.Object]: + """Every drawing camera object this annotation is assigned to (may be several).""" + objs = [] + for rel in element.HasAssignments or []: + if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING": + for related_object in rel.RelatedObjects: + if related_object.is_a("IfcAnnotation") and related_object.ObjectType == "DRAWING": + if (drawing_obj := tool.Ifc.get_object(related_object)) and drawing_obj not in objs: + objs.append(drawing_obj) + return objs + @classmethod def link_collection_object_safe(cls, collection: bpy.types.Collection, obj: bpy.types.Object) -> None: # Catching an exception is 10x faster than doing collection.objects.find diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index d4b9336c2a..27bc932858 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -233,6 +233,21 @@ class Drawing(bonsai.core.tool.Drawing): if e.ObjectType == "DRAWING": return e + @classmethod + def get_annotation_drawings(cls, element: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: + """Return every drawing (camera) this annotation is assigned to. + + An annotation may belong to multiple drawing groups (it renders on each); + the singular get_annotation_drawing returns only the first/home drawing. + """ + drawings = [] + for rel in element.HasAssignments or []: + if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING": + for e in rel.RelatedObjects: + if e.ObjectType == "DRAWING" and e not in drawings: + drawings.append(e) + return drawings + @classmethod def exclude_annotation_from_drawing( cls, element: ifcopenshell.entity_instance, drawing: ifcopenshell.entity_instance @@ -936,13 +951,10 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def import_annotations_in_group(cls, group: ifcopenshell.entity_instance) -> None: - elements = set( - [ - e - for e in cls.get_group_elements(group) - if e.is_a("IfcAnnotation") and e.ObjectType != "DRAWING" and not tool.Ifc.get_object(e) - ] - ) + group_annotations = [ + e for e in cls.get_group_elements(group) if e.is_a("IfcAnnotation") and e.ObjectType != "DRAWING" + ] + elements = set(e for e in group_annotations if not tool.Ifc.get_object(e)) logger = logging.getLogger("ImportIFC") ifc_import_settings = bonsai.bim.import_ifc.IfcImportSettings.factory(bpy.context, None, logger) ifc_importer = bonsai.bim.import_ifc.IfcImporter(ifc_import_settings) @@ -955,6 +967,13 @@ class Drawing(bonsai.core.tool.Drawing): ifc_importer.setup_arrays(annotations_to_import=elements) for obj in ifc_importer.added_data.values(): tool.Collector.assign(obj) + # Annotations shared from another drawing may already have a Blender object + # (so they were skipped above); re-collect them so they are also linked into + # this drawing's collection. + added_objs = set(ifc_importer.added_data.values()) + for e in group_annotations: + if (obj := tool.Ifc.get_object(e)) and obj not in added_objs: + tool.Collector.assign(obj) @classmethod def get_camera_shape_matrix(