From 36372627db32c88ad83d08e9354ddf800851949e Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 18 Apr 2026 11:57:28 -0500 Subject: [PATCH] Fix validate_type corruption; remove debug prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When validate_type selected a preferred_item from remaining_items (e.g. the sole IfcBooleanResult in a representation), it left that item in the list. The subsequent Items filter removed every item, leaving Items=[] and causing guess_type to return "MappedRepresentation" — silently corrupting the representation. Also removes temporary debug print statements added during investigation of the wall-to-slab extension workflow. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/core/model.py | 10 +++++++ src/bonsai/bonsai/tool/geometry.py | 25 ++++++++++++---- src/bonsai/bonsai/tool/model.py | 30 +++++++++++++++---- .../api/geometry/validate_type.py | 7 +++++ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index 947f60bc59..874675ea7f 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -176,6 +176,9 @@ def regenerate_wall_to_underside( continue if ifc.is_moved(obj): geometry.run_edit_object_placement(obj=obj) + # Sync each slab's Blender mesh to its current IFC representation before + # reading face geometry, so a changed profile is picked up correctly. + model.reload_body_representation(slab_objs) model.remove_wall_to_underside_booleans(wall) for slab_obj in slab_objs: clip = model.get_slab_clipping_bmesh(slab_obj) @@ -193,6 +196,13 @@ def extend_wall_to_slab( slab_objs: list[bpy.types.Object], wall_objs: list[bpy.types.Object], ) -> None: + # If any wall is currently in item mode, exit it before modifying the + # representation. Leaving stale item objects around causes delete_ifc_item + # to later remove the extrusion (or other pre-boolean items) from inside + # the boolean chain, corrupting the IFC model. + geom_props = geometry.get_geometry_props() + if geom_props.representation_obj in wall_objs: + geometry.disable_item_mode() clipped_walls = [] for obj in wall_objs: if ifc.is_moved(obj): diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index a57c9a0c7d..3cc9914951 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -257,7 +257,13 @@ class Geometry(bonsai.core.tool.Geometry): break mesh = obj.data assert isinstance(mesh, bpy.types.Mesh) - item = tool.Ifc.get().by_id(tool.Geometry.get_mesh_props(mesh).ifc_definition_id) + item_id = tool.Geometry.get_mesh_props(mesh).ifc_definition_id + try: + item = tool.Ifc.get().by_id(item_id) + except RuntimeError: + # Entity already deleted (e.g. removed as part of a sibling boolean collapse). + bpy.data.objects.remove(obj) + return rep_obj = props.representation_obj assert (rep_obj := props.representation_obj) and (rep_element := tool.Ifc.get_entity(rep_obj)) cls.remove_representation_item(item, rep_element) @@ -1157,11 +1163,16 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def get_representation_item(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: data = obj.data - if ( - isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES) - and (ifc_id := tool.Geometry.get_mesh_props(data).ifc_definition_id) - and ((item := tool.Ifc.get().by_id(ifc_id)).is_a("IfcRepresentationItem")) - ): + if not isinstance(data, Geometry.TYPES_WITH_MESH_PROPERTIES): + return None + ifc_id = tool.Geometry.get_mesh_props(data).ifc_definition_id + if not ifc_id: + return None + try: + item = tool.Ifc.get().by_id(ifc_id) + except RuntimeError: + return None + if item.is_a("IfcRepresentationItem"): return item return None @@ -1335,6 +1346,8 @@ class Geometry(bonsai.core.tool.Geometry): cls, representation: ifcopenshell.entity_instance ) -> ifcopenshell.entity_instance: if representation.RepresentationType == "MappedRepresentation": + if not representation.Items: + return representation return cls.resolve_mapped_representation(representation.Items[0].MappingSource.MappedRepresentation) return representation diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 210ce740ef..d8b78c599a 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -351,6 +351,8 @@ class Model(bonsai.core.tool.Model): @classmethod def get_extrusion(cls, representation: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: """Return first found IfcExtrudedAreaSolid""" + if not representation.Items: + return None item = representation.Items[0] while True: if item.is_a("IfcExtrudedAreaSolid"): @@ -871,11 +873,23 @@ class Model(bonsai.core.tool.Model): manual_booleans = cls.get_manual_booleans(wall) if not manual_booleans: return - mesh_operands = [ - b.SecondOperand for b in manual_booleans if b.SecondOperand.is_a("IfcTessellatedFaceSet") - ] - for mesh in mesh_operands: - tool.Geometry.remove_representation_item(mesh, wall) + ifc_file = tool.Ifc.get() + for b in manual_booleans: + sec = b.SecondOperand + if sec is None: + # The IfcPolygonalFaceSet was already deleted externally. Splice the + # orphaned IfcBooleanResult out of the chain so the representation stays valid. + parents = list(ifc_file.get_inverse(b)) + for parent in parents: + if parent.is_a("IfcBooleanResult") and parent.FirstOperand == b: + parent.FirstOperand = b.FirstOperand + elif parent.is_a("IfcShapeRepresentation"): + new_items = tuple((set(parent.Items) - {b}) | {b.FirstOperand}) + parent.Items = new_items + cls.unmark_manual_booleans(wall, [b.id()]) + ifc_file.remove(b) + elif sec.is_a("IfcTessellatedFaceSet"): + tool.Geometry.remove_representation_item(sec, wall) @classmethod def get_manual_booleans( @@ -889,7 +903,8 @@ class Model(bonsai.core.tool.Model): representation = tool.Geometry.get_body_representation(element) if not representation: return [] - booleans = [b for b in cls.get_booleans(element, representation) if b.id() in boolean_ids] + all_chain_booleans = cls.get_booleans(element, representation) + booleans = [b for b in all_chain_booleans if b.id() in boolean_ids] return booleans @classmethod @@ -2591,6 +2606,7 @@ class Model(bonsai.core.tool.Model): clipping_bm = bmesh.new() vertex_map = {} + kept = 0 for face in bm.faces: face.normal_update() normal = face.normal.to_4d() @@ -2598,6 +2614,7 @@ class Model(bonsai.core.tool.Model): world_normal_z = (obj.matrix_world @ normal).z if world_normal_z >= -0.5: continue + kept += 1 new_verts = [] for vert in face.verts: if not (new_vert := vertex_map.get(vert.index, None)): @@ -2688,6 +2705,7 @@ class Model(bonsai.core.tool.Model): extrusion.Depth = max_z / direction[2] if operands: + body_repr = ifcopenshell.util.representation.get_representation(wall, "Model", "Body", "MODEL_VIEW") booleans = ifcopenshell.api.geometry.add_boolean( ifc_file, first_item=extrusion, second_items=operands ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py index 3731b2fffc..39ea232e25 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py @@ -81,6 +81,13 @@ def validate_type( if not preferred_item and remaining_items: preferred_item = remaining_items[0] + # preferred_item must not appear in remaining_items — if it was selected from + # that list, leaving it in causes add_boolean to union it with itself, and the + # subsequent Items filter then removes ALL items (including preferred_item), + # leaving Items=[] which guess_type maps to "MappedRepresentation". + if preferred_item in remaining_items: + remaining_items = [i for i in remaining_items if i != preferred_item] + if remaining_items: ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION") representation.Items = [i for i in representation.Items if i not in remaining_items]