1349 lines
51 KiB
Python
1349 lines
51 KiB
Python
"""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",
|
||
)
|