"""Dialogs for Fluency CAD operations: extrude, revolve, offset, workplane orientation.""" from __future__ import annotations import logging import math from typing import Any, Callable, Dict, Optional, Tuple from PySide6.QtWidgets import ( QButtonGroup, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QRadioButton, QSpinBox, QVBoxLayout, QWidget, ) logger = logging.getLogger(__name__) def _vec3(value: Any) -> Tuple[float, float, float]: """Coerce a 3-vector to a typed float triple (defensive fallback).""" try: return (float(value[0]), float(value[1]), float(value[2])) except (TypeError, ValueError, IndexError): return (0.0, 0.0, 0.0) class ExtrudeDialog(QDialog): """Dialog for extrude options. Carries an optional ``preview_callback`` that is invoked whenever the user changes any option; the host uses it to render a live transparent preview of the operation result in the 3D view. Passing *False* (or *None*) to the callback tells the host to clear the preview. """ def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setWindowTitle("Extrude Options") self.setMinimumWidth(320) self._preview_callback = None layout = QVBoxLayout(self) length_layout = QHBoxLayout() length_layout.addWidget(QLabel("Extrude Length (mm):")) self.length_input = QDoubleSpinBox() self.length_input.setDecimals(2) self.length_input.setRange(-10000, 10000) self.length_input.setValue(10) length_layout.addWidget(self.length_input) layout.addLayout(length_layout) self.symmetric_checkbox = QCheckBox("Symmetric Extrude") layout.addWidget(self.symmetric_checkbox) self.invert_checkbox = QCheckBox("Invert Extrusion") layout.addWidget(self.invert_checkbox) self.cut_checkbox = QCheckBox("Perform Cut") layout.addWidget(self.cut_checkbox) self.union_checkbox = QCheckBox("Combine (Union)") layout.addWidget(self.union_checkbox) self.through_all_checkbox = QCheckBox("Through All (cut/union target)") self.through_all_checkbox.setToolTip( "Ignore the typed length and extrude far enough to fully pass " "through the cut/union target body. Applies when Perform Cut or " "Combine (Union) is checked." ) layout.addWidget(self.through_all_checkbox) self.cut_all_bodies_checkbox = QCheckBox("Cut All Bodies") self.cut_all_bodies_checkbox.setToolTip( "Apply the boolean cut to every body in the current component, " "not just the one the sketch was drawn on. Requires Perform Cut." ) layout.addWidget(self.cut_all_bodies_checkbox) self.rounded_checkbox = QCheckBox("Round Edges") layout.addWidget(self.rounded_checkbox) line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() ok_button = QPushButton("OK") ok_button.clicked.connect(self.accept) cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) # Live preview: recompute on every option change. Wire each widget # to its own signal by type — spinboxes emit ``valueChanged``, # checkboxes emit ``stateChanged``. for w in ( self.length_input, self.symmetric_checkbox, self.invert_checkbox, self.cut_checkbox, self.union_checkbox, self.through_all_checkbox, self.cut_all_bodies_checkbox, self.rounded_checkbox, ): if isinstance(w, QDoubleSpinBox): w.valueChanged.connect(self._emit_preview) else: w.stateChanged.connect(self._emit_preview) def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: """Install the live-preview callback (or *None* to disable).""" self._preview_callback = callback # Emit once so the initial state shows a preview right away. self._emit_preview() def _emit_preview(self, *args: Any) -> None: if self._preview_callback is None: return try: self._preview_callback(self.get_values()) except Exception as exc: # preview must never break the dialog logger.debug("extrude preview callback raised: %s", exc) def hideEvent(self, event: Any) -> None: # Tell the host to clear the preview when the dialog goes away # (accept, reject, or close). The host is responsible for the # actual viewer cleanup. if self._preview_callback is not None: try: self._preview_callback(None) except Exception: pass super().hideEvent(event) def get_values(self) -> Tuple[float, bool, bool, bool, bool, bool, bool, bool]: return ( self.length_input.value(), self.symmetric_checkbox.isChecked(), self.invert_checkbox.isChecked(), self.cut_checkbox.isChecked(), self.union_checkbox.isChecked(), self.through_all_checkbox.isChecked(), self.cut_all_bodies_checkbox.isChecked(), self.rounded_checkbox.isChecked(), ) class RevolveDialog(QDialog): """Dialog for revolve options. ``line_axis`` is ``(line_entity_id, origin, direction)`` from a line selected in the sketch (see Sketch2DWidget.get_selected_revolve_axis). When provided the dialog offers “Selected sketch line” as the revolve axis (default); X / Y / Z world axes through the origin remain available as a fallback. """ def __init__(self, parent: Optional[QWidget] = None, line_axis: Optional[Tuple] = None): super().__init__(parent) self.setWindowTitle("Revolve Options") self.setMinimumWidth(340) self._line_axis = line_axis # (line_id, origin, direction) or None layout = QVBoxLayout(self) angle_layout = QHBoxLayout() angle_layout.addWidget(QLabel("Revolve Angle (°):")) self.angle_input = QDoubleSpinBox() self.angle_input.setDecimals(1) self.angle_input.setRange(1, 360) self.angle_input.setValue(360) self.angle_input.setSuffix("°") angle_layout.addWidget(self.angle_input) layout.addLayout(angle_layout) axis_layout = QHBoxLayout() axis_layout.addWidget(QLabel("Axis:")) self._axis_group = QButtonGroup(self) self._axis_buttons = {} for label, vec in (("X", (1, 0, 0)), ("Y", (0, 1, 0)), ("Z", (0, 0, 1))): btn = QRadioButton(label) btn.setProperty("axis", vec) self._axis_group.addButton(btn) self._axis_buttons[label] = btn axis_layout.addWidget(btn) if line_axis is not None: self._line_button = QRadioButton("Selected line") self._line_button.setToolTip( "Revolve around the line selected in the sketch (origin = its start point)" ) self._axis_group.addButton(self._line_button) axis_layout.addWidget(self._line_button) self._line_button.setChecked(True) else: self._line_button = None self._axis_buttons["Z"].setChecked(True) # backward compatible default axis_layout.addStretch() layout.addLayout(axis_layout) line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() ok_button = QPushButton("OK") ok_button.clicked.connect(self.accept) cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) def get_values(self) -> Tuple[float, Tuple[float, float, float], Tuple[float, float, float], bool]: """Return ``(angle_deg, axis_vector, origin, use_line)``. ``use_line`` is True when the revolve should use the sketch line that was selected before the dialog opened (axis/origin from the line). Otherwise axis is one of the X / Y / Z unit vectors and origin is ``(0, 0, 0)``. """ checked = self._axis_group.checkedButton() if checked is not None and checked is self._line_button and self._line_axis is not None: _, origin, direction = self._line_axis return self.angle_input.value(), tuple(direction), tuple(origin), True axis = (0, 0, 1) if checked is not None: vec = checked.property("axis") if vec: axis = tuple(float(v) for v in vec) return self.angle_input.value(), axis, (0.0, 0.0, 0.0), False class OffsetDialog(QDialog): """Dialog for 2D sketch offset options. Shows a number input for the offset distance with a live preview callback so the sketch widget can render the offset result in real time. On accept the caller retrieves ``get_values()`` → distance. """ def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setWindowTitle("Offset Sketch") self.setMinimumWidth(300) self._preview_callback = None layout = QVBoxLayout(self) dist_layout = QHBoxLayout() dist_layout.addWidget(QLabel("Offset Distance (mm):")) self.distance_input = QDoubleSpinBox() self.distance_input.setDecimals(2) self.distance_input.setRange(-10000, 10000) self.distance_input.setValue(10.0) self.distance_input.setSingleStep(0.5) dist_layout.addWidget(self.distance_input) layout.addLayout(dist_layout) self.inward_checkbox = QCheckBox("Offset Inward (negative)") self.inward_checkbox.setToolTip("Offset is applied inward instead of outward.") layout.addWidget(self.inward_checkbox) line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() ok_button = QPushButton("OK") ok_button.clicked.connect(self.accept) cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) # Live preview on every value change. self.distance_input.valueChanged.connect(self._emit_preview) self.inward_checkbox.stateChanged.connect(self._emit_preview) def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: """Install the live-preview callback (or *None* to disable).""" self._preview_callback = callback self._emit_preview() def _emit_preview(self, *args: Any) -> None: if self._preview_callback is None: return try: self._preview_callback(self.get_values()) except Exception as exc: logger.debug("offset preview callback raised: %s", exc) def hideEvent(self, event: Any) -> None: if self._preview_callback is not None: try: self._preview_callback(None) except Exception: pass super().hideEvent(event) def get_values(self) -> Tuple[float, bool]: return (self.distance_input.value(), self.inward_checkbox.isChecked()) class ArrayDialog(QDialog): """Dialog for the array (pattern) tool: linear or circular repeats. Asks the user how many repeats (the total item count, original included) plus the pattern geometry: - **Linear**: spacing between adjacent items and the direction (X / Y / Z presets or a custom 3D vector — negative spacing flips the direction). - **Circular**: total angular span (default 360°) over which the copies are evenly distributed, the rotation axis (X / Y / Z or custom vector) and the axis origin point. A live-preview callback (``set_preview_callback``) fires on every change so the host can show the repeated copies plus a direction / axis indicator in the 3D view before committing. ``get_values()`` returns a dict: ``{"pattern_type", "count", "spacing", "direction", "angle", "axis", "origin"}``. """ def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setWindowTitle("Array Options") self.setMinimumWidth(400) self._preview_callback: Optional[Callable[[Any], None]] = None layout = QVBoxLayout(self) # ── Pattern type ── type_layout = QHBoxLayout() type_layout.addWidget(QLabel("Pattern type:")) self.linear_radio = QRadioButton("Linear") self.linear_radio.setChecked(True) self.circular_radio = QRadioButton("Circular") self.type_group = QButtonGroup(self) self.type_group.addButton(self.linear_radio) self.type_group.addButton(self.circular_radio) type_layout.addWidget(self.linear_radio) type_layout.addWidget(self.circular_radio) type_layout.addStretch() layout.addLayout(type_layout) # ── Repeats (shared by both types) ── count_layout = QHBoxLayout() count_layout.addWidget(QLabel("Repeats (items, incl. original):")) self.count_input = QSpinBox() self.count_input.setRange(1, 1000) self.count_input.setValue(3) self.count_input.setToolTip( "Total number of items in the array, including the original body." ) count_layout.addWidget(self.count_input) count_layout.addStretch() layout.addLayout(count_layout) # ── Linear group ── self.linear_group = QGroupBox("Linear Pattern") lin = QVBoxLayout(self.linear_group) spacing_layout = QHBoxLayout() spacing_layout.addWidget(QLabel("Spacing (mm):")) self.spacing_input = QDoubleSpinBox() self.spacing_input.setDecimals(2) self.spacing_input.setRange(-100000.0, 100000.0) self.spacing_input.setValue(10.0) self.spacing_input.setSingleStep(1.0) self.spacing_input.setToolTip( "Distance between adjacent items. Negative flips the direction." ) spacing_layout.addWidget(self.spacing_input) spacing_layout.addStretch() lin.addLayout(spacing_layout) lin.addWidget(QLabel("Direction:")) dir_btns = QHBoxLayout() self.dir_x_radio = QRadioButton("X") self.dir_x_radio.setChecked(True) self.dir_y_radio = QRadioButton("Y") self.dir_z_radio = QRadioButton("Z") self.dir_custom_radio = QRadioButton("Custom") self.dir_group = QButtonGroup(self) for b in (self.dir_x_radio, self.dir_y_radio, self.dir_z_radio, self.dir_custom_radio): self.dir_group.addButton(b) dir_btns.addWidget(b) dir_btns.addStretch() lin.addLayout(dir_btns) self.dir_custom_row = QHBoxLayout() self.dir_custom_row.addWidget(QLabel("Vector:")) self.dir_x_input = self._vector_spin() self.dir_y_input = self._vector_spin() self.dir_z_input = self._vector_spin() self.dir_custom_row.addWidget(self.dir_x_input) self.dir_custom_row.addWidget(self.dir_y_input) self.dir_custom_row.addWidget(self.dir_z_input) self.dir_custom_row.addStretch() lin.addLayout(self.dir_custom_row) self._set_custom_enabled(self.dir_x_input, self.dir_y_input, self.dir_z_input, enabled=False) layout.addWidget(self.linear_group) # ── Circular group ── self.circular_group = QGroupBox("Circular Pattern") circ = QVBoxLayout(self.circular_group) angle_layout = QHBoxLayout() angle_layout.addWidget(QLabel("Total angle (°):")) self.angle_input = QDoubleSpinBox() self.angle_input.setDecimals(1) self.angle_input.setRange(-3600.0, 3600.0) self.angle_input.setValue(360.0) self.angle_input.setSingleStep(15.0) self.angle_input.setSuffix("°") self.angle_input.setToolTip( "Total angular span over which the copies are evenly distributed. " "360° gives a full evenly-spaced ring (step = angle / count)." ) angle_layout.addWidget(self.angle_input) angle_layout.addStretch() circ.addLayout(angle_layout) circ.addWidget(QLabel("Rotation axis:")) axis_btns = QHBoxLayout() self.axis_z_radio = QRadioButton("Z") self.axis_z_radio.setChecked(True) self.axis_x_radio = QRadioButton("X") self.axis_y_radio = QRadioButton("Y") self.axis_custom_radio = QRadioButton("Custom") self.axis_group = QButtonGroup(self) for b in (self.axis_x_radio, self.axis_y_radio, self.axis_z_radio, self.axis_custom_radio): self.axis_group.addButton(b) axis_btns.addWidget(b) axis_btns.addStretch() circ.addLayout(axis_btns) self.axis_custom_row = QHBoxLayout() self.axis_custom_row.addWidget(QLabel("Vector:")) self.axis_x_input = self._vector_spin() self.axis_y_input = self._vector_spin() self.axis_z_input = self._vector_spin() self.axis_custom_row.addWidget(self.axis_x_input) self.axis_custom_row.addWidget(self.axis_y_input) self.axis_custom_row.addWidget(self.axis_z_input) self.axis_custom_row.addStretch() circ.addLayout(self.axis_custom_row) self._set_custom_enabled(self.axis_x_input, self.axis_y_input, self.axis_z_input, enabled=False) origin_layout = QHBoxLayout() origin_layout.addWidget(QLabel("Axis origin (mm):")) self.origin_x_input = self._vector_spin(-100000, 100000) self.origin_y_input = self._vector_spin(-100000, 100000) self.origin_z_input = self._vector_spin(-100000, 100000) origin_layout.addWidget(self.origin_x_input) origin_layout.addWidget(self.origin_y_input) origin_layout.addWidget(self.origin_z_input) origin_layout.addStretch() circ.addLayout(origin_layout) layout.addWidget(self.circular_group) # ── Buttons ── line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() ok_button = QPushButton("Apply Array") ok_button.clicked.connect(self.accept) cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) # ── Signals / live preview ── self.linear_radio.toggled.connect(self._on_type_changed) self.circular_radio.toggled.connect(self._on_type_changed) self.count_input.valueChanged.connect(self._emit_preview) self.spacing_input.valueChanged.connect(self._emit_preview) self.angle_input.valueChanged.connect(self._emit_preview) self.dir_group.buttonToggled.connect(self._on_direction_changed) self.axis_group.buttonToggled.connect(self._on_axis_changed) for w in ( self.dir_x_input, self.dir_y_input, self.dir_z_input, self.axis_x_input, self.axis_y_input, self.axis_z_input, self.origin_x_input, self.origin_y_input, self.origin_z_input, ): w.valueChanged.connect(self._emit_preview) # ── Widget builders ── @staticmethod def _vector_spin(lo: float = -100000.0, hi: float = 100000.0) -> QDoubleSpinBox: """A compact spinbox for a vector component (axis/direction/origin).""" spin = QDoubleSpinBox() spin.setDecimals(2) spin.setRange(lo, hi) spin.setValue(0.0) spin.setSingleStep(1.0) spin.setFixedWidth(80) return spin @staticmethod def _set_custom_enabled(*spins: QDoubleSpinBox, enabled: bool) -> None: for s in spins: s.setEnabled(enabled) # ── State handling ── def _on_type_changed(self) -> None: """Enable only the group matching the selected pattern type.""" linear = self.linear_radio.isChecked() self.linear_group.setEnabled(linear) self.circular_group.setEnabled(not linear) self._emit_preview() def _on_direction_changed(self, *args: Any) -> None: custom = self.dir_custom_radio.isChecked() self._set_custom_enabled(self.dir_x_input, self.dir_y_input, self.dir_z_input, enabled=custom) self._emit_preview() def _on_axis_changed(self, *args: Any) -> None: custom = self.axis_custom_radio.isChecked() self._set_custom_enabled(self.axis_x_input, self.axis_y_input, self.axis_z_input, enabled=custom) self._emit_preview() def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: """Install the live-preview callback; fires immediately with defaults.""" self._preview_callback = callback self._emit_preview() def _emit_preview(self, *args: Any) -> None: if self._preview_callback is None: return try: self._preview_callback(self.get_values()) except Exception as exc: logger.debug("array preview callback raised: %s", exc) def hideEvent(self, event: Any) -> None: if self._preview_callback is not None: try: self._preview_callback(None) except Exception: pass super().hideEvent(event) # ── Accessors ── def _selected_vector( self, x_radio: QRadioButton, y_radio: QRadioButton, z_radio: QRadioButton, custom_radio: QRadioButton, x_in: QDoubleSpinBox, y_in: QDoubleSpinBox, z_in: QDoubleSpinBox, ) -> Tuple[float, float, float]: """Return the vector from the preset/custom radio selection.""" if x_radio.isChecked(): return (1.0, 0.0, 0.0) if y_radio.isChecked(): return (0.0, 1.0, 0.0) if z_radio.isChecked(): return (0.0, 0.0, 1.0) if custom_radio.isChecked(): return (x_in.value(), y_in.value(), z_in.value()) return (1.0, 0.0, 0.0) def get_values(self) -> Dict[str, Any]: """Return the current pattern parameters as a dict. Keys: ``pattern_type``, ``count``, ``spacing``, ``direction``, ``angle``, ``axis``, ``origin``. """ linear = self.linear_radio.isChecked() direction = self._selected_vector( self.dir_x_radio, self.dir_y_radio, self.dir_z_radio, self.dir_custom_radio, self.dir_x_input, self.dir_y_input, self.dir_z_input, ) axis = self._selected_vector( self.axis_x_radio, self.axis_y_radio, self.axis_z_radio, self.axis_custom_radio, self.axis_x_input, self.axis_y_input, self.axis_z_input, ) return { "pattern_type": "linear" if linear else "circular", "count": self.count_input.value(), "spacing": self.spacing_input.value(), "direction": direction, "angle": self.angle_input.value(), "axis": axis, "origin": ( self.origin_x_input.value(), self.origin_y_input.value(), self.origin_z_input.value(), ), } class WorkplaneOrientationDialog(QDialog): """Modal dialog to choose the orientation of a new workplane. Offers XY, XZ, YZ, and custom angle presets. On accept, the caller can retrieve the chosen orientation via :meth:`get_orientation`, which returns (normal, x_dir) pair (both as 3-tuples). """ def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setWindowTitle("New Workplane Orientation") self.setMinimumWidth(320) self._normal: Tuple[float, float, float] = (0.0, 0.0, 1.0) self._x_dir: Tuple[float, float, float] = (1.0, 0.0, 0.0) # Optional callback for live 3D preview of the workplane. # The host installs it via ``set_preview_callback``. The callback # receives ``(normal, x_dir)`` or *None* to clear. self._preview_callback = None layout = QVBoxLayout(self) # ── Orientation presets ── lbl = QLabel("Choose orientation:") layout.addWidget(lbl) self._preset_group = QButtonGroup(self) # normal/x_dir per preset, keyed by the QButtonGroup id — QRadioButton # has no data slot of its own, so stash the vectors here instead of # duck-typing extra attributes onto the widget. self._preset_vectors: Dict[ int, Tuple[Tuple[float, float, float], Tuple[float, float, float]] ] = {} preset_layout = QGridLayout() presets = [ ("XY (Top)", (0, 0, 1), (1, 0, 0)), ("XZ (Front)", (0, 1, 0), (1, 0, 0)), ("YZ (Right)", (1, 0, 0), (0, 1, 0)), ("-XY (Bottom)", (0, 0, -1), (1, 0, 0)), ("-XZ (Back)", (0, -1, 0), (1, 0, 0)), ("-YZ (Left)", (-1, 0, 0), (0, 1, 0)), ] for idx, (label, normal, x_dir) in enumerate(presets): btn = QRadioButton(label) btn.setChecked(idx == 0) self._preset_group.addButton(btn, idx) self._preset_vectors[idx] = (_vec3(normal), _vec3(x_dir)) preset_layout.addWidget(btn, idx // 2, idx % 2) layout.addLayout(preset_layout) # ── Custom angle (offset from XY) ── line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) self._custom_radio = QRadioButton("Custom (angle from XY):") self._custom_radio.setChecked(False) layout.addWidget(self._custom_radio) angle_layout = QHBoxLayout() angle_layout.addWidget(QLabel("Angle X (°):")) self._angle_x = QDoubleSpinBox() self._angle_x.setDecimals(1) self._angle_x.setRange(-360, 360) self._angle_x.setValue(0.0) self._angle_x.setSuffix("°") angle_layout.addWidget(self._angle_x) angle_layout.addWidget(QLabel("Angle Y (°):")) self._angle_y = QDoubleSpinBox() self._angle_y.setDecimals(1) self._angle_y.setRange(-360, 360) self._angle_y.setValue(0.0) self._angle_y.setSuffix("°") angle_layout.addWidget(self._angle_y) layout.addLayout(angle_layout) self._name_label = QLabel("Workplane Name:") layout.addWidget(self._name_label) self._name_input = QLineEdit() self._name_input.setText("Workplane 1") layout.addWidget(self._name_input) # ── Buttons ── line2 = QFrame() line2.setFrameShape(QFrame.Shape.HLine) line2.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line2) button_layout = QHBoxLayout() ok_button = QPushButton("Create") ok_button.clicked.connect(self._on_ok) cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) # Live preview: update the 3D view whenever the user changes # the preset, custom radio toggle, or angle values. self._preset_group.buttonClicked.connect(self._on_preset_changed) self._custom_radio.toggled.connect(self._emit_preview) self._angle_x.valueChanged.connect(self._emit_preview) self._angle_y.valueChanged.connect(self._emit_preview) def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: """Install a callback for live 3D preview of the workplane orientation. *callback* is called with ``(normal, x_dir)`` whenever the user changes the selection, or with *None* when the dialog closes. """ self._preview_callback = callback # Emit once so the initial state shows a preview right away. self._emit_preview() def _emit_preview(self, *args: Any) -> None: """Call the preview callback with the current orientation, if installed.""" if self._preview_callback is None: return try: normal, x_dir, _name = self.get_orientation() self._preview_callback((normal, x_dir)) except Exception as exc: logger.debug("workplane preview callback raised: %s", exc) def hideEvent(self, event: Any) -> None: """Clear the live preview when the dialog closes.""" if self._preview_callback is not None: try: self._preview_callback(None) except Exception: pass super().hideEvent(event) def _on_preset_changed(self, btn: Any) -> None: """When a preset is selected, deselect the custom radio and emit preview.""" self._custom_radio.setChecked(False) self._emit_preview() def _compute_custom_orientation( self, ) -> Optional[Tuple[Tuple[float, float, float], Tuple[float, float, float]]]: """Compute ``(normal, x_dir)`` from the custom angle spinboxes. Starts from the +Z normal and rotates by the two angle values. Returns *None* if the math fails (defensive — the dialog then falls back to the default orientation instead of crashing). """ import numpy as np try: ax = math.radians(self._angle_x.value()) ay = math.radians(self._angle_y.value()) # Start from +Z normal, rotate around X then Y n = np.array([0.0, 0.0, 1.0]) # Rotate around X rx = np.array( [ [1, 0, 0], [0, math.cos(ax), -math.sin(ax)], [0, math.sin(ax), math.cos(ax)], ] ) n = rx @ n # Rotate around Y ry = np.array( [ [math.cos(ay), 0, math.sin(ay)], [0, 1, 0], [-math.sin(ay), 0, math.cos(ay)], ] ) n = ry @ n n_norm = np.linalg.norm(n) if n_norm < 1e-12: return None n = n / n_norm # x_dir: cross product of normal with world Y, or world Z if normal ~ Y world_y = np.array([0.0, 1.0, 0.0]) if abs(float(np.dot(n, world_y))) > 0.99: world_y = np.array([0.0, 0.0, 1.0]) x = np.cross(world_y, n) x_norm = np.linalg.norm(x) if x_norm > 1e-9: x = x / x_norm else: x = np.array([1.0, 0.0, 0.0]) return ( (float(n[0]), float(n[1]), float(n[2])), (float(x[0]), float(x[1]), float(x[2])), ) except Exception as exc: logger.debug("custom workplane orientation math failed: %s", exc) return None def _on_ok(self) -> None: """Compute the final orientation and accept.""" if self._custom_radio.isChecked(): orientation = self._compute_custom_orientation() if orientation is not None: self._normal, self._x_dir = orientation else: btn = self._preset_group.checkedButton() if btn is not None: self._normal, self._x_dir = self._preset_vectors[self._preset_group.id(btn)] self.accept() def get_orientation(self) -> Tuple[Tuple[float, float, float], Tuple[float, float, float], str]: """Return (normal, x_dir, name) for the chosen workplane. Computes the current selection from the UI state so it works whether called before or after ``_on_ok``. """ if self._custom_radio.isChecked(): orientation = self._compute_custom_orientation() if orientation is None: orientation = ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0)) normal, x_dir = orientation return ( normal, x_dir, self._name_input.text().strip() or "Workplane", ) else: btn = self._preset_group.checkedButton() if btn is not None: normal, x_dir = self._preset_vectors[self._preset_group.id(btn)] return (normal, x_dir, self._name_input.text().strip() or "Workplane") # Fallback: XY default. return ( (0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane", ) # ── Metric thread data (coarse pitch) ────────────────────────────────── METRIC_THREADS = { "M1": (1.0, 0.25), "M1.2": (1.2, 0.25), "M1.4": (1.4, 0.30), "M1.6": (1.6, 0.35), "M1.8": (1.8, 0.35), "M2": (2.0, 0.40), "M2.5": (2.5, 0.45), "M3": (3.0, 0.50), "M3.5": (3.5, 0.60), "M4": (4.0, 0.70), "M5": (5.0, 0.80), "M6": (6.0, 1.00), "M7": (7.0, 1.00), "M8": (8.0, 1.25), "M10": (10.0, 1.50), "M12": (12.0, 1.75), "M14": (14.0, 2.00), "M16": (16.0, 2.00), "M18": (18.0, 2.50), "M20": (20.0, 2.50), "M22": (22.0, 2.50), "M24": (24.0, 3.00), "M27": (27.0, 3.00), "M30": (30.0, 3.50), "M32": (32.0, 3.50), } def _closest_metric_thread(diameter_mm: float) -> Optional[Tuple[str, float, float]]: """Return the closest metric thread ``(name, nominal_dia, pitch)`` for a cylinder of *diameter_mm*, or *None* if no close match. """ best: Optional[Tuple[str, float, float, float]] = None # name, dia, pitch, diff for name, (dia, pitch) in METRIC_THREADS.items(): diff = abs(dia - diameter_mm) if best is None or diff < best[3]: best = (name, dia, pitch, diff) if best is None: return None return (best[0], best[1], best[2]) class ThreadDialog(QDialog): """Dialog for applying an ISO metric thread to a cylindrical face. The user picks a metric size (M1–M32) and optionally overrides the pitch. When the dialog was opened with a detected cylinder diameter the closest size is pre-selected. """ def __init__( self, parent: Optional[QWidget] = None, detected_diameter: Optional[float] = None, ): super().__init__(parent) self.setWindowTitle("Thread Options") self.setMinimumWidth(340) self._preview_callback: Optional[Callable[[Any], None]] = None layout = QVBoxLayout(self) # ── Size selection ── layout.addWidget(QLabel("Thread Size:")) self.size_combo = QComboBox() self.size_combo.setToolTip("Select the metric thread size.") for name in METRIC_THREADS: dia, pitch = METRIC_THREADS[name] self.size_combo.addItem(f"{name} (Ø{dia:g} mm, pitch {pitch:g} mm)", name) layout.addWidget(self.size_combo) # ── Pitch override ── pitch_row = QHBoxLayout() pitch_row.addWidget(QLabel("Pitch (mm):")) self.pitch_input = QDoubleSpinBox() self.pitch_input.setDecimals(2) self.pitch_input.setRange(0.1, 10.0) self.pitch_input.setValue(1.0) self.pitch_input.setSingleStep(0.05) self.pitch_input.setToolTip("Override the standard pitch if needed.") pitch_row.addWidget(self.pitch_input) layout.addLayout(pitch_row) # ── Thread type ── type_row = QHBoxLayout() type_row.addWidget(QLabel("Type:")) self.external_radio = QRadioButton("External (shaft)") self.external_radio.setChecked(True) self.internal_radio = QRadioButton("Internal (hole)") type_row.addWidget(self.external_radio) type_row.addWidget(self.internal_radio) layout.addLayout(type_row) # ── Length ── len_row = QHBoxLayout() len_row.addWidget(QLabel("Thread Length (mm):")) self.length_input = QDoubleSpinBox() self.length_input.setDecimals(2) self.length_input.setRange(0.1, 10000.0) self.length_input.setValue(20.0) self.length_input.setToolTip( "Length of the threaded section. 0 = use full cylinder height." ) len_row.addWidget(self.length_input) layout.addLayout(len_row) # ── Cylinder info ── self.info_label = QLabel("") self.info_label.setStyleSheet("color: #8a8a8a;") layout.addWidget(self.info_label) if detected_diameter is not None: closest = _closest_metric_thread(detected_diameter) if closest is not None: name, dia, pitch = closest idx = self.size_combo.findData(name) if idx >= 0: self.size_combo.setCurrentIndex(idx) self.pitch_input.setValue(pitch) self.length_input.setValue(dia * 3.0) # sensible default length self.info_label.setText( f"Detected cylinder Ø ≈ {detected_diameter:.2f} mm → closest: {name}" ) # ── Signals ── self.size_combo.currentIndexChanged.connect(self._on_size_changed) self.pitch_input.valueChanged.connect(self._emit_preview) self.external_radio.toggled.connect(self._emit_preview) self.internal_radio.toggled.connect(self._emit_preview) self.length_input.valueChanged.connect(self._emit_preview) line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() ok_button = QPushButton("Apply Thread") ok_button.clicked.connect(self.accept) cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) def _on_size_changed(self) -> None: """Update pitch when the user picks a different size.""" name = self.size_combo.currentData() if name and name in METRIC_THREADS: _dia, pitch = METRIC_THREADS[name] self.pitch_input.setValue(pitch) self._emit_preview() def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: """Install a live-preview callback; fires immediately.""" self._preview_callback = callback self._emit_preview() def _emit_preview(self, *args: Any) -> None: if self._preview_callback is None: return try: self._preview_callback(self.get_values()) except Exception as exc: logger.debug("thread preview callback raised: %s", exc) def hideEvent(self, event: Any) -> None: if self._preview_callback is not None: try: self._preview_callback(None) except Exception: pass super().hideEvent(event) def get_values(self) -> Tuple[str, float, float, bool, float]: """Return ``(size_name, nominal_diameter, pitch, internal, length)``.""" name = self.size_combo.currentData() dia, _std_pitch = METRIC_THREADS.get(name, (0.0, 0.0)) return ( name or "M6", dia, self.pitch_input.value(), self.internal_radio.isChecked(), self.length_input.value(), ) class FilletDialog(QDialog): """Dialog for fillet options — the common settings from CAD fillet tools. Shown AFTER the user has picked the two faces whose shared edges will be rounded. Offers: - size, entered as **diameter** or **radius** (the user asked for a diameter box; the unit toggle covers the radius crowd), - **tangent propagation** (extend the round along tangent-connected edges, like FreeCAD/SolidWorks "tangent chain"), - edge **scope** (only the edges between the two picked faces vs every edge of the body), - a live 3D preview (``set_preview_callback``), so dragging the size spinner shows the fillet in real time before committing. ``get_values()`` returns ``(size, size_is_diameter, tangent_propagation, scope)`` where *scope* is ``"selected"`` or ``"all"``. The host converts *size* to a radius (``size / 2`` for diameter). """ def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setWindowTitle("Fillet Options") self.setMinimumWidth(360) self._preview_callback: Optional[Callable[[Any], None]] = None layout = QVBoxLayout(self) # ── Size: value + Diameter/Radius unit ── size_row = QHBoxLayout() self.size_unit_combo = QComboBox() self.size_unit_combo.addItems(["Diameter", "Radius"]) self.size_unit_combo.setToolTip( "Enter the fillet size as a diameter or a radius (radius = diameter / 2)." ) size_row.addWidget(self.size_unit_combo) self.size_value_label = QLabel("Diameter (mm):") size_row.addWidget(self.size_value_label) self.size_input = QDoubleSpinBox() self.size_input.setDecimals(2) self.size_input.setRange(0.01, 100000.0) self.size_input.setValue(2.0) self.size_input.setSingleStep(0.5) self.size_input.setSuffix(" mm") self.size_input.setToolTip("Fillet size along the rounded edge.") size_row.addWidget(self.size_input) layout.addLayout(size_row) # ── Edge scope ── self.scope_group = QButtonGroup(self) scope_layout = QGridLayout() self.scope_selected_radio = QRadioButton("Edges between faces") self.scope_selected_radio.setChecked(True) self.scope_selected_radio.setToolTip("Round only the edges shared by the two picked faces.") self.scope_all_radio = QRadioButton("All edges of body") self.scope_all_radio.setToolTip( "Round every edge of the body (the picked faces only choose which body is modified)." ) self.scope_group.addButton(self.scope_selected_radio) self.scope_group.addButton(self.scope_all_radio) scope_layout.addWidget(self.scope_selected_radio, 0, 0) scope_layout.addWidget(self.scope_all_radio, 1, 0) layout.addLayout(scope_layout) # ── Tangent propagation ── self.tangent_checkbox = QCheckBox("Tangent propagation") self.tangent_checkbox.setChecked(True) self.tangent_checkbox.setToolTip( "Extend the fillet along edges that are tangent to the picked " "ones (e.g. a smooth chain of lines and arcs). Off = only the " "exact edges between the two faces." ) layout.addWidget(self.tangent_checkbox) # ── Edge count feedback ── self.edge_label = QLabel("") self.edge_label.setStyleSheet("color: #8a8a8a;") layout.addWidget(self.edge_label) line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() ok_button = QPushButton("Apply Fillet") ok_button.clicked.connect(self.accept) cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) # ── Live preview on every change ── self.size_unit_combo.currentIndexChanged.connect(self._on_unit_changed) self.size_input.valueChanged.connect(self._emit_preview) self.scope_selected_radio.toggled.connect(self._emit_preview) self.tangent_checkbox.stateChanged.connect(self._emit_preview) def _on_unit_changed(self) -> None: """Swap the size label between Diameter and Radius.""" self.size_value_label.setText( "Radius (mm):" if self.size_unit_combo.currentText() == "Radius" else "Diameter (mm):" ) self._emit_preview() def set_edge_count(self, count: int) -> None: """Show how many edges the current scope will round.""" self.edge_label.setText( f"Fillets {count} edge{'s' if count != 1 else ''} between the picked faces." ) def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: """Install a live-preview callback; fires immediately with defaults.""" self._preview_callback = callback self._emit_preview() def _emit_preview(self, *args: Any) -> None: if self._preview_callback is None: return try: self._preview_callback(self.get_values()) except Exception as exc: # preview must never break the dialog logger.debug("fillet preview callback raised: %s", exc) def hideEvent(self, event: Any) -> None: if self._preview_callback is not None: try: self._preview_callback(None) except Exception: pass super().hideEvent(event) def get_values(self) -> Tuple[float, bool, bool, str]: """Return ``(size, size_is_diameter, tangent_propagation, scope)``.""" return ( self.size_input.value(), self.size_unit_combo.currentText() == "Diameter", self.tangent_checkbox.isChecked(), "all" if self.scope_all_radio.isChecked() else "selected", ) class ChamferDialog(QDialog): """Dialog for chamfer options — shown after the user picks two faces. The user picks the two faces whose shared edges will be beveled (same flow as the fillet tool). This dialog offers: - chamfer **size** (mm) — the equal distance cut from each face along the shared edge (BRepFilletAPI_MakeChamfer.Add(size, edge) cuts the same amount on both faces), - edge **scope** (only the edges between the two picked faces vs every edge of the body), - **tangent propagation** (extend the chamfer along tangent- connected edges, like the fillet tool), - a live 3D preview (``set_preview_callback``). ``get_values()`` returns ``(size, tangent_propagation, scope)`` where *scope* is ``"selected"`` or ``"all"``. """ def __init__(self, parent: Optional[QWidget] = None): super().__init__(parent) self.setWindowTitle("Chamfer Options") self.setMinimumWidth(360) self._preview_callback: Optional[Callable[[Any], None]] = None layout = QVBoxLayout(self) # ── Size ── size_row = QHBoxLayout() size_row.addWidget(QLabel("Size (mm):")) self.size_input = QDoubleSpinBox() self.size_input.setDecimals(2) self.size_input.setRange(0.01, 100000.0) self.size_input.setValue(2.0) self.size_input.setSingleStep(0.5) self.size_input.setSuffix(" mm") self.size_input.setToolTip( "Chamfer size: the equal distance cut from each face along " "the shared edge (a symmetric 45\u00b0 bevel)." ) size_row.addWidget(self.size_input) layout.addLayout(size_row) # ── Edge scope ── self.scope_group = QButtonGroup(self) scope_layout = QGridLayout() self.scope_selected_radio = QRadioButton("Edges between faces") self.scope_selected_radio.setChecked(True) self.scope_selected_radio.setToolTip("Bevel only the edges shared by the two picked faces.") self.scope_all_radio = QRadioButton("All edges of body") self.scope_all_radio.setToolTip( "Bevel every edge of the body (the picked faces only choose which body is modified)." ) self.scope_group.addButton(self.scope_selected_radio) self.scope_group.addButton(self.scope_all_radio) scope_layout.addWidget(self.scope_selected_radio, 0, 0) scope_layout.addWidget(self.scope_all_radio, 1, 0) layout.addLayout(scope_layout) # ── Tangent propagation ── self.tangent_checkbox = QCheckBox("Tangent propagation") self.tangent_checkbox.setChecked(True) self.tangent_checkbox.setToolTip( "Extend the chamfer along edges that are tangent to the picked " "ones (e.g. a smooth chain of lines and arcs). Off = only the " "exact edges between the two faces." ) layout.addWidget(self.tangent_checkbox) # ── Edge count feedback ── self.edge_label = QLabel("") self.edge_label.setStyleSheet("color: #8a8a8a;") layout.addWidget(self.edge_label) line = QFrame() line.setFrameShape(QFrame.Shape.HLine) line.setFrameShadow(QFrame.Shadow.Sunken) layout.addWidget(line) button_layout = QHBoxLayout() ok_button = QPushButton("Apply Chamfer") ok_button.clicked.connect(self.accept) cancel_button = QPushButton("Cancel") cancel_button.clicked.connect(self.reject) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) layout.addLayout(button_layout) # ── Live preview on every change ── self.size_input.valueChanged.connect(self._emit_preview) self.scope_selected_radio.toggled.connect(self._emit_preview) self.tangent_checkbox.stateChanged.connect(self._emit_preview) def set_edge_count(self, count: int) -> None: """Show how many edges the current scope will bevel.""" self.edge_label.setText( f"Chamfers {count} edge{'s' if count != 1 else ''} between the picked faces." ) def set_preview_callback(self, callback: Optional[Callable[[Any], None]]) -> None: """Install a live-preview callback; fires immediately with defaults.""" self._preview_callback = callback self._emit_preview() def _emit_preview(self, *args: Any) -> None: if self._preview_callback is None: return try: self._preview_callback(self.get_values()) except Exception as exc: # preview must never break the dialog logger.debug("chamfer preview callback raised: %s", exc) def hideEvent(self, event: Any) -> None: if self._preview_callback is not None: try: self._preview_callback(None) except Exception: pass super().hideEvent(event) def get_values(self) -> Tuple[float, bool, str]: """Return ``(size, tangent_propagation, scope)``.""" return ( self.size_input.value(), self.tangent_checkbox.isChecked(), "all" if self.scope_all_radio.isChecked() else "selected", )