- Added save file foramt

- Split main.py refactor
This commit is contained in:
bklronin
2026-07-05 22:16:08 +02:00
parent 3a169007f7
commit 5269c0897c
14 changed files with 9117 additions and 7371 deletions
+494
View File
@@ -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")