From 76ff5c67e0aeae1e0c6ec6f3c46c082b64ddf751 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Tue, 10 Mar 2026 21:42:43 -0500 Subject: [PATCH] Fix mirror axis mismatch when ref object is rotated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The geometry inversion was hardcoded to the object's local X axis regardless of the mirror reference orientation. When the reference was rotated (e.g. a wall at 90°), the inversion axis and the position reflection were perpendicular, producing a rotation artefact. Fix by computing the mirror plane normal in the object's local space and passing it as the inversion axis for untyped elements. For typed elements, the cached inverted type is always X-flipped; a 180° Z rotation is applied to the object when a Y-flip is needed instead (flip_Y = Rotate_Z_180 ∘ flip_X). Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/module/model/product.py | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index b057e11074..150e5ed2c1 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -19,6 +19,7 @@ # pyright: reportUnnecessaryTypeIgnoreComment=error import json +import math from typing import TYPE_CHECKING, Any, Literal, get_args import bmesh @@ -727,12 +728,24 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): bb_data = tool.Blender.get_object_bounding_box(obj) + # Compute the geometry inversion axis from the mirror reference's local X (= mirror plane normal). + # Without a reference, always fall back to inverting along the object's local X. + if mirror_ref: + mirror_normal_world = mirror_ref.matrix_world.to_3x3().col[0].normalized() + mirror_normal_local = obj.matrix_world.to_3x3().inverted() @ mirror_normal_world + mirror_axes = ( + 1.0 if abs(mirror_normal_local.x) > 0.5 else 0.0, + 1.0 if abs(mirror_normal_local.y) > 0.5 else 0.0, + ) + else: + mirror_axes = (1, 0) + if type_element and element.id() != type_element.id(): # obj has a type, use / create inverted type and assign it - self.assign_inverted_type(element) + self.assign_inverted_type(element, mirror_axes) else: # invert representation of entity directly - self.invert_representation(element) + self.invert_representation(element, mirror_axes) context.view_layer.update() @@ -761,7 +774,7 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): representation=ifcopenshell.util.representation.get_representation(element, active_context), ) - def invert_general_object(self, element): + def invert_general_object(self, element, mirror_axes=(1, 0)): if element.is_a("IfcProduct"): if not element.Representation: return @@ -769,12 +782,12 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): for representation in element.Representation.Representations: for item in representation.Items: builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) - builder.mirror(item, (1, 0), create_copy=False) + builder.mirror(item, mirror_axes, create_copy=False) elif element.is_a("IfcTypeProduct"): for representation_map in (element.RepresentationMaps or []): for item in representation_map.MappedRepresentation.Items: builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) - builder.mirror(item, (1, 0), create_copy=False) + builder.mirror(item, mirror_axes, create_copy=False) tool.Geometry.reload_representation(tool.Ifc.get_object(element)) @@ -811,26 +824,35 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): tool.Model.mark_thumbnail_for_update(element) - def invert_representation(self, element): + def invert_representation(self, element, mirror_axes=(1, 0)): if ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"): self.invert_door_swing(element) else: - self.invert_general_object(element) + self.invert_general_object(element, mirror_axes) - def assign_inverted_type(self, element): + def assign_inverted_type(self, element, mirror_axes=(1, 0)): type_element = ifcopenshell.util.element.get_type(element) inverted_type = tool.Blender.Modifier.has_mirrored_type(type_element) if not inverted_type: old_to_new, _ = tool.Geometry.duplicate_ifc_objects([tool.Ifc.get_object(type_element)]) inverted_type = old_to_new[type_element][0] - self.invert_representation(inverted_type) + self.invert_representation(inverted_type) # always X-flip for the cached type 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" bonsai.core.type.assign_type(tool.Ifc, tool.Model, tool.Type, element, inverted_type) + # The cached type is always X-flipped. If we need Y-flip, rotate 180° around Z to compensate + # (flip_Y = Rotate_Z_180 ∘ flip_X) + if mirror_axes == (0.0, 1.0): + obj = tool.Ifc.get_object(element) + loc = obj.matrix_world.translation.copy() + rot_180_z = Matrix.Rotation(math.pi, 4, "Z") + obj.matrix_world = rot_180_z @ obj.matrix_world + obj.matrix_world.translation = loc + def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None: box_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Box", "MODEL_VIEW")