From a433f56337684e00b4050df4a74c1949f7652547 Mon Sep 17 00:00:00 2001 From: Tiago Azevedo <129018227+tiagoazvdo@users.noreply.github.com> Date: Fri, 29 May 2026 01:45:27 -0300 Subject: [PATCH 01/13] Fix sign of temporary offset restore in sweep_along_curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The temporary-offset workaround (#7408, commit bd57cc8735) subtracts the directrix centroid (`mean`) from the curve points before building the sweep near the origin, then must add it back to restore the original location. The restore negated the sign — `Move(-mean)` instead of `Move(+mean)` — placing the swept solid at -mean (mirrored through the origin) rather than its true position. Only triggers for polyline directrixes (`is_polyhedron()`) whose centroid is more than 100 m from the origin (`mean.norm() > 1e2`), so models centered near the origin are unaffected. Models that keep absolute site coordinates (e.g. many Revit/ODA IFC exports) render affected swept solids — reinforcing bars, pipes — at a mirrored phantom location far from the rest of the model. --- src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp b/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp index 510c8f182d..f5262662ea 100644 --- a/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp +++ b/src/ifcgeom/kernels/opencascade/sweep_along_curve.cpp @@ -300,7 +300,11 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo if (applied_temporary_offset) { gp_Trsf trsf; - trsf.SetTranslation(gp_Vec(-mean.x(), -mean.y(), -mean.z())); + // Restore original position: add back the mean subtracted from the + // directrix points above. Previously negated, which placed the swept + // solid at -mean instead of its original location for geometry far + // from the origin. + trsf.SetTranslation(gp_Vec(mean.x(), mean.y(), mean.z())); result.Move(trsf); } From 431cf435efc153e79dc1d2ddd44d3569f3fb633f Mon Sep 17 00:00:00 2001 From: falken10vdl <33285113+falken10vdl@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:02:28 +0200 Subject: [PATCH 02/13] Fix assign_container in spatial.py (#8079) ifc.get_object(element) can return None for IFC elements that aren't loaded as Blender objects (e.g., decomposed sub-elements). The loop now skips those instead of passing None into collector.assign(). Cheers! --- src/bonsai/bonsai/core/spatial.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 5ce6d7c253..3af14821a2 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -67,7 +67,8 @@ def assign_container( if products := [e for e in root_elements if spatial.can_contain(container, root_element)]: ifc.run("spatial.assign_container", products=products, relating_structure=container) for element in all_elements: - collector.assign(ifc.get_object(element)) + if obj := ifc.get_object(element): + collector.assign(obj) def enable_editing_container(spatial: type[tool.Spatial], obj: bpy.types.Object) -> None: From e142b9d7b4451999ee72bd8cf395f7dd920db9de Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 16 Apr 2026 22:00:56 -0500 Subject: [PATCH 03/13] Fix extend_walls_to_underside ridge artifact When the operator was called twice on the same wall for a ridge roof, the two IfcPolygonalFaceSet clip solids shared an exact ridge edge (kissing-solid). OCCT produced spurious extra vertices at the coincident boundary. Fix by building the clip solid from a rectangle on the slope plane that extends slightly past the face edge (1 project unit margin) rather than the exact face footprint. Adjacent slope solids now volumetrically overlap at the ridge instead of sharing a boundary face, which OCCT handles correctly. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 4 +- src/bonsai/bonsai/core/model.py | 5 +- src/bonsai/bonsai/tool/model.py | 62 +++++++++++++++++----- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index a0658574ff..8ec429b333 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -305,7 +305,9 @@ class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator): if (obj := tool.Blender.get_active_object(is_selected=True)) and (element := tool.Ifc.get_entity(obj)): slab = obj for obj in tool.Blender.get_selected_objects(include_active=False): - if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2": + element = tool.Ifc.get_entity(obj) + usage = tool.Model.get_usage_type(element) if element else None + if element and usage == "LAYER2": walls.append(obj) if slab and walls: core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slab, walls) diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index fe289cbda1..61f46cf789 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -168,8 +168,9 @@ def extend_wall_to_slab( slab_obj: bpy.types.Object, wall_objs: list[bpy.types.Object], ) -> None: - if not (clip := model.get_slab_clipping_bmesh(slab_obj)): - return # Nothing to clip? + clip = model.get_slab_clipping_bmesh(slab_obj) + if not clip: + return slab = ifc.get_entity(slab_obj) for obj in wall_objs: if ifc.is_moved(obj): diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index f059b32b6c..2bae0b5a89 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -2561,7 +2561,8 @@ class Model(bonsai.core.tool.Model): face.normal_update() normal = face.normal.to_4d() normal.w = 0 - if (obj.matrix_world @ normal).z >= -0.5: + world_normal_z = (obj.matrix_world @ normal).z + if world_normal_z >= -0.5: continue new_verts = [] for vert in face.verts: @@ -2575,6 +2576,7 @@ class Model(bonsai.core.tool.Model): return bmesh.ops.recalc_face_normals(clipping_bm, faces=clipping_bm.faces) + clipping_bm.faces.ensure_lookup_table() return clipping_bm # clipping_bm is in project units @classmethod @@ -2588,17 +2590,53 @@ class Model(bonsai.core.tool.Model): min_z = min(zs) max_z = max(zs) - operand = None - if (z := max_z - min_z) and not np.isclose(z, 0.0): - builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) + ifc_file = tool.Ifc.get() + builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file) - result = bmesh.ops.extrude_face_region(bm, geom=bm.faces) - extruded_verts = [elem for elem in result["geom"] if isinstance(elem, bmesh.types.BMVert)] - bmesh.ops.translate(bm, verts=extruded_verts, vec=(0, 0, z)) + # Build one IfcPolygonalFaceSet clip solid per clipping face. + # Each solid uses a rectangle on the slope plane rather than the exact face + # footprint. The original approach (exact footprint) caused a kissing-solid / + # boundary-coincidence bug when the operator is called twice for a ridge roof: the + # two slope solids share an exact ridge edge, and OCCT produces spurious extra + # vertices. Extending each solid slightly past the ridge (by margin) creates a + # volumetric overlap instead of a kissing boundary — OCCT handles overlapping + # DIFFERENCE operands correctly. + margin = 1.0 # project units past the face edge — enough to ensure overlap at ridge + operands = [] + for face in bm.faces: + face.normal_update() + normal = Vector(face.normal).normalized() - verts = [v.co for v in bm.verts] - faces = [[v.index for v in p.verts] for p in bm.faces] - operand = builder.mesh(verts, faces) + # Orthonormal basis spanning the slope plane. + ref = Vector((0, 0, 1)) if abs(normal.z) < 0.9 else Vector((1, 0, 0)) + tangent1 = normal.cross(ref).normalized() + tangent2 = normal.cross(tangent1).normalized() + + centroid = sum((v.co for v in face.verts), Vector()) / len(face.verts) + + # Tight bounding rectangle in slope-plane coords, plus a small margin. + t1_coords = [(v.co - centroid).dot(tangent1) for v in face.verts] + t2_coords = [(v.co - centroid).dot(tangent2) for v in face.verts] + half1 = max(abs(c) for c in t1_coords) + margin + half2 = max(abs(c) for c in t2_coords) + margin + + # Rectangle on the slope plane, extruded upward in wall-local Z. + clip_bm = bmesh.new() + v0 = clip_bm.verts.new(centroid + half1 * tangent1 + half2 * tangent2) + v1 = clip_bm.verts.new(centroid - half1 * tangent1 + half2 * tangent2) + v2 = clip_bm.verts.new(centroid - half1 * tangent1 - half2 * tangent2) + v3 = clip_bm.verts.new(centroid + half1 * tangent1 - half2 * tangent2) + bottom_face = clip_bm.faces.new([v0, v1, v2, v3]) + result = bmesh.ops.extrude_face_region(clip_bm, geom=[bottom_face]) + top_verts = [e for e in result["geom"] if isinstance(e, bmesh.types.BMVert)] + bmesh.ops.translate(clip_bm, verts=top_verts, vec=Vector((0, 0, max_z - min_z))) + clip_bm.verts.ensure_lookup_table() + + clip_verts = [v.co for v in clip_bm.verts] + clip_faces = [[v.index for v in f.verts] for f in clip_bm.faces] + operand = builder.mesh(clip_verts, clip_faces) + clip_bm.free() + operands.append(operand) for extrusion in ifcopenshell.util.shape.get_base_extrusions(wall) or []: if extrusion.Position: @@ -2615,9 +2653,9 @@ class Model(bonsai.core.tool.Model): extrusion.Depth = max_z / direction[2] - if operand: + if operands: booleans = ifcopenshell.api.geometry.add_boolean( - tool.Ifc.get(), first_item=extrusion, second_items=[operand] + ifc_file, first_item=extrusion, second_items=operands ) tool.Model.mark_manual_booleans(wall, booleans) From 08e33fe572613c55f973dbb33d8982304d1fc5e9 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 16 Apr 2026 22:33:47 -0500 Subject: [PATCH 04/13] Closes #7943: Add regenerate_wall_to_underside operator When extend_walls_to_underside is applied to a wall and the roof/slab is later moved, pressing Shift+G now re-clips the wall to the slab's new position. The IFC relationship created by connect_wall_to_slab (IfcRelConnectsElements, Description="TOP") is used to look up which slabs a wall is clipped to. On regeneration, the existing manual booleans (IfcPolygonalFaceSet operands) are cleanly removed via remove_representation_item, then clip_wall_to_slab is re-applied for each connected slab. Shift+G on a LAYER2 wall that has a TOP connection now calls bim.regenerate_wall_to_underside; walls without a connection continue to call bim.recalculate_wall as before. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/model/__init__.py | 1 + src/bonsai/bonsai/bim/module/model/wall.py | 18 +++++++++++++ .../bonsai/bim/module/model/workspace.py | 8 +++++- src/bonsai/bonsai/core/model.py | 25 +++++++++++++++++++ src/bonsai/bonsai/core/tool.py | 2 ++ src/bonsai/bonsai/tool/model.py | 23 +++++++++++++++++ 6 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 5e2ef55e5c..c281e14ec0 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -85,6 +85,7 @@ classes = ( wall.EnableEditingWall, wall.ExtendWallHeightToCursor, wall.ExtendWallsToUnderside, + wall.RegenerateWallToUnderside, wall.ExtendWallsToWall, wall.ExtendWallsToPolylinePoint, wall.ExtendWallToCursor, diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 8ec429b333..ce86b97e26 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -316,6 +316,24 @@ class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator): self.report({"ERROR"}, "Please select at least one LAYER2 element and an active element") +class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.regenerate_wall_to_underside" + bl_label = "Regenerate Wall to Underside" + bl_description = "Re-clip selected walls to their connected underside objects after the slab has moved" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + wall_objs = [ + obj + for obj in tool.Blender.get_selected_objects() + if (element := tool.Ifc.get_entity(obj)) and tool.Model.get_usage_type(element) == "LAYER2" + ] + if wall_objs: + core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs) + else: + self.report({"ERROR"}, "Please select at least one LAYER2 element") + + class ExtendWallsToWall(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.extend_walls_to_wall" bl_label = "Extend Walls To Wall" diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 0d9e6305ad..a6e5794c11 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -1294,7 +1294,13 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.generate_space() return if self.active_material_usage == "LAYER2": - bpy.ops.bim.recalculate_wall() + if element and any( + rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" + for rel in element.ConnectedFrom + ): + bpy.ops.bim.regenerate_wall_to_underside() + else: + bpy.ops.bim.recalculate_wall() elif self.active_material_usage == "LAYER3": bpy.ops.bim.recalculate_slab() elif tool.System.get_ports(element): diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index 61f46cf789..6b62b076e2 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -161,6 +161,31 @@ def align_objects( model.align_objects(reference_obj, objs, align_type) +def regenerate_wall_to_underside( + ifc: type[tool.Ifc], + geometry: type[tool.Geometry], + model: type[tool.Model], + wall_objs: list[bpy.types.Object], +) -> None: + """Re-clip walls to their connected underside objects after the slab has moved.""" + clipped_objs = [] + for obj in wall_objs: + wall = ifc.get_entity(obj) + slab_objs = model.get_connected_slab_objs(wall) + if not slab_objs: + continue + if ifc.is_moved(obj): + geometry.run_edit_object_placement(obj=obj) + model.remove_wall_to_underside_booleans(wall) + for slab_obj in slab_objs: + clip = model.get_slab_clipping_bmesh(slab_obj) + if clip: + model.clip_wall_to_slab(wall, clip) + clipped_objs.append(obj) + if clipped_objs: + model.reload_body_representation(clipped_objs) + + def extend_wall_to_slab( ifc: type[tool.Ifc], geometry: type[tool.Geometry], diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index d3260fa278..f1b1807bbd 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -681,6 +681,7 @@ class Model: def export_profile(cls, obj, position=None): pass def generate_occurrence_name(cls, element_type, ifc_class): pass def get_extrusion(cls, representation): pass + def get_connected_slab_objs(cls, wall): pass def get_manual_booleans(cls, element): pass def get_material_layer_parameters(cls, element): pass def get_slab_clipping_bmesh(cls, obj): pass @@ -696,6 +697,7 @@ class Model: def regenerate_profile(cls, obj): pass def regenerate_slab(cls, obj): pass def reload_body_representation(cls, obj_or_objects): pass + def remove_wall_to_underside_booleans(cls, wall): pass def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 2bae0b5a89..502b7fb01d 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -843,6 +843,29 @@ class Model(bonsai.core.tool.Model): items.append(item.FirstOperand) return booleans + @classmethod + def get_connected_slab_objs(cls, wall: ifcopenshell.entity_instance) -> list[bpy.types.Object]: + """Return Blender objects for slabs connected to wall via IfcRelConnectsElements(TOP).""" + result = [] + for rel in wall.ConnectedFrom: + if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP": + slab_obj = tool.Ifc.get_object(rel.RelatingElement) + if slab_obj: + result.append(slab_obj) + return result + + @classmethod + def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None: + """Remove all IfcBooleanResult items previously added by extend_walls_to_underside.""" + 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) + @classmethod def get_manual_booleans( cls, element: ifcopenshell.entity_instance, representation: Optional[ifcopenshell.entity_instance] = None From 187b8e7167fa933ab727d091c57647cf8877eb35 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 16 Apr 2026 22:44:53 -0500 Subject: [PATCH 05/13] Add extend/regenerate walls to multiple undersides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extend_walls_to_underside now accepts multiple slab/roof objects in a single operation — all selected non-LAYER2 IFC elements are treated as clip targets, all LAYER2 elements as walls. Placement sync is done once upfront; each wall is then clipped against every selected slab before reloading. Also adds bim.regenerate_wall_to_underside (Shift+G): after moving a slab, re-clips connected walls using the existing IfcRelConnectsElements(TOP) relationship. Old booleans are removed via remove_representation_item before re-clipping. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/wall.py | 19 +++++++++-------- src/bonsai/bonsai/core/model.py | 24 ++++++++++++++-------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index ce86b97e26..907a973735 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -300,20 +300,21 @@ class ExtendWallsToUnderside(bpy.types.Operator, tool.Ifc.Operator): # of the selected walls has an in-progress parametric draft, commit it before # extending, so the slab clip operates on the just-finalised IFC state. _commit_pending_wall_edits_for_selection(context) - slab = None + slabs: list[bpy.types.Object] = [] walls: list[bpy.types.Object] = [] - if (obj := tool.Blender.get_active_object(is_selected=True)) and (element := tool.Ifc.get_entity(obj)): - slab = obj - for obj in tool.Blender.get_selected_objects(include_active=False): + for obj in tool.Blender.get_selected_objects(): element = tool.Ifc.get_entity(obj) - usage = tool.Model.get_usage_type(element) if element else None - if element and usage == "LAYER2": + if not element: + continue + if tool.Model.get_usage_type(element) == "LAYER2": walls.append(obj) - if slab and walls: - core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slab, walls) + else: + slabs.append(obj) + if slabs and walls: + core.extend_wall_to_slab(tool.Ifc, tool.Geometry, tool.Model, slabs, walls) _resync_walls_after_mutation(walls) else: - self.report({"ERROR"}, "Please select at least one LAYER2 element and an active element") + self.report({"ERROR"}, "Please select at least one LAYER2 element and at least one other IFC element") class RegenerateWallToUnderside(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index 6b62b076e2..d93f14fe93 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -190,20 +190,26 @@ def extend_wall_to_slab( ifc: type[tool.Ifc], geometry: type[tool.Geometry], model: type[tool.Model], - slab_obj: bpy.types.Object, + slab_objs: list[bpy.types.Object], wall_objs: list[bpy.types.Object], ) -> None: - clip = model.get_slab_clipping_bmesh(slab_obj) - if not clip: - return - slab = ifc.get_entity(slab_obj) for obj in wall_objs: if ifc.is_moved(obj): geometry.run_edit_object_placement(obj=obj) - wall = ifc.get_entity(obj) - model.clip_wall_to_slab(wall, clip) - model.connect_wall_to_slab(wall, slab) - model.reload_body_representation(wall_objs) + clipped_walls = [] + for slab_obj in slab_objs: + clip = model.get_slab_clipping_bmesh(slab_obj) + if not clip: + continue + slab = ifc.get_entity(slab_obj) + for obj in wall_objs: + wall = ifc.get_entity(obj) + model.clip_wall_to_slab(wall, clip) + model.connect_wall_to_slab(wall, slab) + if obj not in clipped_walls: + clipped_walls.append(obj) + if clipped_walls: + model.reload_body_representation(clipped_walls) class RequireTwoWallsError(Exception): From 9e7d97e2987412258675d2b275fd24e51b8b528b Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 17 Apr 2026 08:04:00 -0500 Subject: [PATCH 06/13] Regenerate connected walls when recalculating a slab When Shift+G is pressed on a LAYER3 element, any LAYER2 walls connected via IfcRelConnectsElements(TOP) are now re-clipped to the slab's updated geometry after recalculate_slab runs. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/workspace.py | 3 +++ src/bonsai/bonsai/core/tool.py | 1 + src/bonsai/bonsai/tool/model.py | 11 +++++++++++ 3 files changed, 15 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index a6e5794c11..5b08563dab 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -1303,6 +1303,9 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.recalculate_wall() elif self.active_material_usage == "LAYER3": bpy.ops.bim.recalculate_slab() + wall_objs = tool.Model.get_connected_wall_objs(element) + if wall_objs: + core.regenerate_wall_to_underside(tool.Ifc, tool.Geometry, tool.Model, wall_objs) elif tool.System.get_ports(element): bpy.ops.bim.regenerate_distribution_element() elif self.active_material_usage == "PROFILE": diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index f1b1807bbd..8b94b5dc21 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -682,6 +682,7 @@ class Model: def generate_occurrence_name(cls, element_type, ifc_class): pass def get_extrusion(cls, representation): pass def get_connected_slab_objs(cls, wall): pass + def get_connected_wall_objs(cls, slab): pass def get_manual_booleans(cls, element): pass def get_material_layer_parameters(cls, element): pass def get_slab_clipping_bmesh(cls, obj): pass diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 502b7fb01d..210ce740ef 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -854,6 +854,17 @@ class Model(bonsai.core.tool.Model): result.append(slab_obj) return result + @classmethod + def get_connected_wall_objs(cls, slab: ifcopenshell.entity_instance) -> list[bpy.types.Object]: + """Return Blender objects for LAYER2 walls connected to slab via IfcRelConnectsElements(TOP).""" + result = [] + for rel in slab.ConnectedTo: + if rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP": + wall_obj = tool.Ifc.get_object(rel.RelatedElement) + if wall_obj: + result.append(wall_obj) + return result + @classmethod def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None: """Remove all IfcBooleanResult items previously added by extend_walls_to_underside.""" From a9b6f02f029b466b3b2c682a62c51635f76c06f9 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 17 Apr 2026 08:10:12 -0500 Subject: [PATCH 07/13] Fix duplicate booleans in extend_walls_to_underside Re-running the operator on the same wall/slab pair created additional IfcPolygonalFaceSet booleans each time. Now each wall's existing booleans are removed before re-clipping, and previously connected slabs are merged with the new selection so no earlier clips are silently discarded. Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/core/model.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index d93f14fe93..947f60bc59 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -193,21 +193,29 @@ def extend_wall_to_slab( slab_objs: list[bpy.types.Object], wall_objs: list[bpy.types.Object], ) -> None: + clipped_walls = [] for obj in wall_objs: if ifc.is_moved(obj): geometry.run_edit_object_placement(obj=obj) - clipped_walls = [] - for slab_obj in slab_objs: - clip = model.get_slab_clipping_bmesh(slab_obj) - if not clip: - continue - slab = ifc.get_entity(slab_obj) - for obj in wall_objs: - wall = ifc.get_entity(obj) + wall = ifc.get_entity(obj) + # Merge previously connected slabs with newly requested ones so that + # re-running the operator never produces duplicate booleans and never + # silently discards clips that were applied in an earlier call. + existing = model.get_connected_slab_objs(wall) + seen = {id(s) for s in existing} + all_slab_objs = list(existing) + [s for s in slab_objs if id(s) not in seen] + # Remove stale booleans once, then re-clip against the full set. + model.remove_wall_to_underside_booleans(wall) + did_clip = False + for slab_obj in all_slab_objs: + clip = model.get_slab_clipping_bmesh(slab_obj) + if not clip: + continue model.clip_wall_to_slab(wall, clip) - model.connect_wall_to_slab(wall, slab) - if obj not in clipped_walls: - clipped_walls.append(obj) + model.connect_wall_to_slab(wall, ifc.get_entity(slab_obj)) + did_clip = True + if did_clip: + clipped_walls.append(obj) if clipped_walls: model.reload_body_representation(clipped_walls) From 36372627db32c88ad83d08e9354ddf800851949e Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 18 Apr 2026 11:57:28 -0500 Subject: [PATCH 08/13] 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] From 1c128a2d6ad81902b44f2ab5ecba26d0c7d7eb17 Mon Sep 17 00:00:00 2001 From: falken10vdl Date: Wed, 20 May 2026 08:53:57 +0200 Subject: [PATCH 09/13] Add has_underside_connection method to Model class and update wall regeneration logic --- src/bonsai/bonsai/bim/module/model/workspace.py | 5 +---- src/bonsai/bonsai/core/tool.py | 1 + src/bonsai/bonsai/tool/model.py | 9 ++++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 5b08563dab..cd1fc449d0 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -1294,10 +1294,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.generate_space() return if self.active_material_usage == "LAYER2": - if element and any( - rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" - for rel in element.ConnectedFrom - ): + if element and tool.Model.has_underside_connection(element): bpy.ops.bim.regenerate_wall_to_underside() else: bpy.ops.bim.recalculate_wall() diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 8b94b5dc21..4c5df8edf1 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -683,6 +683,7 @@ class Model: def get_extrusion(cls, representation): pass def get_connected_slab_objs(cls, wall): pass def get_connected_wall_objs(cls, slab): pass + def has_underside_connection(cls, element): pass def get_manual_booleans(cls, element): pass def get_material_layer_parameters(cls, element): pass def get_slab_clipping_bmesh(cls, obj): pass diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index d8b78c599a..a697add69e 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -867,6 +867,11 @@ class Model(bonsai.core.tool.Model): result.append(wall_obj) return result + @classmethod + def has_underside_connection(cls, element: ifcopenshell.entity_instance) -> bool: + """Return True if element has an IfcRelConnectsElements(TOP) relationship.""" + return any(rel.is_a("IfcRelConnectsElements") and rel.Description == "TOP" for rel in element.ConnectedFrom) + @classmethod def remove_wall_to_underside_booleans(cls, wall: ifcopenshell.entity_instance) -> None: """Remove all IfcBooleanResult items previously added by extend_walls_to_underside.""" @@ -2706,9 +2711,7 @@ class Model(bonsai.core.tool.Model): 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 - ) + booleans = ifcopenshell.api.geometry.add_boolean(ifc_file, first_item=extrusion, second_items=operands) tool.Model.mark_manual_booleans(wall, booleans) @classmethod From 0d7c378db541b760748999c246ef9336f2f76c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Mon, 1 Jun 2026 09:53:54 -0300 Subject: [PATCH 10/13] Lazy BVH tree construction in SnapObj --- src/bonsai/bonsai/tool/raycast.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index dc45b4e9bb..327ebe6322 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -377,6 +377,7 @@ class Raycast(bonsai.core.tool.Raycast): view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d ] # Numpy version is worst in performance + snap_obj._ensure_bvh() intersected = snap_obj.raycast_boxes( context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction) ) @@ -936,12 +937,19 @@ class SnapObj: def __init__(self, obj: bpy.types.Object): self.__class__.all.append(self) self.obj = obj - self.root = self._create_root_node() - self.root.edges = [e.index for e in obj.data.edges] - self.split_box(self.root, 0) + self.root = None + self._bvh_built = False self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices] self.snap_points = [] + def _ensure_bvh(self): + if self._bvh_built: + return + self.root = self._create_root_node() + self.root.edges = [e.index for e in self.obj.data.edges] + self.split_box(self.root, 0) + self._bvh_built = True + def __clear_all__(): for instance in SnapObj.all: del instance From 1daee04d9c806159f4f5b0ea83974ba7d05cc57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Mon, 1 Jun 2026 22:18:13 -0300 Subject: [PATCH 11/13] Early-terminate solid raycasts in non-xray mode --- src/bonsai/bonsai/tool/raycast.py | 133 ++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 33 deletions(-) diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index 327ebe6322..f5440f9a80 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -800,6 +800,30 @@ class Raycast(bonsai.core.tool.Raycast): else: return None, None, None + @classmethod + def process_wireframe_snap_obj( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + snap_obj, + ray_origin: Vector, + closest_snaps: list, + ): + snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj) + hit_obj = None + hit = None + if snap_points: + closest_length_squared = float("inf") + for point in snap_points: + point["group"] = "Wireframe" + closest_snaps.append(point) + length = (point["point"] - ray_origin).length_squared + if length < closest_length_squared: + closest_length_squared = length + hit = point["point"] + hit_obj = point["object"] + return hit_obj, hit + @classmethod def ray_cast_and_get_closest_to_camera_snaps( cls, @@ -814,35 +838,45 @@ class Raycast(bonsai.core.tool.Raycast): ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) + space = context.space_data + xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or ( + space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe + ) + closest_snaps = [] - hit = None - for snap_obj in objs_to_raycast: - if snap_obj.obj.type in {"EMPTY", "CURVE"} or ( - hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0 - ): - # For wireframe objects we have to test all the snaps to see which is closer - snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj) - closest_wf_hit = None - closest_wf_length_squared = 1.0 - closest_wf_point = None - if snap_points: - for point in snap_points: - point["group"] = "Wireframe" - closest_snaps.append(point) - length = (point["point"] - ray_origin).length_squared - if closest_wf_hit is None or length < closest_wf_length_squared: - closest_wf_length_squared = length - closest_wf_hit = point["point"] - closest_wf_point = point + if not xray_mode and objs_to_raycast: + # Non-xray - only the closest solid object's Face snap is kept by + # the caller (detect_snapping_points). Process solids in distance + # order and stop at the first hit to minimise raycasts. + wireframe_objs = [] + solid_objs = [] + for snap_obj in objs_to_raycast: + if snap_obj.obj.type in {"EMPTY", "CURVE"} or ( + hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0 + ): + wireframe_objs.append(snap_obj) + else: + solid_objs.append(snap_obj) - if closest_wf_point: - hit_obj = closest_wf_point["object"] - hit = closest_wf_point["point"] - face_index = None + # Rough distance - object origin to ray origin + solid_objs.sort(key=lambda so: (so.obj.matrix_world.translation - ray_origin).length_squared) - else: - # Solid objects + # Process wireframe objects first (all of them, always collected) + for snap_obj in wireframe_objs: + hit_obj, hit = cls.process_wireframe_snap_obj( + context, event, snap_obj, ray_origin, closest_snaps + ) + if hit is not None: + length_squared = (hit - ray_origin).length_squared + if closest_obj is None or length_squared < closest_length_squared: + closest_length_squared = length_squared + closest_obj = hit_obj + closest_hit = hit + closest_face_index = None + + # Process solid objects in distance order, stop at first hit + for snap_obj in solid_objs: hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj) if hit: @@ -856,14 +890,47 @@ class Raycast(bonsai.core.tool.Raycast): } closest_snaps.append(snap_point) - # Here we test which is closer, including wireframe and solid objects - if hit is not None: - length_squared = (hit - ray_origin).length_squared - if closest_obj is None or length_squared < closest_length_squared: - closest_length_squared = length_squared - closest_obj = hit_obj - closest_hit = hit - closest_face_index = face_index + length_squared = (hit - ray_origin).length_squared + if closest_obj is None or length_squared < closest_length_squared: + closest_length_squared = length_squared + closest_obj = hit_obj + closest_hit = hit + closest_face_index = face_index + + break + + else: + # Xray mode - process all objects (all snaps are kept by the caller) + for snap_obj in objs_to_raycast: + if snap_obj.obj.type in {"EMPTY", "CURVE"} or ( + hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0 + ): + hit_obj, hit = cls.process_wireframe_snap_obj( + context, event, snap_obj, ray_origin, closest_snaps + ) + face_index = None + else: + # Solid objects + hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj) + + if hit: + snap_point = { + "point": hit, + "type": "Face", + "group": "Object", + "object": hit_obj, + "face_index": face_index, + "distance": 9, # High value so it has low priority + } + closest_snaps.append(snap_point) + + if hit is not None: + length_squared = (hit - ray_origin).length_squared + if closest_obj is None or length_squared < closest_length_squared: + closest_length_squared = length_squared + closest_obj = hit_obj + closest_hit = hit + closest_face_index = face_index # Label snaps from the closest object if closest_obj is not None: From 5dde402f8b479f69e7f9b12c8b5f51688b44c6db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Mon, 1 Jun 2026 22:27:58 -0300 Subject: [PATCH 12/13] Optimize 2D projection in ray_cast_by_proximity_2d --- src/bonsai/bonsai/tool/raycast.py | 47 ++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index f5440f9a80..2ce5d4bca4 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -373,27 +373,42 @@ class Raycast(bonsai.core.tool.Raycast): except: loc = Vector((0, 0, 0)) - verts_2d = [ - view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d - ] # Numpy version is worst in performance snap_obj._ensure_bvh() intersected = snap_obj.raycast_boxes( context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction) ) + + # Collect edges from intersected BVH boxes edges = [] for it in intersected: edges.extend(it.edges) edges = set(edges) + # Build only the vertices indices that belong to these edges + verts_idx: set[int] = set() + for e in edges: + ev = snap_obj.obj.data.edges[e].vertices + verts_idx.add(ev[0]) + verts_idx.add(ev[1]) + + # Lazily project only the needed vertices to 2D screen space + verts_2d: dict[int, Vector] = {} + for idx in verts_idx: + v2d = view3d_utils.location_3d_to_region_2d( + region, rv3d, snap_obj.verts_3d[idx] + ) + if v2d is not None: + verts_2d[idx] = v2d + + edge_verts = {} for e in edges: - verts_idx = tuple(snap_obj.obj.data.edges[e].vertices) - verts = snap_obj.obj.data.vertices - v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co - v1_2d = verts_2d[verts_idx[0]] - v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co - v2_2d = verts_2d[verts_idx[1]] + verts_idx = snap_obj.obj.data.edges[e].vertices + v1 = snap_obj.verts_3d[verts_idx[0]] + v2 = snap_obj.verts_3d[verts_idx[1]] + v1_2d = verts_2d.get(verts_idx[0]) + v2_2d = verts_2d.get(verts_idx[1]) if (v1_2d is None) ^ (v2_2d is None): point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2) if v1_2d is None: @@ -405,10 +420,16 @@ class Raycast(bonsai.core.tool.Raycast): snap_threshold = 10.0 - for i, point in enumerate(verts_2d): - if not point: - continue - distance = (Vector(mouse_pos) - point).length + # Check all vertices for proximity to mouse position. + # Re-use the 2D projections already computed for edge endpoints. + for i, v3d in enumerate(snap_obj.verts_3d): + if i in verts_2d: + v2d = verts_2d[i] + else: + v2d = view3d_utils.location_3d_to_region_2d(region, rv3d, v3d) + if v2d is None: + continue + distance = (Vector(mouse_pos) - v2d).length if distance <= snap_threshold: snap_point = { "object": snap_obj.obj, From f158ae7377871b0e3821f9100b84e1401d618508 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 1 Jun 2026 15:01:13 -0500 Subject: [PATCH 13/13] Fix crash in update_bim_tool_props when selected type isn't a valid ifc_class props.ifc_class is an EnumProperty whose items list only the element/space types present in the model. Assigning element_type.is_a() crashed with `enum "" not found` when the selected element's type wasn't a member (e.g. a raw IfcTypeProduct, or a stale item list mid-rebuild), aborting the post-commit refresh. Wrap the assignment in the same try/except TypeError guard already used for the sibling relating_type_id assignments (added in 233cc344fa). Co-Authored-By: Claude Opus 4.8 --- src/bonsai/bonsai/bim/handler.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index ff916bf8a9..47e488b68e 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -151,7 +151,14 @@ def update_bim_tool_props(): return if is_bim_tool: - props.ifc_class = element_type.is_a() + try: + props.ifc_class = element_type.is_a() + except TypeError: + # ifc_class only lists element/space types present in the model, so an + # unsupported type (e.g. a raw IfcTypeProduct) or a stale item list mid- + # rebuild raises `enum "" not found`. Skip rather than crash the + # handler — it re-fires on the next selection and the panel resyncs. + pass # Only assign when the target enum is the one that lists this type — otherwise # we hit `enum "" not found in (...)` if the user selects an element of a