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 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-07-27 21:58:21 -05:00
parent 3409c656ca
commit 5da87bfd3f
8 changed files with 260 additions and 10 deletions
@@ -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,
+20 -1
View File
@@ -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)
@@ -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"
@@ -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 = {
+49
View File
@@ -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],
+3
View File
@@ -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
+18 -2
View File
@@ -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
+26 -7
View File
@@ -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(