From a2a5780d59df97bfe31fdd41947b66253b65dcc3 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 13 Dec 2025 09:37:56 -0600 Subject: [PATCH] Fix #6392: when duplicating a window/door/etc, the associated IfcOpenElement duplicates as well. Also when `bim.append_library_element` it copies the IfcOpenElement, as well. --- src/bonsai/bonsai/bim/module/model/opening.py | 137 +++++++++++++++--- .../bonsai/bim/module/project/operator.py | 52 +++++++ src/bonsai/bonsai/tool/root.py | 41 ++++-- 3 files changed, 199 insertions(+), 31 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index 958716954b..d028425fb2 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -151,11 +151,29 @@ class FilledOpeningGenerator: existing_opening_occurrence, "Model", "Body", "MODEL_VIEW" ) assert representation - representation = ifcopenshell.util.representation.resolve_representation(representation) + + # Check if mapped representation - preserve it + if (representation.RepresentationType == 'MappedRepresentation' and + len(representation.Items) == 1 and + representation.Items[0].is_a("IfcMappedItem")): + source_rep = representation.Items[0].MappingSource.MappedRepresentation + representation = ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), + source_rep, + exclude=["IfcGeometricRepresentationContext"] + ) + else: + representation = ifcopenshell.util.representation.resolve_representation(representation) else: - representation = self.generate_opening_from_filling( - filling, filling_obj, opening_thickness_si=opening_thickness_si - ) + # Check for library template before generating from filling + template_rep = self.get_opening_template_from_type(filling) + + if template_rep: + representation = template_rep + else: + representation = self.generate_opening_from_filling( + filling, filling_obj, opening_thickness_si=opening_thickness_si + ) mapped_representation = ifcopenshell.api.geometry.map_representation( tool.Ifc.get(), representation=representation @@ -204,38 +222,85 @@ class FilledOpeningGenerator: voided_element = opening.VoidsElements[0].RelatingBuildingElement opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW") + + # ALWAYS preserve the existing opening representation (Tessellation, SweptSolid, etc.) + preserved_representation = None + if opening_rep: + if (opening_rep.RepresentationType == 'MappedRepresentation' and + len(opening_rep.Items) == 1 and + opening_rep.Items[0].is_a("IfcMappedItem")): + # For mapped representations, copy the underlying representation + preserved_representation = ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), + opening_rep.Items[0].MappingSource.MappedRepresentation, + exclude=["IfcGeometricRepresentationContext"] + ) + else: + # For direct representations (non-mapped), copy them too + preserved_representation = ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), + opening_rep, + exclude=["IfcGeometricRepresentationContext"] + ) + ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep) ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep) existing_opening_occurrence = self.get_existing_opening_occurrence_if_any(filling) - + + # Priority order for choosing representation: + # 1. Existing occurrence with Tessellation (best quality) + # 2. Library template with Tessellation + # 3. Preserved representation from old opening (maintain user's work) + # 4. Generate from filling (last resort) + + representation_to_use = None + if existing_opening_occurrence: representation = ifcopenshell.util.representation.get_representation( existing_opening_occurrence, "Model", "Body", "MODEL_VIEW" ) - representation = ifcopenshell.util.representation.resolve_representation(representation) - mapped_representation = ifcopenshell.api.geometry.map_representation( - tool.Ifc.get(), representation=representation - ) - ifcopenshell.api.geometry.assign_representation( - tool.Ifc.get(), product=opening, representation=mapped_representation - ) - else: + + if (representation and + representation.RepresentationType == 'MappedRepresentation' and + len(representation.Items) == 1 and + representation.Items[0].is_a("IfcMappedItem")): + source_rep = representation.Items[0].MappingSource.MappedRepresentation + # Prefer Tessellation from existing occurrence over preserved representation + if source_rep.RepresentationType == 'Tessellation': + representation_to_use = ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), + source_rep, + exclude=["IfcGeometricRepresentationContext"] + ) + else: + representation_to_use = ifcopenshell.util.representation.resolve_representation(representation) + + if not representation_to_use: + template_rep = self.get_opening_template_from_type(filling) + if template_rep and template_rep.RepresentationType == 'Tessellation': + representation_to_use = template_rep + + if not representation_to_use and preserved_representation: + representation_to_use = preserved_representation + + if not representation_to_use: opening_obj = tool.Ifc.get_object(opening) if opening_obj: tool.Ifc.unlink(element=opening) tool.Blender.remove_data_blocks([opening_obj], remove_unused_data=True) filling_obj = tool.Ifc.get_object(filling) - representation = self.generate_opening_from_filling(filling, filling_obj) - mapped_representation = ifcopenshell.api.geometry.map_representation( - tool.Ifc.get(), representation=representation - ) - ifcopenshell.api.geometry.assign_representation( - tool.Ifc.get(), product=opening, representation=mapped_representation - ) + representation_to_use = self.generate_opening_from_filling(filling, filling_obj) - # update voided object representation or all it's parts if it's an aggregate + mapped_representation = ifcopenshell.api.geometry.map_representation( + tool.Ifc.get(), representation=representation_to_use + ) + ifcopenshell.api.geometry.assign_representation( + tool.Ifc.get(), product=opening, representation=mapped_representation + ) + + # update voided object representation... voided_elements = ifcopenshell.util.element.get_parts(voided_element) or [voided_element] for voided_element in voided_elements: voided_obj = tool.Ifc.get_object(voided_element) @@ -249,6 +314,36 @@ class FilledOpeningGenerator: representation=representation, ) + def get_opening_template_from_type(self, filling: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: + """ + Check if the filling's type has a stored opening template from library import. + """ + element_type = ifcopenshell.util.element.get_type(filling) + + if not element_type: + return None + + desc = element_type.Description + + if not desc or "||BonsaiOpeningTemplate:" not in desc: + return None + + # Extract template ID + marker = desc.split("||BonsaiOpeningTemplate:")[-1] + template_id = int(marker.split("||")[0]) + + try: + template_rep = tool.Ifc.get().by_id(template_id) + # Make a copy so we don't reuse the same representation instance + copied = ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), + template_rep, + exclude=["IfcGeometricRepresentationContext"] + ) + return copied + except: + return None + def generate_opening_from_filling( self, filling: ifcopenshell.entity_instance, diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 2f5b9c906e..0650cda238 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -611,6 +611,8 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): if not element: return {"FINISHED"} if element.is_a("IfcTypeProduct"): + # Store opening template from library if it exists + self.store_opening_template_from_library(element, library_file) self.import_type_from_ifc(element, context) elif element.is_a("IfcProduct"): # NOTE: Non-types are not exposed in UI directly @@ -712,6 +714,56 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): ifc_importer.create_style(element) + def store_opening_template_from_library( + self, + element: ifcopenshell.entity_instance, + library_file: ifcopenshell.file + ) -> None: + """ + Find an opening representation in the library and copy it to the current file + as a template. Store the template ID on the type for later retrieval. + """ + try: + library_element = library_file.by_guid(element.GlobalId) + except: + return + + # Find occurrences with openings in the library + library_occurrences = ifcopenshell.util.element.get_types(library_element) + + for occurrence in library_occurrences: + if not getattr(occurrence, "FillsVoids", None): + continue + + library_opening = occurrence.FillsVoids[0].RelatingOpeningElement + library_opening_rep = ifcopenshell.util.representation.get_representation( + library_opening, "Model", "Body", "MODEL_VIEW" + ) + + if not library_opening_rep: + continue + + # Check if mapped representation + if (library_opening_rep.RepresentationType == 'MappedRepresentation' and + len(library_opening_rep.Items) == 1 and + library_opening_rep.Items[0].is_a("IfcMappedItem")): + + mapped_rep = library_opening_rep.Items[0].MappingSource.MappedRepresentation + + # Store ALL representation types (Tessellation, SweptSolid, etc.) + template_rep = ifcopenshell.util.element.copy_deep( + self.file, + mapped_rep, + exclude=["IfcGeometricRepresentationContext"] + ) + + # Store reference in type's Description + current_desc = element.Description or "" + element.Description = f"{current_desc}||BonsaiOpeningTemplate:{template_rep.id()}" + return + break + + class EditProjectLibrary(bpy.types.Operator): bl_idname = "bim.edit_project_library" bl_label = "Edit Project Library" diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py index 22e8b42d81..109af267d6 100644 --- a/src/bonsai/bonsai/tool/root.py +++ b/src/bonsai/bonsai/tool/root.py @@ -91,16 +91,37 @@ class Root(bonsai.core.tool.Root): elif dest.is_a("IfcTypeProduct"): if not source.RepresentationMaps: return copied_entities - dest.RepresentationMaps = [ - ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), - m, - exclude=["IfcGeometricRepresentationContext"], - exclude_callback=exclude_callback, - copied_entities=copied_entities, - ) - for m in source.RepresentationMaps - ] + + # Copy representation maps while preserving mapped representation structures + new_maps = [] + for i, rep_map in enumerate(source.RepresentationMaps): + source_rep = rep_map.MappedRepresentation + + + # Copy the map itself + new_map = ifcopenshell.util.element.copy(tool.Ifc.get(), rep_map) + + # Handle the mapped representation - preserve mapping structure if present + if (source_rep.RepresentationType == 'MappedRepresentation' and + len(source_rep.Items) == 1 and + source_rep.Items[0].is_a("IfcMappedItem")): + # This is a mapped representation - preserve the structure + new_rep = ifcopenshell.util.element.copy(tool.Ifc.get(), source_rep) + new_rep.Items = [ifcopenshell.util.element.copy(tool.Ifc.get(), item) for item in source_rep.Items] + new_map.MappedRepresentation = new_rep + else: + # Not a mapped representation - use copy_deep as before + new_map.MappedRepresentation = ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), + source_rep, + exclude=["IfcGeometricRepresentationContext"], + exclude_callback=exclude_callback, + copied_entities=copied_entities, + ) + + new_maps.append(new_map) + + dest.RepresentationMaps = new_maps return copied_entities @classmethod