- arc improvements, fillets, operations, bodys
This commit is contained in:
+696
-3
@@ -14,11 +14,13 @@ from PySide6.QtWidgets import (
|
||||
QDoubleSpinBox,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -162,12 +164,20 @@ class ExtrudeDialog(QDialog):
|
||||
|
||||
|
||||
class RevolveDialog(QDialog):
|
||||
"""Dialog for revolve options."""
|
||||
"""Dialog for revolve options.
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
``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(300)
|
||||
self.setMinimumWidth(340)
|
||||
self._line_axis = line_axis # (line_id, origin, direction) or None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
@@ -181,6 +191,30 @@ class RevolveDialog(QDialog):
|
||||
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)
|
||||
@@ -195,6 +229,25 @@ class RevolveDialog(QDialog):
|
||||
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.
|
||||
@@ -270,6 +323,324 @@ class OffsetDialog(QDialog):
|
||||
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.
|
||||
|
||||
@@ -509,6 +880,197 @@ class WorkplaneOrientationDialog(QDialog):
|
||||
)
|
||||
|
||||
|
||||
# ── 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.
|
||||
|
||||
@@ -653,3 +1215,134 @@ class FilletDialog(QDialog):
|
||||
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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user