From 07b6560835c54c6dbd6864ec683cd5ac62d7cdcb Mon Sep 17 00:00:00 2001 From: Robin Quint Date: Wed, 6 Aug 2025 20:37:31 +0200 Subject: [PATCH] Added ChangeSwingDirection operator for parametric doors --- .../bonsai/bim/module/model/__init__.py | 1 + src/bonsai/bonsai/bim/module/model/product.py | 488 ------------------ .../bonsai/bim/module/model/workspace.py | 4 +- 3 files changed, 2 insertions(+), 491 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index a4dcd28ded..2890411b73 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -84,6 +84,7 @@ classes = ( product.MirrorElements, product.TrueMirrorElements, product.SetActiveType, + product.ChangeSwingDirection, workspace.Hotkey, workspace.BIM_MT_add_representation_item, wall.AddPerpendicularWall, diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index f78bce16f8..3d1e155e6a 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -682,494 +682,6 @@ class MirrorElements(bpy.types.Operator, tool.Ifc.Operator): obj.matrix_world = newmat -class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.mirror_geometry" - bl_label = "Mirror Element Geometry" - bl_options = {"REGISTER", "UNDO"} - bl_description = "Mirrors the selected objects by mirroring their representation (or their types representation)" - - @classmethod - def poll(cls, context): - return context.selected_objects - - def _execute(self, context): - for obj in context.selected_objects: - self.mirror_obj(obj) - return { "FINISHED" } - - def mirror_obj(self, obj): - element = tool.Ifc.get_entity(obj) - if not element: - return - type_element = ifcopenshell.util.element.get_type(element) - - active_context = tool.Geometry.get_active_representation_context(obj) - - 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) - else: - # invert representation of entity directly - self.invert_representation(element) - - # 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. - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=ifcopenshell.util.representation.get_representation(element, active_context), - should_reload=False, - is_global=False, - should_sync_changes_first=False, - ) - - def invert_general_object(self, element): - if element.is_a("IfcProduct"): - if not element.Representation: - return - - 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) - elif element.is_a("IfcTypeProduct"): - for representation_map in element.RepresentationMaps: - for item in representation_map.MappedRepresentation.Items: - builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) - builder.mirror(item, (1, 0), create_copy=False) - - tool.Geometry.reload_representation(tool.Ifc.get_object(element)) - - def invert_door_swing(self, element): - obj = tool.Ifc.get_object(element) - - pset_data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data")) - - if "LEFT" in pset_data["door_type"]: - pset_data["door_type"] = pset_data["door_type"].replace("LEFT", "RIGHT") - elif "RIGHT" in pset_data["door_type"]: - pset_data["door_type"] = pset_data["door_type"].replace("RIGHT", "LEFT") - - pset = tool.Pset.get_element_pset(element, "BBIM_Door") - pset_data_str = tool.Ifc.get().createIfcText(json.dumps(pset_data, default=list)) - ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": pset_data_str}) - - pset_data.update(pset_data.pop("lining_properties")) - pset_data.update(pset_data.pop("panel_properties")) - pset_data.update(tool.Model.get_constituents_props_data(element)) - - # we need this workaround because set_props_kwargs_from_ifc_data will - # "update" the mesh of the active object, which will switch its representation - prev_active = bpy.context.view_layer.objects.active - bpy.context.view_layer.objects.active = obj - - props = tool.Model.get_door_props(obj) - props.set_props_kwargs_from_ifc_data(pset_data) - - bpy.context.view_layer.objects.active = prev_active - - # regenerate door geometry - update_door_modifier_representation(obj) - - tool.Model.mark_thumbnail_for_update(element) - - def invert_representation(self, element): - if ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"): - self.invert_door_swing(element) - else: - self.invert_general_object(element) - - def assign_inverted_type(self, element): - 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) - 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.Type, element, inverted_type) - - -class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.mirror_geometry" - bl_label = "Mirror Element Geometry" - bl_options = {"REGISTER", "UNDO"} - bl_description = ( - "Mirrors the selected objects by inverting their representation. " - "If an active object is set, mirrors about its YZ plane; otherwise mirrors in place along the X axis. " - "Shift: duplicate and mirror, leaving the original in place" - ) - keep_original: bpy.props.BoolProperty(name="Keep Original", default=False) - - @classmethod - def poll(cls, context): - return context.selected_objects - - def _execute(self, context): - active_obj = context.active_object - mirror_ref = None - if active_obj: - objs_to_mirror = [obj for obj in context.selected_objects if obj != active_obj] - if objs_to_mirror: - mirror_ref = active_obj - if mirror_ref is None: - objs_to_mirror = list(context.selected_objects) - - if self.keep_original: - if mirror_ref: - mirror_ref.select_set(False) - bpy.ops.bim.override_object_duplicate_move(is_interactive=False) - objs_to_mirror = [obj for obj in context.selected_objects if obj != mirror_ref] - if mirror_ref: - mirror_ref.select_set(True) - - for obj in objs_to_mirror: - self.mirror_obj(context, obj, mirror_ref) - return {"FINISHED"} - - def mirror_obj(self, context: bpy.types.Context, obj: bpy.types.Object, mirror_ref: bpy.types.Object = None): - element = tool.Ifc.get_entity(obj) - if not element: - return - type_element = ifcopenshell.util.element.get_type(element) - - active_context = tool.Geometry.get_active_representation_context(obj) - - 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, - 1.0 if abs(mirror_normal_local.z) > 0.5 else 0.0, - ) - else: - mirror_axes = (1, 0, 0) - - # 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 - # regardless of when (or whether) the slab's own IFC ObjectPlacement gets synced by the depsgraph. - opening_placements_before: dict[int, np.ndarray] = {} - M_slab_before: np.ndarray | None = None - if hasattr(element, "HasOpenings") and element.HasOpenings: - M_slab_before = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement).copy() - for rel in element.HasOpenings: - opening = rel.RelatedOpeningElement - M = ifcopenshell.util.placement.get_local_placement(opening.ObjectPlacement) - opening_placements_before[opening.id()] = M.copy() - - usage_type = tool.Model.get_usage_type(element) - type_has_reps = bool(type_element and (type_element.RepresentationMaps or [])) - # LAYER2 (walls) and LAYER3 (slabs) generate instance-specific bodies via DumbWallGenerator / - # DumbSlabGenerator rather than mapping the type's RepresentationMaps. assign_inverted_type - # only flips the *type* geometry and would leave the instance body unchanged, so both layer - # usage types must go through invert_representation instead. - is_assign_type_path = bool(type_element and element.id() != type_element.id() and usage_type not in ("LAYER2", "LAYER3") and type_has_reps) - if is_assign_type_path: - self.assign_inverted_type(element, mirror_axes) - else: - # invert representation of entity directly; - # LAYER2/LAYER3 (walls/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: - self._apply_opening_mirror(element, mirror_axes, M_slab_before, opening_placements_before, np.eye(3)) - self.invert_representation(element, mirror_axes) - # For LAYER2 walls, layers stack along local Y (LayerSetDirection=AXIS2). A Y-axis flip - # reverses that direction, so DirectionSense and OffsetFromReferenceLine must both invert. - # (X/Z flips don't touch local Y, and the assign_inverted_type path handles Y-mirror via - # a 180°Z element rotation which implicitly flips local Y without changing the IFC attribute.) - if usage_type == "LAYER2" and mirror_axes[1] > 0.5: - mat_usage = ifcopenshell.util.element.get_material(element, should_inherit=False) - if mat_usage and mat_usage.is_a("IfcMaterialLayerSetUsage"): - mat_usage.DirectionSense = "NEGATIVE" if mat_usage.DirectionSense == "POSITIVE" else "POSITIVE" - mat_usage.OffsetFromReferenceLine = -mat_usage.OffsetFromReferenceLine - - context.view_layer.update() - - 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 - origin_in_mirror.x *= -1 - obj.matrix_world.translation = mirror_ref.matrix_world @ origin_in_mirror - 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 - - if element.is_a("IfcElement") and element.FillsVoids: - tool.Model.update_simple_openings(element) - - # 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). - # The LAYER3 / invert_representation path already did this before invert_representation. - if is_assign_type_path and opening_placements_before and M_slab_before is not None: - R_elem_old = M_slab_before[:3, :3] - _, 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 - # 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), - # the wrong representation will be used. - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - 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, 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 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). - """ - 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: - 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() - - 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. 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_anchor @ M_rel_new - ifcopenshell.api.geometry.edit_object_placement( - tool.Ifc.get(), product=opening, matrix=M_abs_new, is_si=False - ) - - if not opening.Representation: - continue - 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 item.is_a("IfcExtrudedAreaSolid"): - # H (wall-local Householder) is already computed above for the placement - # mirror. Applying it as T_geom to opening-local coords is correct because: - # M_rel_new @ T_geom = H @ M_rel (the true geometric mirror) - # with M_rel_new = H@R@H (conjugation) → T_geom = H (wall-local numerics - # applied to opening-local coords). Conjugating into Position-local gives: - # H_pos = placement_mat.T @ H @ placement_mat - placement_mat = ifcopenshell.util.placement.get_axis2placement(item.Position)[:3, :3] - H_pos = placement_mat.T @ H @ placement_mat - - # Mirror Position.Location (opening-local 3D point) by H - pos_coords = item.Position.Location.Coordinates - pos3 = np.array([pos_coords[0], pos_coords[1], pos_coords[2] if len(pos_coords) > 2 else 0.0]) - pos3_new = H @ pos3 - if len(pos_coords) > 2: - item.Position.Location.Coordinates = tuple(float(v) for v in pos3_new) - else: - item.Position.Location.Coordinates = (float(pos3_new[0]), float(pos3_new[1])) - - # Mirror profile coords in Position-local via H_pos. - # H_pos is a reflection (det=-1) so winding must be reversed. - profile = item.SweptArea - for curve in [getattr(profile, "OuterCurve", None)]: - if curve is None: - continue - coords = builder.get_polyline_coords(curve) - coords3 = np.hstack([coords, np.zeros((len(coords), 1))]) - coords_new = (H_pos @ coords3.T).T[:, :2][::-1] - builder.set_polyline_coords(curve, coords_new) - for inner in getattr(profile, "InnerCurves", None) or []: - coords = builder.get_polyline_coords(inner) - coords3 = np.hstack([coords, np.zeros((len(coords), 1))]) - coords_new = (H_pos @ coords3.T).T[:, :2][::-1] - builder.set_polyline_coords(inner, coords_new) - - # Mirror ExtrudedDirection in Position-local via H_pos - dir_local = np.array(item.ExtrudedDirection.DirectionRatios) - dir_local_new = H_pos @ dir_local - item.ExtrudedDirection.DirectionRatios = tuple(float(v) for v in dir_local_new) - else: - builder.mirror(item, mirror_axes_2d, create_copy=False) - - 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()) - - def mirror_item(item): - if item.is_a("IfcBooleanResult"): - mirror_item(item.FirstOperand) - try: - 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: - try: - builder.mirror(item, mirror_axes_2d, create_copy=False) - except Exception: - pass - - if element.is_a("IfcProduct"): - if not element.Representation: - return - for representation in element.Representation.Representations: - for item in representation.Items: - mirror_item(item) - elif element.is_a("IfcTypeProduct"): - for representation_map in (element.RepresentationMaps or []): - for item in representation_map.MappedRepresentation.Items: - mirror_item(item) - - tool.Geometry.reload_representation(tool.Ifc.get_object(element)) - - def invert_door_swing(self, element): - obj = tool.Ifc.get_object(element) - - pset_data = json.loads(ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data")) - - if "LEFT" in pset_data["door_type"]: - pset_data["door_type"] = pset_data["door_type"].replace("LEFT", "RIGHT") - elif "RIGHT" in pset_data["door_type"]: - pset_data["door_type"] = pset_data["door_type"].replace("RIGHT", "LEFT") - - pset = tool.Pset.get_element_pset(element, "BBIM_Door") - pset_data_str = tool.Ifc.get().createIfcText(json.dumps(pset_data, default=list)) - ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Data": pset_data_str}) - - pset_data.update(pset_data.pop("lining_properties")) - pset_data.update(pset_data.pop("panel_properties")) - pset_data.update(tool.Model.get_constituents_props_data(element)) - - # we need this workaround because set_props_kwargs_from_ifc_data will - # "update" the mesh of the active object, which will switch its representation - prev_active = bpy.context.view_layer.objects.active - bpy.context.view_layer.objects.active = obj - - props = tool.Model.get_door_props(obj) - props.set_props_kwargs_from_ifc_data(pset_data) - - bpy.context.view_layer.objects.active = prev_active - - # regenerate door geometry - update_door_modifier_representation(obj) - - tool.Model.mark_thumbnail_for_update(element) - - def invert_representation(self, element, mirror_axes=(1, 0, 0)): - if ifcopenshell.util.element.get_pset(element, "BBIM_Door", "Data"): - self.invert_door_swing(element) - else: - self.invert_general_object(element, mirror_axes) - - def assign_inverted_type(self, element, mirror_axes=(1, 0, 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) # 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. Apply rotation compensation for other axes: - # flip_Y = Rotate_Z_180 ∘ flip_X - # flip_Z = Rotate_Y_180 ∘ flip_X - if mirror_axes == (0.0, 1.0, 0.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 - 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 - class TrueMirrorElements(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.mirror_geometry" diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index ff50dd8262..01941e05bf 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -1093,8 +1093,6 @@ class EditObjectUI: def draw_flip(cls, ui_context, layout) -> None: row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else layout add_layout_hotkey_operator(row, "Flip", "S_F", bpy.ops.bim.flip_object.__doc__, ui_context) - row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else layout - add_layout_hotkey_operator(row, "Mirror Geometry", "C_F", bpy.ops.bim.mirror_geometry.__doc__, ui_context) @classmethod def draw_mirror_geometry(cls, ui_context, layout) -> None: @@ -1303,7 +1301,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): def hotkey_C_F(self): if not bpy.context.selected_objects: return - bpy.ops.bim.mirror_geometry() + bpy.ops.bim.mirror_geometry(keep_original=getattr(self, "shift", False)) def hotkey_S_G(self): obj = bpy.context.active_object