From 97cd08ee920fc6ce104f3969e14ce575a9196a81 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 16 Apr 2026 22:00:56 -0500 Subject: [PATCH] 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 a08b016a76..9999319c9e 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -306,7 +306,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)