Merge entities, if match on name or identification attribute only.

This commit is contained in:
Ryan Schultz
2025-10-13 11:25:36 -05:00
parent 8014980b88
commit 322a2179e5
3 changed files with 80 additions and 21 deletions
+22 -3
View File
@@ -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
+2 -1
View File
@@ -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
+56 -17
View File
@@ -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.