From b0ae0a2c82bc1b2d53791fce0f78164f3ddf20e7 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Tue, 14 Oct 2025 11:29:44 -0500 Subject: [PATCH] extend https://github.com/IfcOpenShell/IfcOpenShell/commit/322a2179e51a9ec4802fe9e35039c7e3e29f9678: Merges names with number suffix, as well (ex: foo, foo.001, foo.002) --- .../bonsai/bim/module/debug/operator.py | 7 ++- src/bonsai/bonsai/tool/debug.py | 50 ++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index 3ef8dfc953..0820d5e3d6 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -818,7 +818,12 @@ 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 = "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"} object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] diff --git a/src/bonsai/bonsai/tool/debug.py b/src/bonsai/bonsai/tool/debug.py index 179263f0d1..35e877e734 100644 --- a/src/bonsai/bonsai/tool/debug.py +++ b/src/bonsai/bonsai/tool/debug.py @@ -18,6 +18,7 @@ from __future__ import annotations import os +import re import json import bmesh import bpy @@ -145,10 +146,21 @@ class Debug(bonsai.core.tool.Debug): 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). + Strips .XXX suffix patterns (e.g., 'foo.001' matches 'foo', 'foo.002'). For PERSON, uses Identification. For APPLICATION, uses ApplicationFullName. 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: data = element.get_info_2(include_identifier=False, recursive=True) if object_type == "APPLICATION": @@ -161,6 +173,30 @@ class Debug(bonsai.core.tool.Debug): 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": + 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": return element.Name if element.Name else "" 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]] 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) for element in elements: name_key = get_name_key(element) @@ -217,6 +253,18 @@ class Debug(bonsai.core.tool.Debug): if not name_key: continue 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: # Group by full hash hash_to_elements = defaultdict(list)