From 322a2179e51a9ec4802fe9e35039c7e3e29f9678 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 13 Oct 2025 11:25:36 -0500 Subject: [PATCH] Merge entities, if match on name or identification attribute only. --- .../bonsai/bim/module/debug/operator.py | 25 ++++++- src/bonsai/bonsai/bim/module/project/ui.py | 3 +- src/bonsai/bonsai/tool/debug.py | 73 ++++++++++++++----- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index 3b527b198b..3ef8dfc953 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -818,7 +818,7 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.merge_identical_objects" bl_label = "Merge Identical Objects" - bl_description = "For materials currently only IfcMaterials are supported" + bl_description = "Merge identical IFC entities (that match all attributes). Hold Shift to merge by name/identification attribute only" bl_options = {"REGISTER", "UNDO"} object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] @@ -826,18 +826,36 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)), ) + by_name_or_identification_only: bpy.props.BoolProperty( + name="By Name/Identification Only", + description="Merge based only on Name or Identification attribute, ignoring other properties", + default=False, + ) + if TYPE_CHECKING: object_type: tool.Debug.PurgeMergeObjectType + def invoke(self, context, event): + # Check if shift key is pressed + if event.shift: + self.by_name_or_identification_only = True + else: + self.by_name_or_identification_only = False + + return self.execute(context) + def _execute(self, context): object_type: str = self.object_type if object_type in ("PROFILE", "TYPE"): self.report({"ERROR"}, f"Unsupported object type {object_type}.") return {"CANCELLED"} - merged_data = tool.Debug.merge_identical_objects(object_type) + merged_data = tool.Debug.merge_identical_objects( + object_type, by_name_or_identification_only=self.by_name_or_identification_only + ) plural_object_type = f"{object_type.lower().replace('_', ' ')}s" if merged_data: + merge_mode = " by name/identification" if self.by_name_or_identification_only else "" for element_type, element_names in merged_data.items(): print(f"- {element_type}:") for name in element_names: @@ -846,7 +864,8 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): merged = sum(len(v) for v in merged_data.values()) msg = " See system console for details." if merged else "" - self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged.{msg}") + merge_mode = " (by name/identification)" if self.by_name_or_identification_only else "" + self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged{merge_mode}.{msg}") if merged == 0: return diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 75d114e204..03b4222008 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -653,4 +653,5 @@ class BIM_PT_purge(Panel): row = layout.row(align=True) row.label(text=f"{object_type.replace('_', ' ').capitalize()}:") row.operator("bim.purge_unused_objects", text="Purge Unused").object_type = object_type - row.operator("bim.merge_identical_objects", text="Merge Identical").object_type = object_type + merge_op = row.operator("bim.merge_identical_objects", text="Merge Identical") + merge_op.object_type = object_type diff --git a/src/bonsai/bonsai/tool/debug.py b/src/bonsai/bonsai/tool/debug.py index 079892a9b5..179263f0d1 100644 --- a/src/bonsai/bonsai/tool/debug.py +++ b/src/bonsai/bonsai/tool/debug.py @@ -33,7 +33,7 @@ import bonsai.tool as tool from bonsai.bim.ifc import IfcStore from mathutils import Vector from collections import defaultdict -from typing import Literal, TYPE_CHECKING, assert_never +from typing import Literal, TYPE_CHECKING, assert_never, Union from collections.abc import Iterable if TYPE_CHECKING: @@ -136,10 +136,17 @@ class Debug(bonsai.core.tool.Debug): "PERSON", "PERSON_AND_ORGANIZATION", ], + by_name_or_identification_only: bool = False, ) -> dict[str, list[str]]: """Merge identical objects. Note that Styles UI (or other UI) should be updated manually after using this method. + + Args: + object_type: The type of object to merge + by_name_or_identification_only: If True, merge based only on Name attribute (or equivalent identifier). + For PERSON, uses Identification. For APPLICATION, uses ApplicationFullName. + For PERSON_AND_ORGANIZATION, uses combination of person and organization identifiers. """ def get_hash(element: ifcopenshell.entity_instance) -> int: @@ -152,6 +159,25 @@ class Debug(bonsai.core.tool.Debug): data["TheOrganization"] = element.TheOrganization.id() return hash(json.dumps(data, sort_keys=True)) + def get_name_key(element: ifcopenshell.entity_instance) -> str: + """Get key based on name/identifier attribute for the given object type""" + if object_type == "STYLE": + return element.Name if element.Name else "" + elif object_type == "MATERIAL": + return element.Name if element.Name else "" + elif object_type == "ORGANIZATION": + return element.Name if element.Name else "" + elif object_type == "APPLICATION": + return element.ApplicationFullName if element.ApplicationFullName else "" + elif object_type == "PERSON": + return element.Identification if element.Identification else "" + elif object_type == "PERSON_AND_ORGANIZATION": + person_id = element.ThePerson.Identification if element.ThePerson.Identification else "" + org_name = element.TheOrganization.Name if element.TheOrganization.Name else "" + return f"{person_id}|{org_name}" + else: + assert_never(object_type) + ifc_file = tool.Ifc.get() merged_element_types: dict[str, list[str]] = {} @@ -179,22 +205,35 @@ class Debug(bonsai.core.tool.Debug): for element_type in element_types: elements = ifc_file.by_type(element_type, include_subtypes=False) - # Calculate hashes. - hash_to_elements: defaultdict[int, list[ifcopenshell.entity_instance]] = defaultdict(list) - for element in elements: - # Except for styles, ignore unnamed elements as they may be not safe to merge - merge_optional_names = ("STYLE", "PERSON") - not_optional_name = ("APPLICATION", "ORGANIZATION") - has_no_name = ("PERSON_AND_ORGANIZATION",) - if ( - object_type not in merge_optional_names - and object_type not in not_optional_name - and object_type not in has_no_name - and not element.Name - ): - continue - element_hash = get_hash(element) - hash_to_elements[element_hash].append(element) + # Calculate hashes or name keys. + hash_to_elements: defaultdict[Union[int, str], list[ifcopenshell.entity_instance]] + + if by_name_or_identification_only: + # Group by name/identifier only + hash_to_elements = defaultdict(list) + for element in elements: + name_key = get_name_key(element) + # Skip elements without a valid identifier + if not name_key: + continue + hash_to_elements[name_key].append(element) + else: + # Group by full hash + hash_to_elements = defaultdict(list) + for element in elements: + # Except for styles, ignore unnamed elements as they may be not safe to merge + merge_optional_names = ("STYLE", "PERSON") + not_optional_name = ("APPLICATION", "ORGANIZATION") + has_no_name = ("PERSON_AND_ORGANIZATION",) + if ( + object_type not in merge_optional_names + and object_type not in not_optional_name + and object_type not in has_no_name + and not element.Name + ): + continue + element_hash = get_hash(element) + hash_to_elements[element_hash].append(element) merged_elements_names: list[str] = [] # Merge elements.