diff --git a/src/fluency/ui/main_window.py b/src/fluency/ui/main_window.py index 61402a4..0796c50 100644 --- a/src/fluency/ui/main_window.py +++ b/src/fluency/ui/main_window.py @@ -1696,10 +1696,19 @@ class MainWindow(QMainWindow): ops_tools = self._ui.groupBox_8.layout() if ops_tools is None: ops_tools = QGridLayout(self._ui.groupBox_8) - ops_tools.addWidget(self._btn_del_op, 1, 0, 1, 2) + ops_tools.addWidget(self._btn_del_op, 1, 0, 1, 1) + + self._btn_mirror_op = QPushButton("Mirror Op") + self._btn_mirror_op.setEnabled(False) + self._btn_mirror_op.setToolTip( + "Mirror the body at the selected operation — inserts a mirror " + "after it so the operation's effect appears on both sides." + ) + ops_tools.addWidget(self._btn_mirror_op, 1, 1, 1, 1) self._operations_list.itemSelectionChanged.connect(self._on_operations_selection_changed) self._btn_del_op.clicked.connect(self._on_delete_operation) + self._btn_mirror_op.clicked.connect(self._on_mirror_operation) def _setup_ui_aliases(self): """Create _btn_* aliases pointing to the UI-loaded widgets. @@ -2325,6 +2334,7 @@ class MainWindow(QMainWindow): body = self._selected_body if body is None: self._btn_del_op.setEnabled(False) + self._btn_mirror_op.setEnabled(False) return features = _ensure_feature_history(body) for index, feat in enumerate(features): @@ -2338,13 +2348,108 @@ class MainWindow(QMainWindow): self._on_operations_selection_changed() def _on_operations_selection_changed(self) -> None: - """Enable 'Del Op' only when a deletable (non-base) op is selected.""" + """Enable 'Del Op' / 'Mirror Op' when a non-base op is selected.""" item = self._operations_list.currentItem() if item is None: self._btn_del_op.setEnabled(False) + self._btn_mirror_op.setEnabled(False) return index = item.data(Qt.ItemDataRole.UserRole) - self._btn_del_op.setEnabled(index is not None and index > 0) + deletable = index is not None and index > 0 + self._btn_del_op.setEnabled(deletable) + self._btn_mirror_op.setEnabled(index is not None and index >= 0) + + def _on_mirror_operation(self) -> None: + """Mirror the body at the selected operation's point in the feature history. + + Opens the MirrorDialog; on OK a ``mirror`` Feature is inserted right + after the selected operation. The body is then rebuilt from scratch + by replaying the updated feature list, so the mirror applies at + exactly that point and subsequent operations (fillets, chamfers, + etc.) run on the mirrored geometry. + """ + body = self._selected_body + if body is None or body.geometry is None: + QMessageBox.warning( + self, "No Body", "Select a body first." + ) + return + item = self._operations_list.currentItem() + if item is None: + QMessageBox.information( + self, "No Operation", + "Select an operation in the list first — the mirror will " + "be inserted right after it." + ) + return + index = item.data(Qt.ItemDataRole.UserRole) + features = _ensure_feature_history(body) + if not isinstance(index, int) or not (0 <= index < len(features)): + return + + # Gather workplane data for the dialog. + wp_list: list = [] + if self._current_component is not None: + wp_list = [ + (wp.name, wp.origin, wp.normal) + for wp in self._current_component.workplanes.values() + ] + + dialog = MirrorDialog(self, workplanes=wp_list) + + def _preview(values: Any) -> None: + if values is None: + self._viewer_3d.clear_preview() + return + try: + # Replay features up to and including the selected one. + geom = _replay_body_features( + self._kernel, body, features[: index + 1], self._through_all_length_for_geometry + ) + if geom is None: + self._viewer_3d.clear_preview() + return + normal = values["mirror_plane_normal"] + origin = values["mirror_plane_origin"] + keep = values["keep_original"] + mirrored = self._kernel.mirror(geom, normal, origin) + if mirrored is None: + self._viewer_3d.clear_preview() + return + if keep: + result = self._kernel.boolean_union(geom, mirrored) + else: + result = mirrored + if result is not None: + shape = self._kernel._get_shape(result) + self._viewer_3d.show_preview(shape) + else: + self._viewer_3d.clear_preview() + except Exception: + logger.debug("mirror op preview failed", exc_info=True) + self._viewer_3d.clear_preview() + + dialog.set_preview_callback(_preview) + + if dialog.exec(): + values = dialog.get_values() + feat = Feature( + operation="mirror", + mirror_plane_origin=values["mirror_plane_origin"], + mirror_plane_normal=values["mirror_plane_normal"], + keep_original=values["keep_original"], + ) + # Insert the mirror feature right after the selected operation. + features.insert(index + 1, feat) + body.needs_update = True + body.modified_at = datetime.now() + self._mark_dirty() + logger.info( + f"Body '{body.name}': inserted mirror after operation {index}" + ) + self._update_and_redraw() + + self._viewer_3d.clear_preview() def _on_delete_operation(self) -> None: """Delete the selected operation; the body reverts to before it.