- arc improvements, fillets, operations, bodys

This commit is contained in:
bklronin
2026-08-02 22:04:57 +02:00
parent baa2fd5d47
commit 48042659fc
10 changed files with 1481 additions and 442 deletions
+230 -84
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
import logging
import math
from typing import Tuple
from typing import Any, Callable, Dict, Optional, Tuple
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QDoubleSpinBox,
QFrame,
@@ -19,11 +20,20 @@ from PySide6.QtWidgets import (
QPushButton,
QRadioButton,
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.
@@ -33,7 +43,7 @@ class ExtrudeDialog(QDialog):
*None*) to the callback tells the host to clear the preview.
"""
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("Extrude Options")
self.setMinimumWidth(320)
@@ -82,8 +92,8 @@ class ExtrudeDialog(QDialog):
layout.addWidget(self.rounded_checkbox)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
@@ -95,9 +105,9 @@ class ExtrudeDialog(QDialog):
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
# Live preview: recompute on every option change. Use a light-
# weight guard so we don't emit before the host has wired up the
# callback.
# 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,
@@ -108,27 +118,18 @@ class ExtrudeDialog(QDialog):
self.cut_all_bodies_checkbox,
self.rounded_checkbox,
):
# The spinbox has valueChanged; the checkboxes have stateChanged.
# Each must be wired in its own try/except so that a missing
# signal on one widget type doesn't skip the OTHER signal's
# connection (the prior single-try version accidentally
# left checkboxes un-connected when valueChanged raised first).
try:
if isinstance(w, QDoubleSpinBox):
w.valueChanged.connect(self._emit_preview)
except AttributeError:
pass
try:
else:
w.stateChanged.connect(self._emit_preview)
except AttributeError:
pass
def set_preview_callback(self, callback) -> None:
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) -> None:
def _emit_preview(self, *args: Any) -> None:
if self._preview_callback is None:
return
try:
@@ -136,7 +137,7 @@ class ExtrudeDialog(QDialog):
except Exception as exc: # preview must never break the dialog
logger.debug("extrude preview callback raised: %s", exc)
def hideEvent(self, event):
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.
@@ -163,7 +164,7 @@ class ExtrudeDialog(QDialog):
class RevolveDialog(QDialog):
"""Dialog for revolve options."""
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("Revolve Options")
self.setMinimumWidth(300)
@@ -181,8 +182,8 @@ class RevolveDialog(QDialog):
layout.addLayout(angle_layout)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
@@ -203,7 +204,7 @@ class OffsetDialog(QDialog):
time. On accept the caller retrieves ``get_values()`` → distance.
"""
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("Offset Sketch")
self.setMinimumWidth(300)
@@ -227,8 +228,8 @@ class OffsetDialog(QDialog):
layout.addWidget(self.inward_checkbox)
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
button_layout = QHBoxLayout()
@@ -244,12 +245,12 @@ class OffsetDialog(QDialog):
self.distance_input.valueChanged.connect(self._emit_preview)
self.inward_checkbox.stateChanged.connect(self._emit_preview)
def set_preview_callback(self, callback) -> None:
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) -> None:
def _emit_preview(self, *args: Any) -> None:
if self._preview_callback is None:
return
try:
@@ -257,7 +258,7 @@ class OffsetDialog(QDialog):
except Exception as exc:
logger.debug("offset preview callback raised: %s", exc)
def hideEvent(self, event):
def hideEvent(self, event: Any) -> None:
if self._preview_callback is not None:
try:
self._preview_callback(None)
@@ -277,7 +278,7 @@ class WorkplaneOrientationDialog(QDialog):
returns (normal, x_dir) pair (both as 3-tuples).
"""
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setWindowTitle("New Workplane Orientation")
self.setMinimumWidth(320)
@@ -297,6 +298,12 @@ class WorkplaneOrientationDialog(QDialog):
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)),
@@ -310,15 +317,14 @@ class WorkplaneOrientationDialog(QDialog):
btn = QRadioButton(label)
btn.setChecked(idx == 0)
self._preset_group.addButton(btn, idx)
btn.normal = normal
btn.x_dir = x_dir
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.HLine)
line.setFrameShadow(QFrame.Sunken)
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line)
self._custom_radio = QRadioButton("Custom (angle from XY):")
@@ -351,8 +357,8 @@ class WorkplaneOrientationDialog(QDialog):
# ── Buttons ──
line2 = QFrame()
line2.setFrameShape(QFrame.HLine)
line2.setFrameShadow(QFrame.Sunken)
line2.setFrameShape(QFrame.Shape.HLine)
line2.setFrameShadow(QFrame.Shadow.Sunken)
layout.addWidget(line2)
button_layout = QHBoxLayout()
@@ -371,7 +377,7 @@ class WorkplaneOrientationDialog(QDialog):
self._angle_x.valueChanged.connect(self._emit_preview)
self._angle_y.valueChanged.connect(self._emit_preview)
def set_preview_callback(self, callback) -> None:
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
@@ -381,7 +387,7 @@ class WorkplaneOrientationDialog(QDialog):
# Emit once so the initial state shows a preview right away.
self._emit_preview()
def _emit_preview(self, *args) -> None:
def _emit_preview(self, *args: Any) -> None:
"""Call the preview callback with the current orientation, if installed."""
if self._preview_callback is None:
return
@@ -391,7 +397,7 @@ class WorkplaneOrientationDialog(QDialog):
except Exception as exc:
logger.debug("workplane preview callback raised: %s", exc)
def hideEvent(self, event):
def hideEvent(self, event: Any) -> None:
"""Clear the live preview when the dialog closes."""
if self._preview_callback is not None:
try:
@@ -400,17 +406,23 @@ class WorkplaneOrientationDialog(QDialog):
pass
super().hideEvent(event)
def _on_preset_changed(self, btn):
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 _on_ok(self):
"""Compute the final orientation and accept."""
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
if self._custom_radio.isChecked():
# Custom: start from XY normal and rotate by the two angles.
try:
ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value())
# Start from +Z normal, rotate around X then Y
@@ -433,10 +445,13 @@ class WorkplaneOrientationDialog(QDialog):
]
)
n = ry @ n
n = n / np.linalg.norm(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(np.dot(n, world_y)) > 0.99:
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)
@@ -444,14 +459,24 @@ class WorkplaneOrientationDialog(QDialog):
x = x / x_norm
else:
x = np.array([1.0, 0.0, 0.0])
self._normal = tuple(float(v) for v in n)
self._x_dir = tuple(float(v) for v in x)
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 = btn.normal
self._x_dir = btn.x_dir
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]:
@@ -460,50 +485,171 @@ class WorkplaneOrientationDialog(QDialog):
Computes the current selection from the UI state so it works
whether called before or after ``_on_ok``.
"""
import numpy as np
if self._custom_radio.isChecked():
ax = math.radians(self._angle_x.value())
ay = math.radians(self._angle_y.value())
n = np.array([0.0, 0.0, 1.0])
rx = np.array(
[
[1, 0, 0],
[0, math.cos(ax), -math.sin(ax)],
[0, math.sin(ax), math.cos(ax)],
]
)
n = rx @ n
ry = np.array(
[
[math.cos(ay), 0, math.sin(ay)],
[0, 1, 0],
[-math.sin(ay), 0, math.cos(ay)],
]
)
n = ry @ n
n = n / np.linalg.norm(n)
world_y = np.array([0.0, 1.0, 0.0])
if abs(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])
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 (
tuple(float(v) for v in n),
tuple(float(v) for v in x),
normal,
x_dir,
self._name_input.text().strip() or "Workplane",
)
else:
btn = self._preset_group.checkedButton()
if btn is not None:
return (btn.normal, btn.x_dir, self._name_input.text().strip() or "Workplane")
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",
)
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",
)