From 2e6f17ed0f3ef1fd6a301500ae6eef6c81befff4 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 24 Jun 2026 13:31:24 -0500 Subject: [PATCH] Preserve custom opening geometry via a type-level Reference template Custom IfcOpeningElement voids (e.g. an IfcPolygonalFaceSet / tessellation) were lost - reset to a default extrusion - on bim.duplicate_type, project append, and type switching, because the void lived only on occurrences and nothing carried it to a new type. Anchor the shared opening body on the filling type as a 'Reference' representation map (per IfcShapeRepresentation, 'Reference' is geometry "not part of the Body representation", used for opening geometries excluded from an implicit Boolean operation). bim.duplicate_type and append copy a type's RepresentationMaps, so the template survives; generate_opening_from_filling consults it before falling back to a generated extrusion. - map_type_representations: skip 'Reference' maps so occurrences don't receive the opening shape as their own Body geometry. - opening.py: get_/set_type_opening_representation, promote_opening_to_type, update_type_template_from_opening; pre/post type.assign_type listeners (anchor the old type's void before a switch; regenerate to the assigned type's void afterwards, replacing the previous "preserve custom" guard). - DuplicateType promotes the void before copy; AppendLibraryElement harvests the template cross-file from a library instance. - Write-back on void edit, hooked at both commit paths (UpdateRepresentation and OverrideModeSetObject). - reimport_element_representations renders the requested representation, so switching a type to its Reference row shows the void rather than the body. - Representations panel shows RepresentationIdentifier plus column headers. Co-Authored-By: Claude Opus 4.8 --- src/bonsai/bonsai/bim/module/geometry/data.py | 5 + .../bonsai/bim/module/geometry/operator.py | 19 +++ src/bonsai/bonsai/bim/module/geometry/ui.py | 12 ++ src/bonsai/bonsai/bim/module/model/handler.py | 6 + src/bonsai/bonsai/bim/module/model/opening.py | 157 ++++++++++++++++++ .../bonsai/bim/module/project/operator.py | 52 ++++++ src/bonsai/bonsai/bim/module/type/operator.py | 8 + src/bonsai/bonsai/tool/geometry.py | 18 +- src/bonsai/bonsai/tool/model.py | 7 + .../api/type/map_type_representations.py | 7 + 10 files changed, 290 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/geometry/data.py b/src/bonsai/bonsai/bim/module/geometry/data.py index f88eb967ec..0f4a349a08 100644 --- a/src/bonsai/bonsai/bim/module/geometry/data.py +++ b/src/bonsai/bonsai/bim/module/geometry/data.py @@ -138,6 +138,11 @@ class RepresentationsData: "ContextType": representation.ContextOfItems.ContextType or "", "ContextIdentifier": "", "TargetView": "", + # The representation's own identifier (e.g. 'Body', 'Reference'), which is + # distinct from the subcontext's ContextIdentifier above. Two reps can share + # one context (e.g. a Body body and a Reference opening template), so showing + # this lets them be told apart in the panel. + "RepresentationIdentifier": representation.RepresentationIdentifier or "", "RepresentationType": representation_type or "", "is_active": is_active, } diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 9bc0566532..dd98a1d87e 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -710,6 +710,16 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator): if mprops.ifc_parameters: core.get_representation_ifc_parameters(tool.Geometry, obj=obj) + # Persist an edited opening void onto its filling type's 'Reference' template so the + # change survives type duplication/append/switching and propagates to siblings. This + # catches the edited_objs commit path; the in-place item edit is caught in + # bim.override_mode_set_object. + edited_element = tool.Ifc.get_entity(obj) + if edited_element and edited_element.is_a("IfcOpeningElement"): + from bonsai.bim.module.model.opening import FilledOpeningGenerator + + FilledOpeningGenerator().update_type_template_from_opening(edited_element) + class UpdateParametricRepresentation(bpy.types.Operator): bl_idname = "bim.update_parametric_representation" @@ -2489,6 +2499,15 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): return bpy.ops.bim.edit_boundary_geometry() elif tool.Geometry.is_representation_item(context.active_object): self.edit_representation_item(context.active_object) + # If we just edited an opening's void item, persist the new shape onto the + # filling type's 'Reference' template so it survives type duplication/append/ + # switching and propagates to siblings. + rep_obj = tool.Geometry.get_geometry_props().representation_obj + edited_element = tool.Ifc.get_entity(rep_obj) if rep_obj else None + if edited_element and edited_element.is_a("IfcOpeningElement"): + from bonsai.bim.module.model.opening import FilledOpeningGenerator + + FilledOpeningGenerator().update_type_template_from_opening(edited_element) tool.Root.reload_item_decorator() # So you can keep hitting tab to cycle out of edit mode context.active_object.select_set(False) diff --git a/src/bonsai/bonsai/bim/module/geometry/ui.py b/src/bonsai/bonsai/bim/module/geometry/ui.py index 68e3e0fa47..64bcec29c8 100644 --- a/src/bonsai/bonsai/bim/module/geometry/ui.py +++ b/src/bonsai/bonsai/bim/module/geometry/ui.py @@ -148,11 +148,23 @@ class BIM_PT_representations(Panel): self.layout.label(text="No Representations Found") return + header = self.layout.row(align=True) + header.label(text="Context") + header.label(text="Subcontext") + header.label(text="View") + header.label(text="Identifier") + header.label(text="Type") + # Blank icon cells reserve the same width as the switch/remove buttons below so the + # text columns line up with the data rows. + header.label(text="", icon="BLANK1") + header.label(text="", icon="BLANK1") + for representation in RepresentationsData.data["representations"]: row = self.layout.row(align=True) row.label(text=representation["ContextType"]) row.label(text=representation["ContextIdentifier"]) row.label(text=representation["TargetView"]) + row.label(text=representation["RepresentationIdentifier"]) row.label(text=representation["RepresentationType"]) op = row.operator( "bim.switch_representation", diff --git a/src/bonsai/bonsai/bim/module/model/handler.py b/src/bonsai/bonsai/bim/module/model/handler.py index bfa064cda7..3c50f949e3 100644 --- a/src/bonsai/bonsai/bim/module/model/handler.py +++ b/src/bonsai/bonsai/bim/module/model/handler.py @@ -41,6 +41,12 @@ def load_post(*args): profile.DumbProfileRegenerator().regenerate_from_profile, ) + ifcopenshell.api.add_pre_listener( + "type.assign_type", + "Bonsai.Opening.PreserveOnTypeChange", + opening.FilledOpeningGenerator().preserve_opening_on_type_change, + ) + ifcopenshell.api.add_post_listener( "type.assign_type", "Bonsai.Opening.RegenerateFromType", diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index 0508883c35..4a59413759 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -420,6 +420,25 @@ class FilledOpeningGenerator: tool.Geometry.recut_host(voided_obj, representation) + def preserve_opening_on_type_change( + self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any] + ) -> None: + """Pre-listener for type.assign_type: anchor the old type's void before reassigning. + + A custom void that lives only on an occurrence (the type has no 'Reference' + template) would be lost when that occurrence is moved to another type - the + post-assign regeneration replaces it. Promoting it onto its current type first + keeps it durable, so switching back later restores it. Idempotent and only acts on + genuinely custom (non-extrusion) voids. + """ + relating_type = settings.get("relating_type") + for related_object in settings.get("related_objects") or []: + if not getattr(related_object, "FillsVoids", None): + continue + old_type = ifcopenshell.util.element.get_type(related_object) + if old_type and old_type != relating_type: + self.promote_opening_to_type(old_type) + def regenerate_from_type(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None: relating_type = settings["relating_type"] @@ -437,6 +456,13 @@ class FilledOpeningGenerator: opening = filling.FillsVoids[0].RelatingOpeningElement voided_element = opening.VoidsElements[0].RelatingBuildingElement + # Always regenerate the opening to reflect the *assigned* type's void: its + # 'Reference' template if it has one (generate_opening_from_filling consults it), + # else a sibling occurrence's opening, else a generated extrusion. We deliberately + # do NOT preserve the previous type's custom void on a type change - a custom void + # now survives duplicate_type/append by being anchored on the type as a template + # (promote_opening_to_type / harvest), so keeping the old void here would just show + # the wrong type's opening (e.g. switching to a plain type would keep the faceset). opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW") ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep) ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep) @@ -493,6 +519,14 @@ class FilledOpeningGenerator: profile = None filling_type = ifcopenshell.util.element.get_type(filling) if filling_type: + # A stored opening template (e.g. a custom IfcPolygonalFaceSet carried + # across bim.duplicate_type / append) takes priority over generating a + # default extrusion. Returning the shared template representation lets the + # caller's map_representation reuse its IfcRepresentationMap, so this + # opening stays in sync with the type template and its sibling occurrences. + opening_template = self.get_type_opening_representation(filling_type) + if opening_template is not None: + return opening_template profile = ifcopenshell.util.representation.get_representation( filling_type, "Model", "Profile", "ELEVATION_VIEW" ) @@ -590,6 +624,126 @@ class FilledOpeningGenerator: return True return False + def is_opening_representation_custom(self, opening: ifcopenshell.entity_instance) -> bool: + """Whether the opening's Body has user-authored geometry rather than a generated extrusion. + + Openings produced by ``generate_opening_from_filling`` always consist of a + single ``IfcExtrudedAreaSolid``. Anything else (a tessellation such as an + ``IfcPolygonalFaceSet``, a brep, a CSG solid, etc.) was authored by the user + and must not be silently replaced with a default extrusion. + """ + representation = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW") + if not representation: + return False + representation = ifcopenshell.util.representation.resolve_representation(representation) + return any(not item.is_a("IfcExtrudedAreaSolid") for item in representation.Items) + + def get_type_opening_representation( + self, filling_type: ifcopenshell.entity_instance + ) -> Union[ifcopenshell.entity_instance, None]: + """Return the type's stored opening template (its 'Reference' representation), if any. + + The template is the shared opening body anchored on the type as a + 'Reference'-identified representation map (see + :meth:`set_type_opening_representation`). Storing it on the type lets a + custom opening survive ``bim.duplicate_type`` and project append, which copy + the type's ``RepresentationMaps`` but not an opening shared only between + occurrences. + """ + for representation_map in filling_type.RepresentationMaps or []: + representation = representation_map.MappedRepresentation + if representation.RepresentationIdentifier == "Reference": + return representation + + def set_type_opening_representation( + self, filling_type: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance + ) -> None: + """Anchor an opening body representation on the type as its 'Reference' template. + + ``representation`` is tagged 'Reference' (so it is excluded from the + occurrence body geometry, see + ``ifcopenshell.api.type.map_type_representations``) and the + ``IfcRepresentationMap`` wrapping it is registered in the type's + ``RepresentationMaps``, replacing any previous 'Reference' map. The existing map + is reused when present so that occurrences mapping over it stay in sync with the + type template. Idempotent. + """ + ifc_file = tool.Ifc.get() + representation.RepresentationIdentifier = "Reference" + representation_map = next( + (i for i in ifc_file.get_inverse(representation) if i.is_a("IfcRepresentationMap")), None + ) + if representation_map is None: + mapping_origin = ifc_file.createIfcAxis2Placement3D( + ifc_file.createIfcCartesianPoint((0.0, 0.0, 0.0)), + ifc_file.createIfcDirection((0.0, 0.0, 1.0)), + ifc_file.createIfcDirection((1.0, 0.0, 0.0)), + ) + representation_map = ifc_file.createIfcRepresentationMap(mapping_origin, representation) + # Keep all non-'Reference' maps (Body, Annotation, ...) plus this one, dropping any + # previous 'Reference' template so the type carries exactly one. + new_maps = [ + m + for m in (filling_type.RepresentationMaps or []) + if m == representation_map or m.MappedRepresentation.RepresentationIdentifier != "Reference" + ] + if representation_map not in new_maps: + new_maps.append(representation_map) + filling_type.RepresentationMaps = new_maps + + def update_type_template_from_opening(self, opening: ifcopenshell.entity_instance) -> None: + """Write an edited opening's geometry back to its filling type's 'Reference' template. + + After a user edits an opening's void shape, anchor the new geometry on the type so + the change is durable (survives duplicate_type/append and switching the type away + and back) and propagates to sibling occurrences. Only acts on custom (non-extrusion) + geometry; a re-generated extrusion needs no template. + """ + if not getattr(opening, "HasFillings", None) or not self.is_opening_representation_custom(opening): + return + ifc_file = tool.Ifc.get() + new_representation = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW") + if not new_representation: + return + new_representation = ifcopenshell.util.representation.resolve_representation(new_representation) + for rel in opening.HasFillings: + filling_type = ifcopenshell.util.element.get_type(rel.RelatedBuildingElement) + if not filling_type: + continue + old_template = self.get_type_opening_representation(filling_type) + if old_template is not None and old_template != new_representation: + # The edit gave this opening its own geometry; re-point the shared template + # map - and therefore every sibling occurrence mapping over it - at the + # edited geometry, then drop the now-orphaned old template. + for inverse in ifc_file.get_inverse(old_template): + if inverse.is_a("IfcRepresentationMap"): + inverse.MappedRepresentation = new_representation + ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_template) + self.set_type_opening_representation(filling_type, new_representation) + + def promote_opening_to_type(self, filling_type: ifcopenshell.entity_instance) -> None: + """Promote a custom opening from an occurrence to a 'Reference' template on the type. + + Called before a type is copied (``bim.duplicate_type``) so that a custom + (non-extrusion) opening, currently shared only between occurrences, is + anchored on the type itself and therefore carried to the copy. No-op if the + type already has a template or has no custom opening to promote. + """ + if self.get_type_opening_representation(filling_type): + return + for occurrence in ifcopenshell.util.element.get_types(filling_type): + if not getattr(occurrence, "FillsVoids", None): + continue + opening = occurrence.FillsVoids[0].RelatingOpeningElement + if not self.is_opening_representation_custom(opening): + continue + representation = ifcopenshell.util.representation.get_representation( + opening, "Model", "Body", "MODEL_VIEW" + ) + representation = ifcopenshell.util.representation.resolve_representation(representation) + self.set_type_opening_representation(filling_type, representation) + return + def get_existing_opening_occurrence_if_any( self, filling: ifcopenshell.entity_instance ) -> Union[ifcopenshell.entity_instance, None]: @@ -1000,6 +1154,9 @@ class EditOpenings(Operator, tool.Ifc.Operator): building_objs.update(similar_openings_building_objs) if opening_edited: tool.Geometry.run_geometry_update_representation(obj=opening_obj) + # Persist the edited void onto the filling type's 'Reference' template so + # it survives type duplication/append/switching and propagates to siblings. + self.update_type_template_from_opening(opening_element) else: bonsai.core.geometry.edit_object_placement( tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index da5ee77fe2..e524008d9a 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -633,6 +633,7 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): if not element: return {"FINISHED"} if element.is_a("IfcTypeProduct"): + self.harvest_opening_template(element, library_file) self.import_type_from_ifc(element, context) elif element.is_a("IfcProduct"): # NOTE: Non-types are not exposed in UI directly @@ -658,6 +659,57 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): bonsai.bim.handler.refresh_ui_data() return {"FINISHED"} + def harvest_opening_template( + self, type_element: ifcopenshell.entity_instance, library_file: ifcopenshell.file + ) -> None: + """Seed the appended type's 'Reference' opening template from a library instance. + + A type carries no opening of its own (openings are occurrence-level via + IfcRelVoidsElement), so a custom opening would otherwise be lost on append and + regenerated as a default extrusion when occurrences are placed. If the library + file has an instance of this type whose opening is custom (non-extrusion), copy + that opening body onto the appended type as its 'Reference' template. No-op when + the type already carries a template (e.g. a Bonsai-authored library) or the + library has no such instance. + """ + from bonsai.bim.module.model.opening import FilledOpeningGenerator + + generator = FilledOpeningGenerator() + if generator.get_type_opening_representation(type_element): + return + + library_type = library_file.by_id(self.definition) + if not library_type.is_a("IfcTypeProduct"): + return + + for occurrence in ifcopenshell.util.element.get_types(library_type): + if not getattr(occurrence, "FillsVoids", None): + continue + opening = occurrence.FillsVoids[0].RelatingOpeningElement + library_representation = ifcopenshell.util.representation.get_representation( + opening, "Model", "Body", "MODEL_VIEW" + ) + if not library_representation: + continue + library_representation = ifcopenshell.util.representation.resolve_representation(library_representation) + if all(item.is_a("IfcExtrudedAreaSolid") for item in library_representation.Items): + continue # A generated extrusion - nothing custom worth preserving. + + project_file = tool.Ifc.get() + representation = project_file.add(library_representation) + # file.add brings the library's own representation context across; point the + # copy at the project's Body context and drop the now-orphaned duplicate. + body_context = ifcopenshell.util.representation.get_context( + project_file, "Model", "Body", "MODEL_VIEW" + ) + if body_context and representation.ContextOfItems != body_context: + orphan_context = representation.ContextOfItems + representation.ContextOfItems = body_context + if not project_file.get_inverse(orphan_context): + project_file.remove(orphan_context) + generator.set_type_opening_representation(type_element, representation) + return + def import_material_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None: self.file = tool.Ifc.get() logger = logging.getLogger("ImportIFC") diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index 7b306b1b31..69678ff394 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -375,6 +375,14 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator): obj = tool.Ifc.get_object(element) if not obj: return {"FINISHED"} + # Anchor any custom (non-extrusion) opening on the source type before the + # copy so it is carried to the duplicate as a 'Reference' template, rather + # than regenerated as a default extrusion on the new type's occurrences. + if element.is_a("IfcElementType"): + from bonsai.bim.module.model.opening import FilledOpeningGenerator + + FilledOpeningGenerator().promote_opening_to_type(element) + new_obj = obj.copy() if obj.data: new_obj.data = obj.data.copy() diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 6c195ecbab..0f6d7163a5 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -1206,7 +1206,23 @@ class Geometry(bonsai.core.tool.Geometry): for element in element_types: if obj := tool.Ifc.get_object(element): - if representation := ifcopenshell.util.representation.get_representation(element, context): + # A type may hold several representations in one context (e.g. a 'Body' body + # plus a 'Reference' opening template), and get_representation() matches only + # by context. When base_representation is one of this type's own + # representations - i.e. we are reimporting it directly, such as switching to + # the Reference rep - render exactly that, otherwise the context lookup could + # return the wrong one. But element_types also contains each occurrence's + # type (see above), for which base_representation is not theirs; fall back to + # the context lookup there (and skip, as before, when it has none). + type_representations = [ + ifcopenshell.util.representation.resolve_representation(rm.MappedRepresentation) + for rm in (element.RepresentationMaps or []) + ] + if base_representation in type_representations: + representation = base_representation + else: + representation = ifcopenshell.util.representation.get_representation(element, context) + if representation: geometry = ifcopenshell.geom.create_shape(settings, representation) mesh_name = tool.Loader.get_mesh_name_from_shape(geometry) mesh = meshes.get(mesh_name) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index dee8c218f2..29ea4f2e87 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -2124,6 +2124,13 @@ class Model(bonsai.core.tool.Model): if voided_obj is not None: voided_objs.add(voided_obj) + # Preserve user-authored opening geometry (e.g. an IfcPolygonalFaceSet + # or other tessellation) instead of replacing it with a default extrusion. + from bonsai.bim.module.model.opening import FilledOpeningGenerator + + if FilledOpeningGenerator().is_opening_representation_custom(opening): + continue + body = tool.Geometry.get_body_representation(opening) if body is None: continue diff --git a/src/ifcopenshell-python/ifcopenshell/api/type/map_type_representations.py b/src/ifcopenshell-python/ifcopenshell/api/type/map_type_representations.py index 5e2f7de989..fbe4ffd526 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/type/map_type_representations.py +++ b/src/ifcopenshell-python/ifcopenshell/api/type/map_type_representations.py @@ -94,6 +94,13 @@ def map_type_representations( ifcopenshell.api.geometry.remove_representation(file, representation=representation) for representation_map in relating_type.RepresentationMaps: representation = representation_map.MappedRepresentation + # 'Reference' representations are, per IfcShapeRepresentation, "not part of + # the Body representation" (used e.g. for opening geometries excluded from an + # implicit Boolean operation). They may be carried on a type purely as a + # template (e.g. a shared opening body) and must not be mapped onto + # occurrences as their own geometry. + if representation.RepresentationIdentifier == "Reference": + continue mapped_representation = ifcopenshell.api.geometry.map_representation(file, representation=representation) ifcopenshell.api.geometry.assign_representation( file,