Files
fluencyCAD/src/fluency/ui/dialogs.py
T
2026-08-05 16:47:25 +02:00

1349 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 (M1M32) 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",
)