- arc improvements, fillets, operations, bodys

This commit is contained in:
bklronin
2026-08-05 16:47:25 +02:00
parent a7e7a9f2c3
commit 1e9b0cab1c
15 changed files with 3506 additions and 92 deletions
+696 -3
View File
@@ -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 (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.
@@ -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",
)