- Added save file foramt
- Split main.py refactor
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
"""Dialogs for Fluency CAD operations: extrude, revolve, offset, workplane orientation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import Qt, QPoint, QPointF
|
||||
from PySide6.QtGui import QColor, QFont, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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=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.rounded_checkbox = QCheckBox("Round Edges")
|
||||
layout.addWidget(self.rounded_checkbox)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.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. Use a light-
|
||||
# weight guard so we don't emit before the host has wired up the
|
||||
# callback.
|
||||
for w in (
|
||||
self.length_input,
|
||||
self.symmetric_checkbox,
|
||||
self.invert_checkbox,
|
||||
self.cut_checkbox,
|
||||
self.union_checkbox,
|
||||
self.through_all_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:
|
||||
w.valueChanged.connect(self._emit_preview)
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
w.stateChanged.connect(self._emit_preview)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def set_preview_callback(self, callback) -> 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:
|
||||
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):
|
||||
# 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]:
|
||||
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.rounded_checkbox.isChecked(),
|
||||
)
|
||||
|
||||
|
||||
class RevolveDialog(QDialog):
|
||||
"""Dialog for revolve options."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Revolve Options")
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
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)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.HLine)
|
||||
line.setFrameShadow(QFrame.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)
|
||||
|
||||
|
||||
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=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.HLine)
|
||||
line.setFrameShadow(QFrame.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) -> None:
|
||||
"""Install the live-preview callback (or *None* to disable)."""
|
||||
self._preview_callback = callback
|
||||
self._emit_preview()
|
||||
|
||||
def _emit_preview(self, *args) -> 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):
|
||||
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 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=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)
|
||||
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)
|
||||
btn.normal = normal
|
||||
btn.x_dir = 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)
|
||||
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.HLine)
|
||||
line2.setFrameShadow(QFrame.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) -> 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) -> 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):
|
||||
"""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):
|
||||
"""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."""
|
||||
import numpy as np
|
||||
import math
|
||||
|
||||
if self._custom_radio.isChecked():
|
||||
# Custom: start from XY normal and rotate by the two angles.
|
||||
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 = n / np.linalg.norm(n)
|
||||
# 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:
|
||||
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])
|
||||
self._normal = tuple(float(v) for v in n)
|
||||
self._x_dir = tuple(float(v) for v in x)
|
||||
|
||||
else:
|
||||
btn = self._preset_group.checkedButton()
|
||||
if btn is not None:
|
||||
self._normal = btn.normal
|
||||
self._x_dir = btn.x_dir
|
||||
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``.
|
||||
"""
|
||||
import numpy as np
|
||||
import math
|
||||
|
||||
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])
|
||||
return (
|
||||
tuple(float(v) for v in n),
|
||||
tuple(float(v) for v in x),
|
||||
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")
|
||||
# Fallback: XY default.
|
||||
return ((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), self._name_input.text().strip() or "Workplane")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user