mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-20 06:58:56 +00:00
Fix IfcOpeningElement mirror on LAYER3 slabs
Three-part fix in _apply_opening_mirror: 1. Rotation: replace direct Householder on rotation columns with conjugation H@R@H. Direct Householder gives det=-1; IFC's Y=Z×X normalization then introduces a spurious 180°Z rotation (R_z(π−θ) instead of the correct R_z(−θ)). Conjugation keeps det=+1 and produces the correct mirrored rotation for all θ. 2. Geometry: call builder.mirror() on each representation item of the opening so that local vertices/profiles are also mirrored. edit_object_placement only moves the placement frame; without this step the void shape is the original, not its mirror image. 3. Ordering (existing): opening update runs before invert_representation so the internal reload_representation sees correct positions. Generated with the assistance of an AI coding tool. AI effort: 8/10
This commit is contained in:
@@ -767,14 +767,21 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
|
|
||||||
usage_type = tool.Model.get_usage_type(element)
|
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}")
|
print(f"[mirror] usage_type={usage_type} type_element=#{type_element.id() if type_element else None}")
|
||||||
if type_element and element.id() != type_element.id() and usage_type != "LAYER3":
|
is_assign_type_path = bool(type_element and element.id() != type_element.id() and usage_type != "LAYER3")
|
||||||
|
if is_assign_type_path:
|
||||||
# obj has a type, use / create inverted type and assign it
|
# obj has a type, use / create inverted type and assign it
|
||||||
print(f"[mirror] -> assign_inverted_type path")
|
print(f"[mirror] -> assign_inverted_type path")
|
||||||
self.assign_inverted_type(element, mirror_axes)
|
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:
|
else:
|
||||||
# invert representation of entity directly;
|
# invert representation of entity directly;
|
||||||
# LAYER3 (slabs) store geometry on the instance, not the type, so always use this path
|
# LAYER3 (slabs) store geometry on the instance, not the type, so always use this path
|
||||||
print(f"[mirror] -> invert_representation path (direct)")
|
print(f"[mirror] -> invert_representation path (direct)")
|
||||||
|
# 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:
|
||||||
|
self._apply_opening_mirror(element, mirror_axes, M_slab_before, opening_placements_before, np.eye(3))
|
||||||
self.invert_representation(element, mirror_axes)
|
self.invert_representation(element, mirror_axes)
|
||||||
|
|
||||||
context.view_layer.update()
|
context.view_layer.update()
|
||||||
@@ -797,66 +804,106 @@ class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator):
|
|||||||
if element.is_a("IfcElement") and element.FillsVoids:
|
if element.is_a("IfcElement") and element.FillsVoids:
|
||||||
tool.Model.update_simple_openings(element)
|
tool.Model.update_simple_openings(element)
|
||||||
|
|
||||||
# Mirror IfcOpeningElement placements in SLAB LOCAL SPACE.
|
# For the assign_inverted_type path, mirror opening placements here (after origin reflection
|
||||||
#
|
# so obj.matrix_world already has the final rotation, needed to compute frame_change).
|
||||||
# Working in world space caused a double-offset: edit_object_placement stores a relative
|
# The LAYER3 / invert_representation path already did this before invert_representation.
|
||||||
# matrix (relative to the slab's current IFC placement), so when the depsgraph later syncs
|
if is_assign_type_path and opening_placements_before and M_slab_before is not None:
|
||||||
# the slab's Blender move to IFC the opening shifted again by the full slab displacement.
|
R_elem_old = M_slab_before[:3, :3]
|
||||||
#
|
_, R_quat, _ = obj.matrix_world.decompose()
|
||||||
# By mirroring in the slab's local space and passing M_slab_before @ M_rel_new as the
|
R_elem_new = np.array(R_quat.to_matrix())
|
||||||
# absolute matrix, edit_object_placement stores M_rel_new as the relative offset.
|
frame_change = np.linalg.inv(R_elem_new) @ R_elem_old
|
||||||
# The opening then moves correctly with the slab regardless of sync timing.
|
self._apply_opening_mirror(element, mirror_axes, M_slab_before, opening_placements_before, frame_change)
|
||||||
if opening_placements_before and M_slab_before is not None:
|
|
||||||
# Mirror normal in the slab's local coordinate space (same axes as geometry inversion)
|
|
||||||
N_local = np.zeros(3)
|
|
||||||
for i, flip in enumerate(mirror_axes[:3]):
|
|
||||||
if flip > 0.0:
|
|
||||||
N_local[i] = 1.0
|
|
||||||
norm = np.linalg.norm(N_local)
|
|
||||||
if norm > 0:
|
|
||||||
N_local /= norm
|
|
||||||
|
|
||||||
for rel in element.HasOpenings:
|
|
||||||
opening = rel.RelatedOpeningElement
|
|
||||||
M_abs_old = opening_placements_before[opening.id()]
|
|
||||||
|
|
||||||
# Express opening in slab's local coordinate system
|
|
||||||
M_rel = np.linalg.inv(M_slab_before) @ M_abs_old
|
|
||||||
M_rel_new = M_rel.copy()
|
|
||||||
|
|
||||||
# Mirror translation: negate the same axes as the geometry inversion
|
|
||||||
for i, flip in enumerate(mirror_axes[:3]):
|
|
||||||
if flip > 0.0:
|
|
||||||
M_rel_new[i, 3] *= -1
|
|
||||||
|
|
||||||
# Mirror rotation columns: d' = d - 2*(d·N)*N
|
|
||||||
for i in range(3):
|
|
||||||
d = M_rel[:3, i].copy()
|
|
||||||
M_rel_new[:3, i] = d - 2 * np.dot(d, N_local) * N_local
|
|
||||||
|
|
||||||
# Restore proper rotation handedness (reflection gives det = -1)
|
|
||||||
if np.linalg.det(M_rel_new[:3, :3]) < 0:
|
|
||||||
M_rel_new[:3, 2] *= -1
|
|
||||||
|
|
||||||
# Back to absolute using OLD slab matrix (still current in IFC at this point).
|
|
||||||
# edit_object_placement will compute relative = inv(M_slab_before) @ M_abs_new = M_rel_new ✓
|
|
||||||
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()}")
|
|
||||||
ifcopenshell.api.geometry.edit_object_placement(
|
|
||||||
tool.Ifc.get(), product=opening, matrix=M_abs_new, is_si=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# bonsai does not automatically switch to the representation that should be active in the given context
|
# 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),
|
# when switching to a type that was previously viewed in another context (e.g. plan view),
|
||||||
# the wrong representation will be used.
|
# 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(
|
bonsai.core.geometry.switch_representation(
|
||||||
tool.Ifc,
|
tool.Ifc,
|
||||||
tool.Geometry,
|
tool.Geometry,
|
||||||
obj=obj,
|
obj=obj,
|
||||||
representation=ifcopenshell.util.representation.get_representation(element, active_context),
|
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.
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
# Mirror normal in element's OLD local space
|
||||||
|
N_local = np.zeros(3)
|
||||||
|
for i, flip in enumerate(mirror_axes[:3]):
|
||||||
|
if flip > 0.0:
|
||||||
|
N_local[i] = 1.0
|
||||||
|
norm = np.linalg.norm(N_local)
|
||||||
|
if norm > 0:
|
||||||
|
N_local /= norm
|
||||||
|
|
||||||
|
for rel in element.HasOpenings:
|
||||||
|
opening = rel.RelatedOpeningElement
|
||||||
|
if opening.id() not in opening_placements_before:
|
||||||
|
continue
|
||||||
|
M_abs_old = opening_placements_before[opening.id()]
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
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
|
||||||
|
# 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:
|
||||||
|
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}")
|
||||||
|
|
||||||
def invert_general_object(self, element, mirror_axes=(1, 0, 0)):
|
def invert_general_object(self, element, mirror_axes=(1, 0, 0)):
|
||||||
# ShapeBuilder.mirror works in 2D; use only the XY components
|
# ShapeBuilder.mirror works in 2D; use only the XY components
|
||||||
|
|||||||
Reference in New Issue
Block a user