Fix BRep and opening element mirroring

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
This commit is contained in:
Ryan Schultz
2026-05-01 19:14:34 -05:00
parent 3782bbe790
commit 1a15cfef70
+43 -70
View File
@@ -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: