From 1a15cfef7022c1128be45f73f275a5d76b9d98ac Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Fri, 1 May 2026 19:14:34 -0500 Subject: [PATCH] Fix BRep and opening element mirroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes in TrueMirrorElements / _apply_opening_mirror: 1. IfcFacetedBrep support: implement mirroring in mirror_item by negating vertex coordinates along the flipped axes and reversing face winding for an odd number of flipped axes (restores outward normals). 2. Routing: add type_has_reps guard to is_assign_type_path so elements whose IfcTypeProduct carries no RepresentationMaps fall through to direct geometry mirroring instead of a no-op type swap. 3. Opening placement: use H@R@H conjugation (not direct Householder on columns) for the rotation part of _apply_opening_mirror. Direct Householder yields det=-1; IFC's Y=Z×X normalization then introduces a spurious 180°Z rotation. Conjugation keeps det=+1 and produces the correct mirrored rotation. Also call builder.mirror() on each opening representation item so local vertex data is mirrored alongside the placement frame. Generated with the assistance of an AI coding tool. AI effort: 8/10 --- src/bonsai/bonsai/bim/module/model/product.py | 113 +++++++----------- 1 file changed, 43 insertions(+), 70 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index f836175922..703a2057ab 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -740,15 +740,8 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): 1.0 if abs(mirror_normal_local.y) > 0.5 else 0.0, 1.0 if abs(mirror_normal_local.z) > 0.5 else 0.0, ) - print(f"[mirror] --- {obj.name} ---") - print(f"[mirror] mirror_ref='{mirror_ref.name}'") - print(f"[mirror] mirror_ref rotation: {mirror_ref.matrix_world.to_euler()}") - print(f"[mirror] mirror normal (world): {mirror_normal_world}") - print(f"[mirror] mirror normal (obj local):{mirror_normal_local}") - print(f"[mirror] mirror_axes: {mirror_axes}") else: mirror_axes = (1, 0, 0) - print(f"[mirror] --- {obj.name} --- no mirror_ref, mirror_axes={mirror_axes}") # Snapshot opening world placements AND slab placement BEFORE any geometry or origin changes. # We work in the slab's LOCAL coordinate space so the mirrored relative offset is correct @@ -761,23 +754,15 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): opening = rel.RelatedOpeningElement M = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement) opening_placements_before[opening.id()] = M.copy() - print(f"[mirror] opening #{opening.id()} pre-mirror world pos={M[:3, 3].tolist()}") - M_rel = np.linalg.inv(M_slab_before) @ M - print(f"[mirror] opening #{opening.id()} pre-mirror slab-local pos={M_rel[:3, 3].tolist()}") usage_type = tool.Model.get_usage_type(element) - print(f"[mirror] usage_type={usage_type} type_element=#{type_element.id() if type_element else None}") - is_assign_type_path = bool(type_element and element.id() != type_element.id() and usage_type != "LAYER3") + type_has_reps = bool(type_element and (type_element.RepresentationMaps or [])) + is_assign_type_path = bool(type_element and element.id() != type_element.id() and usage_type != "LAYER3" and type_has_reps) if is_assign_type_path: - # obj has a type, use / create inverted type and assign it - print(f"[mirror] -> assign_inverted_type path") self.assign_inverted_type(element, mirror_axes) - M_elem_after_assign = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) - print(f"[mirror] element IFC pos after assign_inverted_type: {M_elem_after_assign[:3, 3].tolist()}") else: # invert representation of entity directly; - # LAYER3 (slabs) store geometry on the instance, not the type, so always use this path - print(f"[mirror] -> invert_representation path (direct)") + # LAYER3 (slabs) and elements whose type carries no geometry use this path # Update opening placements BEFORE invert_representation so its internal reload # sees the correct positions. No element rotation change on this path → frame_change = I. if opening_placements_before and M_slab_before is not None: @@ -789,17 +774,14 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): if mirror_ref: # Reflect object origin about the mirror reference's YZ plane origin_in_mirror = mirror_ref.matrix_world.inverted() @ obj.matrix_world.translation - print(f"[mirror] origin before: {obj.matrix_world.translation}") origin_in_mirror.x *= -1 obj.matrix_world.translation = mirror_ref.matrix_world @ origin_in_mirror - print(f"[mirror] origin after: {obj.matrix_world.translation}") else: # Fall back: nudge in place to compensate for bounding box shift after inversion mirrored_bb_data = tool.Blender.get_object_bounding_box(obj) x_correction_factor = mirrored_bb_data["min_x"] - bb_data["min_x"] x_correction_vec = (obj.matrix_world @ Vector((x_correction_factor, 0, 0, 0))).xyz obj.location -= x_correction_vec - print(f"[mirror] fallback x_correction={x_correction_factor}") if element.is_a("IfcElement") and element.FillsVoids: tool.Model.update_simple_openings(element) @@ -817,30 +799,12 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): # bonsai does not automatically switch to the representation that should be active in the given context # when switching to a type that was previously viewed in another context (e.g. plan view), # the wrong representation will be used. - if opening_placements_before: - for rel in element.HasOpenings: - op = rel.RelatedOpeningElement - M_pre_switch = ifcopenshell.util.placement.get_local_placement(op.ObjectPlacement) - print(f"[mirror] opening #{op.id()} IFC world PRE switch_representation: {M_pre_switch[:3, 3].tolist()}") bonsai.core.geometry.switch_representation( tool.Ifc, tool.Geometry, obj=obj, representation=ifcopenshell.util.representation.get_representation(element, active_context), ) - if opening_placements_before: - for rel in element.HasOpenings: - op = rel.RelatedOpeningElement - M_post_switch = ifcopenshell.util.placement.get_local_placement(op.ObjectPlacement) - print(f"[mirror] opening #{op.id()} IFC world POST switch_representation: {M_post_switch[:3, 3].tolist()}") - op_obj = tool.Ifc.get_object(op) - if op_obj: - print(f"[mirror] opening #{op.id()} Blender world POST switch_representation: {list(op_obj.matrix_world.translation)}") - else: - print(f"[mirror] opening #{op.id()} has no Blender object yet") - print(f"[mirror] element Blender world POST switch_representation: {list(obj.matrix_world.translation)}") - M_elem_post_switch = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) - print(f"[mirror] element IFC world POST switch_representation: {M_elem_post_switch[:3, 3].tolist()}") def _apply_opening_mirror(self, element, mirror_axes, M_slab_before, opening_placements_before, frame_change): """Mirror IfcOpeningElement placements in element-local space. @@ -879,15 +843,9 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): M_rel_new[:3, :3] = frame_change @ R_mirrored M_abs_new = M_slab_before @ M_rel_new - print(f"[mirror] opening #{opening.id()} slab-local: {M_rel[:3, 3].tolist()} -> {M_rel_new[:3, 3].tolist()}") - print(f"[mirror] opening #{opening.id()} world: {M_abs_old[:3, 3].tolist()} -> {M_abs_new[:3, 3].tolist()}") - print(f"[mirror] opening #{opening.id()} rot_before X={M_rel[:3, 0].tolist()} Z={M_rel[:3, 2].tolist()}") - print(f"[mirror] opening #{opening.id()} rot_after X={M_rel_new[:3, 0].tolist()} Z={M_rel_new[:3, 2].tolist()}") ifcopenshell.api.geometry.edit_object_placement( tool.Ifc.get(), product=opening, matrix=M_abs_new, is_si=False ) - M_after = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement) - print(f"[mirror] opening #{opening.id()} IFC world AFTER: pos={M_after[:3, 3].tolist()} X={M_after[:3, 0].tolist()} Z={M_after[:3, 2].tolist()}") # Mirror the opening's own representation geometry (local vertices/profile). # edit_object_placement only moves the frame; the local shape must also be @@ -901,48 +859,68 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): for item in rep.Items: try: builder.mirror(item, mirror_axes_2d, create_copy=False) - print(f"[mirror] opening #{opening.id()} geometry mirrored: #{item.id()} {item.is_a()}") - except Exception as e: - print(f"[mirror] opening #{opening.id()} geometry mirror failed for #{item.id()} {item.is_a()}: {e}") + except Exception: + pass def invert_general_object(self, element, mirror_axes=(1, 0, 0)): # ShapeBuilder.mirror works in 2D; use only the XY components mirror_axes_2d = mirror_axes[:2] builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) - print(f"[mirror:invert_general] element=#{element.id()} {element.is_a()} mirror_axes={mirror_axes} mirror_axes_2d={mirror_axes_2d}") - def mirror_item(item, depth=0): - indent = " " * depth - print(f"[mirror:invert_general] {indent}mirror_item #{item.id()} type={item.is_a()}") + def mirror_item(item): if item.is_a("IfcBooleanResult"): - print(f"[mirror:invert_general] {indent} -> IfcBooleanResult operator={item.Operator}") - print(f"[mirror:invert_general] {indent} -> FirstOperand #{item.FirstOperand.id()} {item.FirstOperand.is_a()}") - mirror_item(item.FirstOperand, depth + 1) - print(f"[mirror:invert_general] {indent} -> SecondOperand #{item.SecondOperand.id()} {item.SecondOperand.is_a()}") + mirror_item(item.FirstOperand) try: - mirror_item(item.SecondOperand, depth + 1) - except Exception as e: - print(f"[mirror:invert_general] {indent} -> SecondOperand skipped: {e}") + mirror_item(item.SecondOperand) + except Exception: + pass + elif item.is_a("IfcFacetedBrep") or item.is_a("IfcFacetedBrepWithVoids"): + shells = [item.Outer] + if item.is_a("IfcFacetedBrepWithVoids"): + shells.extend(item.Voids) + + # Mirror all unique vertex coordinates along the flipped axes + points_done = set() + for shell in shells: + for face in shell.CfsFaces: + for bound in face.Bounds: + if not bound.Bound.is_a("IfcPolyLoop"): + continue + for pt in bound.Bound.Polygon: + if pt.id() in points_done: + continue + points_done.add(pt.id()) + coords = list(pt.Coordinates) + for i, flip in enumerate(mirror_axes[: len(coords)]): + if flip > 0.0: + coords[i] = -coords[i] + pt.Coordinates = coords + + # Reverse face winding for an odd number of flipped axes (restores outward normals) + num_flipped = sum(1 for v in mirror_axes if v > 0.0) + if num_flipped % 2 == 1: + for shell in shells: + for face in shell.CfsFaces: + for bound in face.Bounds: + if bound.Bound.is_a("IfcPolyLoop"): + bound.Bound.Polygon = list(reversed(bound.Bound.Polygon)) + elif item.is_a("IfcMappedItem"): + for sub in item.MappingSource.MappedRepresentation.Items: + mirror_item(sub) else: - print(f"[mirror:invert_general] {indent} -> calling builder.mirror()") builder.mirror(item, mirror_axes_2d, create_copy=False) if element.is_a("IfcProduct"): if not element.Representation: - print(f"[mirror:invert_general] element has no Representation, skipping") return - for representation in element.Representation.Representations: - print(f"[mirror:invert_general] representation #{representation.id()} context={representation.ContextOfItems.ContextIdentifier!r} type={representation.RepresentationType!r} items={len(representation.Items)}") for item in representation.Items: mirror_item(item) elif element.is_a("IfcTypeProduct"): for representation_map in (element.RepresentationMaps or []): - print(f"[mirror:invert_general] representation_map #{representation_map.id()} items={len(representation_map.MappedRepresentation.Items)}") for item in representation_map.MappedRepresentation.Items: mirror_item(item) - print(f"[mirror:invert_general] reloading representation for #{element.id()}") tool.Geometry.reload_representation(tool.Ifc.get_object(element)) def invert_door_swing(self, element): @@ -995,9 +973,6 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): tool.Blender.Modifier.set_mirrored_type(inverted_type, type_element) tool.Blender.Modifier.set_mirrored_type(type_element, inverted_type) inverted_type.Name = f"{inverted_type.Name}.Mirror" - print(f"[mirror] created new inverted type '{inverted_type.Name}'") - else: - print(f"[mirror] reusing cached type '{inverted_type.Name}', mirror_axes={mirror_axes}") bonsai.core.type.assign_type(tool.Ifc, tool.Model, tool.Type, element, inverted_type) @@ -1010,14 +985,12 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): rot_180_z = Matrix.Rotation(math.pi, 4, "Z") obj.matrix_world = rot_180_z @ obj.matrix_world obj.matrix_world.translation = loc - print(f"[mirror] applied 180° Z rotation to compensate for Y-flip") elif mirror_axes == (0.0, 0.0, 1.0): obj = tool.Ifc.get_object(element) loc = obj.matrix_world.translation.copy() rot_180_y = Matrix.Rotation(math.pi, 4, "Y") obj.matrix_world = rot_180_y @ obj.matrix_world obj.matrix_world.translation = loc - print(f"[mirror] applied 180° Y rotation to compensate for Z-flip") def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None: