Fix void mirroring on assign_type path

Two bugs fixed in TrueMirrorElements for tessellated elements with
IfcExtrudedAreaSolid voids on the assign_inverted_type path:

1. Sync element IFC placement before computing opening positions so the
   geometry engine uses the correct element-local frame when applying
   boolean voids (fixes void appearing at wrong element-local offset).

2. Replace builder.mirror for IfcExtrudedAreaSolid with direct coordinate
   negation to avoid IFC entity aliasing that corrupted profile vertices
   in the translate→mirror→translate sequence.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Ryan Schultz
2026-05-02 12:27:15 -05:00
parent 4184ec9c94
commit 98ea6a7daf
+80 -23
View File
@@ -794,7 +794,21 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator):
_, R_quat, _ = obj.matrix_world.decompose()
R_elem_new = np.array(R_quat.to_matrix())
frame_change = np.linalg.inv(R_elem_new) @ R_elem_old
self._apply_opening_mirror(element, mirror_axes, M_slab_before, opening_placements_before, frame_change)
# Sync element IFC placement to the post-reflection Blender position NOW, before
# computing opening positions. The geometry engine uses the element's IFC placement
# to convert opening world-space positions to element-local positions when applying
# boolean voids. If the placement is stale the void lands at the wrong offset.
# Calling edit_object_placement here also makes the depsgraph handler skip a
# redundant second call (record_object_position marks it up-to-date).
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj, apply_scale=False)
M_slab_new = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement)
self._apply_opening_mirror(element, mirror_axes, M_slab_before, opening_placements_before, frame_change, M_slab_new)
for rel in element.HasOpenings:
opening = rel.RelatedOpeningElement
tool.Geometry.clear_cache(opening)
opening_obj = tool.Ifc.get_object(opening)
if opening_obj:
tool.Geometry.reload_representation(opening_obj)
# 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),
@@ -806,14 +820,17 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator):
representation=ifcopenshell.util.representation.get_representation(element, active_context),
)
def _apply_opening_mirror(self, element, mirror_axes, M_slab_before, opening_placements_before, frame_change):
"""Mirror IfcOpeningElement placements in element-local space.
def _apply_opening_mirror(self, element, mirror_axes, M_slab_before, opening_placements_before, frame_change, M_slab_new=None):
"""Mirror IfcOpeningElement placements and geometry in element-local space.
frame_change = inv(R_elem_new) @ R_elem_old accounts for any element rotation that will
be applied after save (e.g. 180° Z for Y-mirror via assign_inverted_type). Pass np.eye(3)
when no element rotation changes (LAYER3 / invert_representation path).
frame_change = inv(R_elem_new) @ R_elem_old accounts for any element rotation change
(e.g. 180° Z for Y-mirror via assign_inverted_type). Pass np.eye(3) when the element
rotation does not change (LAYER3 / invert_representation path).
M_slab_new: element IFC world matrix after the mirror. If None, M_slab_before is used
(correct for the LAYER3 path where the element placement stays the same).
"""
# Mirror normal in element's OLD local space
M_slab_anchor = M_slab_new if M_slab_new is not None else M_slab_before
N_local = np.zeros(3)
for i, flip in enumerate(mirror_axes[:3]):
if flip > 0.0:
@@ -830,33 +847,70 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator):
M_rel = np.linalg.inv(M_slab_before) @ M_abs_old
M_rel_new = M_rel.copy()
# Mirror then re-express translation in new element frame
t = M_rel[:3, 3].copy()
t_refl = t - 2 * np.dot(t, N_local) * N_local
M_rel_new[:3, 3] = frame_change @ t_refl
# Mirror rotation by conjugation: H@R@H keeps det=+1 and gives the correct mirrored rotation.
# Direct Householder on columns yields det=-1; IFC's Y=Z×X normalization then introduces
# a spurious 180°Z error (R_z(π-θ) instead of R_z(-θ)). Conjugation avoids this.
# Mirror rotation by conjugation: H@R@H keeps det=+1 and gives the correct mirrored
# rotation. Direct Householder on columns yields det=-1; IFC's Y=Z×X normalization
# then introduces a spurious 180°Z error. Conjugation avoids this.
H = np.eye(3) - 2 * np.outer(N_local, N_local)
R_mirrored = H @ M_rel[:3, :3] @ H
M_rel_new[:3, :3] = frame_change @ R_mirrored
M_abs_new = M_slab_before @ M_rel_new
M_abs_new = M_slab_anchor @ M_rel_new
ifcopenshell.api.geometry.edit_object_placement(
tool.Ifc.get(), product=opening, matrix=M_abs_new, is_si=False
)
# Mirror the opening's own representation geometry (local vertices/profile).
# edit_object_placement only moves the frame; the local shape must also be
# mirrored so the void is the correct mirror image. Blender reload is skipped
# here because invert_representation / reload_representation recreates the
# opening objects when the host element reloads.
if opening.Representation:
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
mirror_axes_2d = mirror_axes[:2]
for rep in opening.Representation.Representations:
for item in rep.Items:
if not opening.Representation:
continue
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
mirror_axes_2d = mirror_axes[:2]
num_flipped = sum(1 for v in mirror_axes_2d if v > 0.0)
for rep in opening.Representation.Representations:
for item in rep.Items:
if item.is_a("IfcExtrudedAreaSolid"):
# Bypass builder.mirror for IfcExtrudedAreaSolid — its translate→mirror→
# translate sequence can corrupt profile coordinates via IFC entity aliasing
# when Position.Location shares an IfcCartesianPoint with a profile vertex.
# Direct negation avoids all aliasing. (Position assumed to have default /
# identity orientation in practice.)
pos = list(item.Position.Location.Coordinates)
for i, flip in enumerate(mirror_axes_2d[: len(pos)]):
if flip > 0.0:
pos[i] = -pos[i]
item.Position.Location.Coordinates = tuple(pos)
profile = item.SweptArea
for curve in [getattr(profile, "OuterCurve", None)]:
if curve is None:
continue
coords = builder.get_polyline_coords(curve)
for i, flip in enumerate(mirror_axes_2d[: coords.shape[1]]):
if flip > 0.0:
coords[:, i] = -coords[:, i]
if num_flipped % 2 == 1:
coords = coords[::-1]
builder.set_polyline_coords(curve, coords)
for inner in getattr(profile, "InnerCurves", None) or []:
coords = builder.get_polyline_coords(inner)
for i, flip in enumerate(mirror_axes_2d[: coords.shape[1]]):
if flip > 0.0:
coords[:, i] = -coords[:, i]
if num_flipped % 2 == 1:
coords = coords[::-1]
builder.set_polyline_coords(inner, coords)
placement_mat = ifcopenshell.util.placement.get_axis2placement(item.Position)[:3, :3]
dir_local = np.array(item.ExtrudedDirection.DirectionRatios)
dir_opening = placement_mat @ dir_local
for i, flip in enumerate(mirror_axes_2d):
if flip > 0.0:
dir_opening[i] = -dir_opening[i]
dir_local_new = np.linalg.inv(placement_mat) @ dir_opening
item.ExtrudedDirection.DirectionRatios = tuple(float(v) for v in dir_local_new)
else:
try:
builder.mirror(item, mirror_axes_2d, create_copy=False)
except Exception:
@@ -908,7 +962,10 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator):
for sub in item.MappingSource.MappedRepresentation.Items:
mirror_item(sub)
else:
builder.mirror(item, mirror_axes_2d, create_copy=False)
try:
builder.mirror(item, mirror_axes_2d, create_copy=False)
except Exception:
pass
if element.is_a("IfcProduct"):
if not element.Representation: