mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user