extend https://github.com/IfcOpenShell/IfcOpenShell/commit/322a2179e51a9ec4802fe9e35039c7e3e29f9678: Merges names with number suffix, as well (ex: foo, foo.001, foo.002)

This commit is contained in:
Ryan Schultz
2025-10-14 11:29:44 -05:00
parent 322a2179e5
commit b0ae0a2c82
2 changed files with 55 additions and 2 deletions
@@ -818,7 +818,12 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator):
class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.merge_identical_objects" bl_idname = "bim.merge_identical_objects"
bl_label = "Merge Identical Objects" bl_label = "Merge Identical Objects"
bl_description = "Merge identical IFC entities (that match all attributes). Hold Shift to merge by name/identification attribute only" bl_description = (
"Merge identical IFC objects (that match all attributes).\n"
"\n"
"SHIFT + CLICK to merge by name/identification attribute only.\n"
"Merges names with number suffix, as well (ex: foo, foo.001, foo.002)\n"
)
bl_options = {"REGISTER", "UNDO"} bl_options = {"REGISTER", "UNDO"}
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
+49 -1
View File
@@ -18,6 +18,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
import json import json
import bmesh import bmesh
import bpy import bpy
@@ -145,10 +146,21 @@ class Debug(bonsai.core.tool.Debug):
Args: Args:
object_type: The type of object to merge object_type: The type of object to merge
by_name_or_identification_only: If True, merge based only on Name attribute (or equivalent identifier). by_name_or_identification_only: If True, merge based only on Name attribute (or equivalent identifier).
Strips .XXX suffix patterns (e.g., 'foo.001' matches 'foo', 'foo.002').
For PERSON, uses Identification. For APPLICATION, uses ApplicationFullName. For PERSON, uses Identification. For APPLICATION, uses ApplicationFullName.
For PERSON_AND_ORGANIZATION, uses combination of person and organization identifiers. For PERSON_AND_ORGANIZATION, uses combination of person and organization identifiers.
""" """
def normalize_name(name: str) -> str:
"""Remove .XXX suffix pattern from names (e.g., 'foo.001' -> 'foo')"""
if not name:
return ""
# Match pattern: name ending with .digits
match = re.match(r"^(.+)\.\d+$", name)
if match:
return match.group(1)
return name
def get_hash(element: ifcopenshell.entity_instance) -> int: def get_hash(element: ifcopenshell.entity_instance) -> int:
data = element.get_info_2(include_identifier=False, recursive=True) data = element.get_info_2(include_identifier=False, recursive=True)
if object_type == "APPLICATION": if object_type == "APPLICATION":
@@ -161,6 +173,30 @@ class Debug(bonsai.core.tool.Debug):
def get_name_key(element: ifcopenshell.entity_instance) -> str: def get_name_key(element: ifcopenshell.entity_instance) -> str:
"""Get key based on name/identifier attribute for the given object type""" """Get key based on name/identifier attribute for the given object type"""
if object_type == "STYLE":
name = element.Name if element.Name else ""
return normalize_name(name)
elif object_type == "MATERIAL":
name = element.Name if element.Name else ""
return normalize_name(name)
elif object_type == "ORGANIZATION":
name = element.Name if element.Name else ""
return normalize_name(name)
elif object_type == "APPLICATION":
name = element.ApplicationFullName if element.ApplicationFullName else ""
return normalize_name(name)
elif object_type == "PERSON":
ident = element.Identification if element.Identification else ""
return normalize_name(ident)
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"{normalize_name(person_id)}|{normalize_name(org_name)}"
else:
assert_never(object_type)
def get_element_name(element: ifcopenshell.entity_instance) -> str:
"""Get the actual name/identifier from element for sorting purposes"""
if object_type == "STYLE": if object_type == "STYLE":
return element.Name if element.Name else "" return element.Name if element.Name else ""
elif object_type == "MATERIAL": elif object_type == "MATERIAL":
@@ -209,7 +245,7 @@ class Debug(bonsai.core.tool.Debug):
hash_to_elements: defaultdict[Union[int, str], list[ifcopenshell.entity_instance]] hash_to_elements: defaultdict[Union[int, str], list[ifcopenshell.entity_instance]]
if by_name_or_identification_only: if by_name_or_identification_only:
# Group by name/identifier only # Group by name/identifier only (with .XXX suffix normalization)
hash_to_elements = defaultdict(list) hash_to_elements = defaultdict(list)
for element in elements: for element in elements:
name_key = get_name_key(element) name_key = get_name_key(element)
@@ -217,6 +253,18 @@ class Debug(bonsai.core.tool.Debug):
if not name_key: if not name_key:
continue continue
hash_to_elements[name_key].append(element) hash_to_elements[name_key].append(element)
# Sort elements within each group to keep the one without suffix (or lowest suffix)
for name_key in hash_to_elements:
# Sort by: 1) prefer names without .XXX suffix, 2) then by original name
def sort_key(el):
name = get_element_name(el)
# Check if name has .XXX suffix
has_suffix = bool(re.match(r"^.+\.\d+$", name))
# Return tuple: (has_suffix, name) - sort by no suffix first, then alphabetically
return (has_suffix, name)
hash_to_elements[name_key].sort(key=sort_key)
else: else:
# Group by full hash # Group by full hash
hash_to_elements = defaultdict(list) hash_to_elements = defaultdict(list)