Files
A1SwapModPacker/a1_swap_mod_packer/gui.py
T

1456 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
A1 SwapMod Packer 的 PySide6 图形用户界面模块。
提供完整的桌面 GUI,用于:
- 拖放/添加多个 .3mf 输入文件
- 配置换盘 G-code、打包选项、输出规则
- 单个或批量构建打包的 3MF 文件
- 显示板缩略图预览、合计统计和构建日志
主入口为 ``main()`` 函数,启动 QApplication 并显示 ``MainWindow``。
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import zipfile
from multiprocessing import freeze_support
from pathlib import Path
from typing import Any, Callable
# --- 平台适配:Windows 下使用 FreeType 字体引擎以避免字体渲染问题 ---
if sys.platform.startswith("win") and "QT_QPA_PLATFORM" not in os.environ:
os.environ["QT_QPA_PLATFORM"] = "windows:fontengine=freetype"
try:
from PySide6.QtCore import QEasingCurve, QEvent, QPropertyAnimation, Qt, QTimer, QUrl
from PySide6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QFont, QKeyEvent, QPixmap
from PySide6.QtWidgets import (
QApplication,
QAbstractItemView,
QCheckBox,
QComboBox,
QFileDialog,
QFormLayout,
QGridLayout,
QGraphicsOpacityEffect,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QSpinBox,
QStyle,
QTableWidget,
QTableWidgetItem,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
except ImportError as exc: # pragma: no cover
raise SystemExit("PySide6 is required to run the GUI. Install it with: pip install PySide6") from exc
from . import APP_NAME, APP_TITLE
from .batch import IndividualBuildTask, individual_batch_worker_count, run_individual_batch_builds
from .core import (
BuildOptions,
DEFAULT_ZIP_COMPRESS_LEVEL,
PlateJob,
ThreeMfSummary,
build_packed_3mf,
format_duration,
format_filament,
list_gcode_members,
list_swap_gcode_files,
read_3mf_summary,
)
from .builder import preview_members_for_gcode_member, resolve_output_gcode_member
from .paths import default_patch_config_path, default_swap_gcode_dir, user_settings_path
from .planning import (
DEFAULT_OUTPUT_PATTERN,
OutputNamingOptions,
OutputSummary,
make_unique_for_run,
resolve_output_path,
summarize_jobs_for_output,
three_mf_summary_from_mapping,
)
# --- 自定义 Qt 数据角色,用于在表格单元格中存储摘要和路径信息 ---
SUMMARY_ROLE = Qt.UserRole + 100 # 存储 ThreeMfSummary 数据的角色
PATH_ROLE = Qt.UserRole + 101 # 存储文件绝对路径的角色
# --- 表格列索引常量 ---
ORDER_COLUMN = 0 # 顺序列(上移/下移按钮)
FILE_COLUMN = 1 # 文件名列
COPIES_COLUMN = 2 # 份数列
TIME_COLUMN = 3 # 预估时间列
FILAMENT_COLUMN = 4 # 耗材用量列
# --- 3MF 预览图匹配正则:Metadata/plate_N.png 或 Metadata/plate_N_small.png ---
PREVIEW_IMAGE_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)
def preview_image_sort_key(member_name: str) -> tuple[int, int, str]:
"""返回预览图排序键:(是否小图, 板号, 原始名称)。
排序规则:大图优先于小图,板号升序,同号按名称排序。
"""
match = PREVIEW_IMAGE_RE.match(member_name)
plate_number = int(match.group(1)) if match else 9999
is_small = 1 if member_name.lower().endswith("_small.png") else 0
return (is_small, plate_number, member_name.lower())
def first_preview_image_member(member_names: list[str], gcode_member: str | None = None) -> str | None:
"""从 3MF 存档成员列表中查找最佳预览图。
优先匹配与指定 gcode_member 关联的预览图,其次匹配所有 plate_N.png。
"""
available_members = set(member_names)
if gcode_member is not None:
candidates = [name for name in preview_members_for_gcode_member(gcode_member) if name in available_members]
if candidates:
return sorted(candidates, key=preview_image_sort_key)[0]
candidates = [name for name in member_names if PREVIEW_IMAGE_RE.match(name)]
if not candidates:
candidates = [
name
for name in member_names
if name.lower().startswith("metadata/") and name.lower().endswith(".png")
]
if not candidates:
return None
return sorted(candidates, key=preview_image_sort_key)[0]
class SuccessToast(QLabel):
"""构建成功时的浮动提示条组件。
提供淡入-停留-淡出动画效果,自动居中于父窗口底部。
不会拦截鼠标事件,点击可穿透到下层控件。
"""
FADE_MS = 1000 # 淡入/淡出动画时长(毫秒)
HOLD_MS = 5000 # 停留显示时长(毫秒)
def __init__(self, parent: QWidget) -> None:
"""初始化浮动提示条,创建透明度动画和停留计时器。"""
super().__init__(parent)
self.setAlignment(Qt.AlignCenter)
self.setWordWrap(True)
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self.setStyleSheet(
"""
QLabel {
background: #f4fff6;
color: #183f22;
border: 3px solid #2e7d32;
border-radius: 8px;
padding: 20px 32px;
font-size: 18px;
font-weight: 700;
}
"""
)
self._opacity_effect = QGraphicsOpacityEffect(self)
self.setGraphicsEffect(self._opacity_effect)
# 淡入动画
self._fade_in = QPropertyAnimation(self._opacity_effect, b"opacity", self)
self._fade_in.setDuration(self.FADE_MS)
self._fade_in.setEasingCurve(QEasingCurve.InOutQuad)
# 淡出动画
self._fade_out = QPropertyAnimation(self._opacity_effect, b"opacity", self)
self._fade_out.setDuration(self.FADE_MS)
self._fade_out.setEasingCurve(QEasingCurve.InOutQuad)
self._fade_out.finished.connect(self.hide)
# 停留计时器
self._hold_timer = QTimer(self)
self._hold_timer.setSingleShot(True)
self._hold_timer.timeout.connect(self._start_fade_out)
# 监听父窗口大小变化,自动重新定位
parent.installEventFilter(self)
self.hide()
def show_message(self, message: str) -> None:
"""显示指定消息文本,启动淡入-停留-淡出动画序列。"""
self._hold_timer.stop()
self._fade_in.stop()
self._fade_out.stop()
self.setText(message)
self._fit_to_parent()
self._position()
self._opacity_effect.setOpacity(0.0)
self.show()
self.raise_()
self._fade_in.setStartValue(0.0)
self._fade_in.setEndValue(1.0)
self._fade_in.start()
self._hold_timer.start(self.FADE_MS + self.HOLD_MS)
def eventFilter(self, watched: object, event: QEvent) -> bool:
"""监听父窗口 Resize 事件,自动调整提示条尺寸和位置。"""
if watched is self.parentWidget() and event.type() == QEvent.Resize and self.isVisible():
self._fit_to_parent()
self._position()
return super().eventFilter(watched, event)
def _fit_to_parent(self) -> None:
"""根据父窗口宽度自适应提示条的最大宽度。"""
parent = self.parentWidget()
if parent is None:
return
self.setMaximumWidth(max(360, min(720, parent.width() - 48)))
self.adjustSize()
def _position(self) -> None:
"""将提示条定位到父窗口底部居中位置。"""
parent = self.parentWidget()
if parent is None:
return
margin = 24
x = max(margin, (parent.width() - self.width()) // 2)
y = max(margin, parent.height() - self.height() - margin)
self.move(x, y)
def _start_fade_out(self) -> None:
"""启动淡出动画,从当前透明度渐变至完全透明。"""
self._fade_out.stop()
self._fade_out.setStartValue(self._opacity_effect.opacity())
self._fade_out.setEndValue(0.0)
self._fade_out.start()
class DropTableWidget(QTableWidget):
"""支持拖放 .3mf 文件的表格控件。
允许用户将 .3mf 文件或文件夹从文件管理器拖放到表格中。
支持按 Delete 键删除选中行。
"""
def __init__(
self,
on_files_dropped: Callable[[list[Path]], None],
on_delete_pressed: Callable[[], None],
parent: QWidget | None = None,
) -> None:
"""初始化拖放表格。
参数:
on_files_dropped: 文件拖放完成时的回调,接收 Path 列表。
on_delete_pressed: 按下 Delete 键时的回调。
"""
super().__init__(parent)
self.on_files_dropped = on_files_dropped
self.on_delete_pressed = on_delete_pressed
self.setAcceptDrops(True)
self.viewport().setAcceptDrops(True)
self.viewport().installEventFilter(self)
self.setDragDropMode(QAbstractItemView.DropOnly)
def keyPressEvent(self, event: QKeyEvent) -> None:
"""处理键盘事件:Delete 键删除选中行。"""
if event.key() == Qt.Key_Delete and self.selectedIndexes():
self.on_delete_pressed()
event.accept()
return
super().keyPressEvent(event)
def eventFilter(self, watched: object, event: QEvent) -> bool:
"""过滤视口拖放事件,仅接受包含 .3mf 文件的拖放操作。"""
if watched is self.viewport():
if event.type() in {QEvent.DragEnter, QEvent.DragMove}:
drag_event = event # type: ignore[assignment]
if self._has_3mf_urls(drag_event):
drag_event.acceptProposedAction()
return True
if event.type() == QEvent.Drop:
drop_event = event # type: ignore[assignment]
paths = self._paths_from_urls(drop_event.mimeData().urls())
if paths:
self.on_files_dropped(paths)
drop_event.acceptProposedAction()
return True
return super().eventFilter(watched, event)
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
"""拖入事件:仅接受包含 .3mf 文件的拖放。"""
if self._has_3mf_urls(event):
event.acceptProposedAction()
return
super().dragEnterEvent(event)
def dragMoveEvent(self, event: QDragMoveEvent) -> None:
"""拖移事件:仅接受包含 .3mf 文件的拖放。"""
if self._has_3mf_urls(event):
event.acceptProposedAction()
return
super().dragMoveEvent(event)
def dropEvent(self, event: QDropEvent) -> None:
"""释放事件:提取 .3mf 文件路径并触发回调。"""
paths = self._paths_from_urls(event.mimeData().urls())
if paths:
self.on_files_dropped(paths)
event.acceptProposedAction()
return
super().dropEvent(event)
def _has_3mf_urls(self, event: Any) -> bool:
"""检查拖放事件是否包含至少一个 .3mf 文件 URL。"""
if not event.mimeData().hasUrls():
return False
return bool(self._paths_from_urls(event.mimeData().urls()))
def _paths_from_urls(self, urls: list[QUrl]) -> list[Path]:
"""从 URL 列表中提取 .3mf 文件路径,支持文件和文件夹。"""
result: list[Path] = []
for url in urls:
local_path = url.toLocalFile()
if not local_path:
continue
path = Path(local_path)
if path.is_dir():
result.extend(sorted(item for item in path.iterdir() if item.is_file() and item.suffix.lower() == ".3mf"))
elif path.is_file() and path.suffix.lower() == ".3mf":
result.append(path)
return result
class MainWindow(QMainWindow):
"""应用程序主窗口。
职责:
- 管理 3MF 输入文件列表(添加、移除、排序、份数调整)
- 提供打包选项配置界面
- 显示缩略图预览和输出预览
- 执行单个/批量构建并显示结果日志
- 持久化用户设置到本地 JSON 文件
"""
def __init__(self) -> None:
"""初始化主窗口:创建 UI、加载设置、连接信号。"""
super().__init__()
self.setWindowTitle(APP_TITLE)
self.resize(960, 680)
self.setAcceptDrops(True)
self._updating_table = False # 防重入标记:表格正在批量更新中
self._loading_settings = True # 防重入标记:正在从设置恢复 UI 状态
self._settings = self.load_settings()
self._shared_growth_enabled = False # 窗口高度足够时,文件区和日志区共享扩展空间
self.build_ui()
self.load_swap_gcode_to_combo()
self.restore_settings_to_ui()
self.connect_option_signals()
self._loading_settings = False
self.update_total_summary()
self.update_output_preview()
# =========================================================================
# 设置存取
# =========================================================================
def load_settings(self) -> dict[str, Any]:
"""从用户设置 JSON 文件加载设置,返回字典。"""
path = user_settings_path()
if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
return {}
def save_settings(self) -> None:
"""将当前设置字典写入用户设置 JSON 文件。"""
path = user_settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(self._settings, indent=2), encoding="utf-8")
# =========================================================================
# UI 构建
# =========================================================================
def build_ui(self) -> None:
"""构建完整的主窗口用户界面。
布局结构(自上而下):
1. 文件输入区(表格 + 缩略图预览 + 操作按钮)
2. 打包选项区(网格布局)
3. 输出区(目录、文件名规则、预览)
4. 日志区(只读文本框)
"""
central = QWidget(self)
root = QVBoxLayout(central)
root.setSpacing(6)
self.root_layout = root
# =====================================================================
# --- 文件输入区 ---
# =====================================================================
file_group = QGroupBox("输入 3MF 文件")
self.file_group = file_group
file_layout = QVBoxLayout(file_group)
file_body = QHBoxLayout()
# --- 文件表格 ---
table_layout = QVBoxLayout()
self.table = DropTableWidget(self.add_paths, self.remove_selected)
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["顺序", "3MF 文件", "份数", "时间", "耗材"])
self.table.horizontalHeader().setSectionResizeMode(ORDER_COLUMN, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(FILE_COLUMN, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(COPIES_COLUMN, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(TIME_COLUMN, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(FILAMENT_COLUMN, QHeaderView.ResizeToContents)
self.table.setColumnWidth(ORDER_COLUMN, 84)
self.table.verticalHeader().setDefaultSectionSize(30)
self.table.verticalHeader().hide()
self.table.setMinimumHeight(140)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setAlternatingRowColors(True)
self.table.setToolTip("将 .3mf 文件或文件夹拖放到此处。文件夹将添加所有顶层 .3mf 文件。")
self.table.itemChanged.connect(self.on_table_item_changed)
self.table.itemSelectionChanged.connect(self.update_thumbnail_preview)
table_layout.addWidget(self.table)
# --- 合计统计标签 ---
self.total_summary_label = QLabel("合计:0 板 | 时间:0s | 耗材:0.00 g")
table_layout.addWidget(self.total_summary_label)
file_body.addLayout(table_layout, 1)
# --- 缩略图预览 ---
preview_group = QGroupBox("选中文件的缩略图")
preview_layout = QVBoxLayout(preview_group)
self.thumbnail_label = QLabel("选择一个输入文件")
self.thumbnail_label.setAlignment(Qt.AlignCenter)
self.thumbnail_label.setFixedSize(260, 190)
self.thumbnail_label.setStyleSheet(
"""
QLabel {
background: #f8f8f8;
border: 1px solid #cfcfcf;
color: #666;
}
"""
)
self.thumbnail_name_label = QLabel("")
self.thumbnail_name_label.setWordWrap(True)
self.thumbnail_name_label.setMaximumWidth(280)
preview_layout.addWidget(self.thumbnail_label)
preview_layout.addWidget(self.thumbnail_name_label)
preview_layout.addStretch(1)
file_body.addWidget(preview_group)
file_layout.addLayout(file_body)
# --- 文件操作按钮栏 ---
file_buttons = QHBoxLayout()
add_button = QPushButton("添加 3MF")
remove_button = QPushButton("移除")
remove_all_button = QPushButton("全部移除")
apply_default_copies_button = QPushButton("应用默认份数至选中行")
self.build_button = QPushButton("构建 3MF")
self.build_button.setMinimumHeight(56)
self.build_button.setMinimumWidth(180)
build_font = self.build_button.font()
build_font.setPointSize(build_font.pointSize() + 2)
build_font.setBold(True)
self.build_button.setFont(build_font)
add_button.clicked.connect(self.add_files)
remove_button.clicked.connect(self.remove_selected)
remove_all_button.clicked.connect(self.remove_all)
apply_default_copies_button.clicked.connect(self.apply_default_copies_to_selected)
self.build_button.clicked.connect(self.build_output)
for button in (add_button, remove_button, remove_all_button, apply_default_copies_button):
button.setMinimumHeight(38)
file_buttons.addWidget(button)
file_buttons.addStretch(1)
file_buttons.addWidget(self.build_button)
file_layout.addLayout(file_buttons)
root.addWidget(file_group, 1)
# =====================================================================
# --- 打包选项区 ---
# =====================================================================
options_group = QGroupBox("打包选项")
grid = QGridLayout(options_group)
grid.setColumnStretch(1, 1)
grid.setColumnStretch(3, 1)
grid.setHorizontalSpacing(20)
grid.setVerticalSpacing(6)
# ---- 控件创建 ----
# 换盘 G-code 选择
self.swap_gcode_combo = QComboBox()
self.swap_gcode_combo.setMinimumWidth(260)
self.swap_gcode_combo.setMaximumWidth(440)
refresh_button = QPushButton("刷新")
open_folder_button = QPushButton("打开文件夹")
refresh_button.setFixedWidth(refresh_button.sizeHint().width())
open_folder_button.setFixedWidth(open_folder_button.sizeHint().width())
refresh_button.clicked.connect(self.load_swap_gcode_to_combo)
open_folder_button.clicked.connect(self.open_swap_gcode_folder)
# 默认份数
self.default_copies_spin = QSpinBox()
self.default_copies_spin.setRange(1, 9999)
self.default_copies_spin.setValue(1)
self.default_copies_spin.setFixedWidth(96)
# 热床降温
self.bed_cooldown_check = QCheckBox("等待热床降温")
self.bed_cooldown_check.setChecked(True)
self.cool_bed_spin = QSpinBox()
self.cool_bed_spin.setRange(0, 120)
self.cool_bed_spin.setValue(45)
bed_row = QHBoxLayout()
bed_row.addWidget(self.bed_cooldown_check)
bed_row.addWidget(self.cool_bed_spin)
bed_row.addWidget(QLabel("°C"))
bed_row.addStretch(1)
# 换盘后等待时间(勾选后启用,不勾选则不插入等待指令)
self.eject_wait_check = QCheckBox("换盘后等待")
self.eject_wait_check.setChecked(True)
self.wait_spin = QSpinBox()
self.wait_spin.setRange(0, 3600)
self.wait_spin.setValue(45)
self.wait_spin.setSuffix("")
self.wait_spin.setFixedWidth(96)
eject_wait_row = QHBoxLayout()
eject_wait_row.addWidget(self.eject_wait_check)
eject_wait_row.addWidget(self.wait_spin)
eject_wait_row.addStretch(1)
# 剩余时间板号显示
self.show_plate_number_check = QCheckBox("在剩余时间的百位显示当前板号")
self.show_plate_number_check.setChecked(True)
# 最后换盘
self.swap_final_check = QCheckBox("最后一块盘后也执行换盘 G-code")
self.swap_final_check.setChecked(True)
# G-code 补丁
self.patch_check = QCheckBox("应用可编辑的 G-code 补丁")
self.patch_check.setToolTip("使用 gcode_patches.ini")
self.patch_check.setChecked(True)
patch_row = QHBoxLayout()
open_patch_button = QPushButton("打开配置文件")
open_patch_button.clicked.connect(self.open_patch_config)
patch_row.addWidget(self.patch_check)
patch_row.addWidget(open_patch_button)
patch_row.addStretch(1)
# 3MF 元数据模式
self.metadata_combo = QComboBox()
self.metadata_combo.addItem("保留原始预测和重量", "source")
self.metadata_combo.addItem("累加预测和耗材用量", "sum")
self.metadata_combo.setFixedWidth(260)
# ZIP 压缩级别
self.zip_level_combo = QComboBox()
for level in range(1, 10):
self.zip_level_combo.addItem(f"级别 {level}", level)
self.zip_level_combo.setCurrentIndex(DEFAULT_ZIP_COMPRESS_LEVEL - 1)
self.zip_level_combo.setFixedWidth(120)
self.zip_level_combo.setToolTip("输出 3MF 的 zlib-ng Deflate 压缩级别。")
# 独立批处理模式
self.individual_batch_check = QCheckBox("独立批处理模式")
self.individual_batch_check.setToolTip(
"将每行输入构建为独立的输出文件,使用该行的份数。"
)
# 构建后清空 / 跳过重复
self.clear_after_build_check = QCheckBox("构建成功后清空输入列表")
self.clear_after_build_check.setChecked(False)
self.skip_duplicates_check = QCheckBox("添加输入时跳过重复文件路径")
self.skip_duplicates_check.setChecked(True)
# 标签列固定宽度(左对齐),控件列自动对齐(stretch=1 平分空间)
grid.setColumnMinimumWidth(0, 140)
grid.setColumnMinimumWidth(2, 100)
for label_text, row, col in [
("换盘 G-code", 0, 0),
("新输入的默认份数", 1, 0),
("换盘后等待时间", 2, 0),
("最后换盘", 3, 0),
("3MF 元数据", 4, 0),
("批处理模式", 5, 0),
("热床降温", 1, 2),
("剩余时间板号", 2, 2),
("G-code 补丁", 3, 2),
("ZIP 压缩", 4, 2),
("输入处理", 5, 2),
("构建成功后", 6, 0),
]:
label = QLabel(label_text)
label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
grid.addWidget(label, row, col)
# 第 0 行:换盘 G-code(下拉框跨 col1-2,按钮放 col3 对齐右侧控件)
grid.addWidget(self.swap_gcode_combo, 0, 1, 1, 2)
swap_btn_row = QHBoxLayout()
swap_btn_row.addWidget(refresh_button)
swap_btn_row.addWidget(open_folder_button)
swap_btn_row.addStretch(1)
grid.addLayout(swap_btn_row, 0, 3)
# 第 1 行:默认份数 | 热床降温
grid.addWidget(self.default_copies_spin, 1, 1)
grid.addLayout(bed_row, 1, 3)
# 第 2 行:换盘后等待 | 剩余时间板号
grid.addLayout(eject_wait_row, 2, 1)
grid.addWidget(self.show_plate_number_check, 2, 3)
# 第 3 行:最后换盘 | G-code 补丁
grid.addWidget(self.swap_final_check, 3, 1)
grid.addLayout(patch_row, 3, 3)
# 第 4 行:3MF 元数据 | ZIP 压缩
grid.addWidget(self.metadata_combo, 4, 1)
grid.addWidget(self.zip_level_combo, 4, 3)
# 第 5 行:批处理模式 | 输入处理
grid.addWidget(self.individual_batch_check, 5, 1)
grid.addWidget(self.skip_duplicates_check, 5, 3)
# 第 6 行:构建成功后清空输入列表(独立一行)
grid.addWidget(self.clear_after_build_check, 6, 1, 1, 3)
root.addWidget(options_group)
# =====================================================================
# --- 输出区 ---
# =====================================================================
output_group = QGroupBox("输出")
output_layout = QFormLayout(output_group)
# 输出目录
output_dir_row = QHBoxLayout()
self.output_dir_edit = QLineEdit()
self.output_dir_edit.setPlaceholderText("留空则写入输入文件所在目录")
self.output_dir_edit.setMinimumWidth(280)
self.output_dir_edit.setMaximumWidth(560)
browse_output_dir_button = QPushButton("浏览")
browse_output_dir_button.setFixedWidth(browse_output_dir_button.sizeHint().width())
browse_output_dir_button.clicked.connect(self.choose_output_dir)
output_dir_row.addWidget(self.output_dir_edit, 1)
output_dir_row.addWidget(browse_output_dir_button)
output_layout.addRow("输出目录", output_dir_row)
# 输出文件名规则
self.output_name_edit = QLineEdit(DEFAULT_OUTPUT_PATTERN)
self.output_name_edit.setPlaceholderText("可使用 {source}{sources}{plates}{copies}{date}{time} 等标记")
self.output_name_edit.setMinimumWidth(280)
self.output_name_edit.setMaximumWidth(560)
filename_rule_row = QHBoxLayout()
output_rule_help_button = QPushButton("?")
output_rule_help_button.setFixedWidth(34)
output_rule_help_button.setToolTip("显示文件名标记帮助")
output_rule_help_button.clicked.connect(self.show_output_rule_help)
filename_rule_row.addWidget(self.output_name_edit, 1)
filename_rule_row.addWidget(output_rule_help_button)
output_layout.addRow("输出文件名规则", filename_rule_row)
# 输出预览
self.output_preview_label = QLabel("-")
output_layout.addRow("预览", self.output_preview_label)
root.addWidget(output_group)
# =====================================================================
# --- 日志区 ---
# =====================================================================
self.log = QTextEdit()
self.log.setReadOnly(True)
self.log.setMinimumHeight(100)
root.addWidget(self.log, 0)
self.setCentralWidget(central)
self.success_toast = SuccessToast(central)
self.update_vertical_growth_policy()
# =========================================================================
# 窗口事件
# =========================================================================
def update_vertical_growth_policy(self) -> None:
"""根据窗口高度动态调整文件区和日志区的垂直扩展比例。
窗口高度 >= 980px 时,文件区和日志区按 4:1 共享扩展空间;
否则文件区独占扩展空间。
"""
if not hasattr(self, "root_layout") or not hasattr(self, "file_group") or not hasattr(self, "log"):
return
shared_growth = self.height() >= 980
if shared_growth == self._shared_growth_enabled:
return
self._shared_growth_enabled = shared_growth
self.root_layout.setStretchFactor(self.file_group, 4 if shared_growth else 1)
self.root_layout.setStretchFactor(self.log, 1 if shared_growth else 0)
def resizeEvent(self, event: QEvent) -> None:
"""窗口大小变化时,更新垂直扩展策略并刷新缩略图。"""
super().resizeEvent(event)
self.update_vertical_growth_policy()
self.update_thumbnail_preview()
# =========================================================================
# 设置同步
# =========================================================================
def connect_option_signals(self) -> None:
"""将所有选项控件的值变更信号连接到设置保存函数。"""
self.swap_gcode_combo.currentIndexChanged.connect(self.save_current_settings)
self.default_copies_spin.valueChanged.connect(self.save_current_settings)
self.bed_cooldown_check.stateChanged.connect(self.save_current_settings)
self.cool_bed_spin.valueChanged.connect(self.save_current_settings)
self.eject_wait_check.stateChanged.connect(self.save_current_settings)
self.eject_wait_check.toggled.connect(self.wait_spin.setEnabled)
self.wait_spin.valueChanged.connect(self.save_current_settings)
self.show_plate_number_check.stateChanged.connect(self.save_current_settings)
self.swap_final_check.stateChanged.connect(self.save_current_settings)
self.patch_check.stateChanged.connect(self.save_current_settings)
self.metadata_combo.currentIndexChanged.connect(self.save_current_settings)
self.zip_level_combo.currentIndexChanged.connect(self.save_current_settings)
self.individual_batch_check.stateChanged.connect(self.on_individual_batch_toggled)
self.clear_after_build_check.stateChanged.connect(self.save_current_settings)
self.skip_duplicates_check.stateChanged.connect(self.save_current_settings)
self.output_dir_edit.textChanged.connect(self.on_output_rule_changed)
self.output_name_edit.textChanged.connect(self.on_output_rule_changed)
def restore_settings_to_ui(self) -> None:
"""从加载的设置字典恢复所有 UI 控件的状态。"""
options = self._settings.get("packing_options", {})
if not isinstance(options, dict):
return
self.default_copies_spin.setValue(int(options.get("default_copies", 1)))
self.bed_cooldown_check.setChecked(bool(options.get("bed_cooldown_enabled", True)))
self.cool_bed_spin.setValue(int(options.get("cool_bed_temp", 45)))
self.eject_wait_check.setChecked(bool(options.get("eject_wait_enabled", True)))
self.wait_spin.setValue(int(options.get("wait_after_eject_seconds", 45)))
self.show_plate_number_check.setChecked(bool(options.get("show_plate_number", True)))
self.swap_final_check.setChecked(bool(options.get("swap_after_final", True)))
self.patch_check.setChecked(bool(options.get("apply_gcode_patches", True)))
self.individual_batch_check.setChecked(bool(options.get("individual_batch_mode", False)))
self.clear_after_build_check.setChecked(bool(options.get("clear_after_build", False)))
self.skip_duplicates_check.setChecked(bool(options.get("skip_duplicates", True)))
self.output_dir_edit.setText(str(options.get("output_directory", "")))
self.output_name_edit.setText(str(options.get("output_filename_rule", DEFAULT_OUTPUT_PATTERN)))
metadata_mode = options.get("metadata_mode", "sum")
metadata_index = self.metadata_combo.findData(metadata_mode)
if metadata_index >= 0:
self.metadata_combo.setCurrentIndex(metadata_index)
try:
zip_level = int(options.get("zip_compress_level", DEFAULT_ZIP_COMPRESS_LEVEL))
except (TypeError, ValueError):
zip_level = DEFAULT_ZIP_COMPRESS_LEVEL
zip_level_index = self.zip_level_combo.findData(min(9, max(1, zip_level)))
if zip_level_index >= 0:
self.zip_level_combo.setCurrentIndex(zip_level_index)
swap_gcode = options.get("swap_gcode") or self._settings.get("last_swap_gcode")
if swap_gcode:
index = self.swap_gcode_combo.findData(str(swap_gcode))
if index < 0:
index = self.swap_gcode_combo.findText(Path(str(swap_gcode)).name)
if index >= 0:
self.swap_gcode_combo.setCurrentIndex(index)
def collect_current_settings(self) -> dict[str, Any]:
"""从 UI 控件收集当前所有打包选项,返回设置字典。"""
return {
"swap_gcode": self.swap_gcode_combo.currentData() or "",
"default_copies": self.default_copies_spin.value(),
"bed_cooldown_enabled": self.bed_cooldown_check.isChecked(),
"cool_bed_temp": self.cool_bed_spin.value(),
"eject_wait_enabled": self.eject_wait_check.isChecked(),
"wait_after_eject_seconds": self.wait_spin.value(),
"show_plate_number": self.show_plate_number_check.isChecked(),
"swap_after_final": self.swap_final_check.isChecked(),
"apply_gcode_patches": self.patch_check.isChecked(),
"metadata_mode": self.metadata_combo.currentData(),
"zip_compress_level": int(self.zip_level_combo.currentData() or DEFAULT_ZIP_COMPRESS_LEVEL),
"individual_batch_mode": self.individual_batch_check.isChecked(),
"clear_after_build": self.clear_after_build_check.isChecked(),
"skip_duplicates": self.skip_duplicates_check.isChecked(),
"output_directory": self.output_dir_edit.text().strip(),
"output_filename_rule": self.output_name_edit.text().strip() or DEFAULT_OUTPUT_PATTERN,
}
def save_current_settings(self) -> None:
"""将当前 UI 设置保存到本地 JSON 文件(加载阶段跳过)。"""
if self._loading_settings:
return
self._settings["packing_options"] = self.collect_current_settings()
self._settings["last_swap_gcode"] = self.swap_gcode_combo.currentData() or ""
self.save_settings()
def on_output_rule_changed(self) -> None:
"""输出规则变更时:更新输出预览并保存设置。"""
self.update_output_preview()
self.save_current_settings()
def on_individual_batch_toggled(self, state: int) -> None:
"""独立批处理模式切换时:显示说明对话框,更新预览并保存。"""
self.update_output_preview()
self.save_current_settings()
if self._loading_settings:
return
if state != 0:
QMessageBox.information(
self,
"独立批处理模式",
"独立批处理模式将每一行输入作为独立的构建任务。\n\n"
"示例:如果你添加了 20 个单盘 3MF 文件并设置份数为 5,"
"点击“构建 3MF”将创建 20 个独立的打包文件。"
"每个输出文件仅包含该源文件重复 5 次的内容。\n\n"
"这个功能适用于快速将大量独立的 3MF 作业批量转换为多份数的 "
"SwapMod 包。它不会将所有输入文件合并到一个 3MF 中。",
)
def show_output_rule_help(self) -> None:
"""显示输出文件名规则的帮助对话框。"""
QMessageBox.information(
self,
"输出文件名规则",
"可用的文件名标记:\n\n"
"{source} — 第一个输入文件的文件名(不含 .3mf)\n"
"{sources} — 源文件名称摘要。多个不同输入时,显示为 first_source_and_N_more\n"
"{plates} — 输出中的总板数\n"
"{copies} — 输出中使用的总份数\n"
"{date} — 当前日期,格式 YYYYMMDD\n"
"{time} — 当前时间,格式 HHMMSS\n\n"
"默认规则:\n"
"{plates}-Plates-{sources}.3mf\n\n"
"在独立批处理模式下,这些标记会针对每行输入单独计算。",
)
# =========================================================================
# 换盘 G-code 管理
# =========================================================================
def load_swap_gcode_to_combo(self) -> None:
"""扫描换盘 G-code 目录,填充下拉列表,恢复上次选择。"""
current = self.swap_gcode_combo.currentData() or self._settings.get("last_swap_gcode")
options = self._settings.get("packing_options", {})
if isinstance(options, dict):
current = current or options.get("swap_gcode")
self.swap_gcode_combo.clear()
files = list_swap_gcode_files(default_swap_gcode_dir())
for path in files:
self.swap_gcode_combo.addItem(path.name, str(path))
if current:
index = self.swap_gcode_combo.findData(str(current))
if index < 0:
index = self.swap_gcode_combo.findText(Path(str(current)).name)
if index >= 0:
self.swap_gcode_combo.setCurrentIndex(index)
if not files:
self.log.append(f"{default_swap_gcode_dir()} 中未找到换盘 G-code 文件")
def open_path(self, path: Path) -> None:
"""跨平台打开文件或文件夹(Windows/Mac/Linux)。"""
target = path if path.exists() else path.parent
target.parent.mkdir(parents=True, exist_ok=True)
if sys.platform.startswith("win"):
os.startfile(str(target)) # type: ignore[attr-defined]
elif sys.platform == "darwin":
subprocess.run(["open", str(target)], check=False)
else:
subprocess.run(["xdg-open", str(target)], check=False)
def open_swap_gcode_folder(self) -> None:
"""在系统文件管理器中打开换盘 G-code 目录。"""
folder = default_swap_gcode_dir()
folder.mkdir(parents=True, exist_ok=True)
self.open_path(folder)
def open_patch_config(self) -> None:
"""打开 G-code 补丁配置文件。"""
path = default_patch_config_path()
if not path.exists():
QMessageBox.information(self, APP_NAME, f"补丁配置文件不存在:\n{path}")
return
self.open_path(path)
# =========================================================================
# 文件输入管理(表格行操作)
# =========================================================================
def add_files(self) -> None:
"""通过文件对话框添加 3MF 文件到输入列表。"""
files, _ = QFileDialog.getOpenFileNames(self, "添加 3MF 文件", "", "3MF 文件 (*.3mf);;所有文件 (*)")
self.add_paths([Path(file_name) for file_name in files])
def create_order_widget(self, row: int) -> QWidget:
"""创建行排序控件(上移/下移按钮 + 行号标签)。"""
widget = QWidget()
layout = QHBoxLayout(widget)
layout.setContentsMargins(2, 0, 2, 0)
layout.setSpacing(2)
number_label = QLabel(str(row + 1))
number_label.setAlignment(Qt.AlignCenter)
number_label.setMinimumWidth(18)
up_button = QToolButton()
up_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp))
up_button.setToolTip("上移")
up_button.setAutoRaise(True)
up_button.setFixedSize(22, 22)
up_button.setEnabled(row > 0)
up_button.clicked.connect(lambda _checked=False, current_row=row: self.move_row(current_row, -1))
down_button = QToolButton()
down_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
down_button.setToolTip("下移")
down_button.setAutoRaise(True)
down_button.setFixedSize(28, 28)
down_button.setEnabled(row < self.table.rowCount() - 1)
down_button.clicked.connect(lambda _checked=False, current_row=row: self.move_row(current_row, 1))
layout.addWidget(number_label)
layout.addWidget(up_button)
layout.addWidget(down_button)
return widget
def update_order_controls(self) -> None:
"""刷新所有行的排序控件和份数控件(行号、上下按钮状态)。"""
for row in range(self.table.rowCount()):
self.table.setCellWidget(row, ORDER_COLUMN, self.create_order_widget(row))
self.table.setCellWidget(row, COPIES_COLUMN, self.create_copies_spin(row, self.get_row_copies(row)))
def create_copies_spin(self, row: int, copies: int) -> QSpinBox:
"""创建份数微调框控件,绑定值变更信号。"""
spin = QSpinBox()
spin.setRange(1, 9999)
spin.setFixedWidth(64)
spin.setAlignment(Qt.AlignCenter)
spin.setValue(max(1, int(copies)))
spin.valueChanged.connect(lambda value, current_row=row: self.on_row_copies_changed(current_row, value))
return spin
def row_path(self, row: int) -> Path | None:
"""获取指定行的 3MF 文件路径。"""
item = self.table.item(row, FILE_COLUMN)
if item is None:
return None
path_value = item.data(PATH_ROLE)
if path_value:
return Path(str(path_value))
return Path(item.text())
def load_thumbnail_pixmap(self, path: Path) -> QPixmap | None:
"""从 3MF 文件中加载预览缩略图。"""
with zipfile.ZipFile(path, "r") as archive:
gcode_members = list_gcode_members(archive)
active_gcode_member = resolve_output_gcode_member(archive, gcode_members[0]) if gcode_members else None
member_name = first_preview_image_member(archive.namelist(), active_gcode_member)
if member_name is None:
return None
data = archive.read(member_name)
pixmap = QPixmap()
if not pixmap.loadFromData(data):
return None
return pixmap
def update_thumbnail_preview(self) -> None:
"""更新缩略图预览:从当前选中行的 3MF 文件中提取预览图并显示。"""
if not hasattr(self, "thumbnail_label"):
return
row = self.selected_row()
path = self.row_path(row) if row is not None else None
self.thumbnail_label.clear()
if path is None:
self.thumbnail_label.setText("选择一个输入文件")
self.thumbnail_name_label.setText("")
return
self.thumbnail_name_label.setText(path.name)
try:
pixmap = self.load_thumbnail_pixmap(path)
except Exception as exc:
self.thumbnail_label.setText("预览不可用")
self.thumbnail_label.setToolTip(str(exc))
return
if pixmap is None:
self.thumbnail_label.setText("无预览图")
self.thumbnail_label.setToolTip("")
return
scaled = pixmap.scaled(self.thumbnail_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.thumbnail_label.setPixmap(scaled)
self.thumbnail_label.setToolTip(str(path))
def add_paths(self, paths: list[Path]) -> None:
"""批量添加 3MF 文件路径到输入表格。
支持文件和文件夹(递归添加顶层 .3mf 文件)。
根据"跳过重复"设置过滤已存在的文件。
"""
expanded: list[Path] = []
for path in paths:
if path.is_dir():
expanded.extend(sorted(item for item in path.iterdir() if item.is_file() and item.suffix.lower() == ".3mf"))
elif path.is_file() and path.suffix.lower() == ".3mf":
expanded.append(path)
if not expanded:
return
existing: set[str] = set()
for row in range(self.table.rowCount()):
current_path = self.row_path(row)
if current_path is not None:
existing.add(str(current_path))
added = 0
skipped = 0
for path in expanded:
normalized = str(path.resolve())
if self.skip_duplicates_check.isChecked() and normalized in existing:
skipped += 1
continue
if self.add_file_row(normalized, self.default_copies_spin.value()):
existing.add(normalized)
added += 1
else:
skipped += 1
if added or skipped:
self.log.append(f"已添加 {added} 个输入文件。跳过 {skipped} 个。")
self.update_total_summary()
self.update_output_preview()
def add_file_row(self, file_name: str, copies: int) -> bool:
"""向表格添加单行:读取 3MF 摘要,创建表格行并填充数据。"""
path = Path(file_name)
try:
summary = read_3mf_summary(path)
except Exception as exc:
self.log.append(f"已跳过 {path}{exc}")
return False
row = self.table.rowCount()
self._updating_table = True
try:
self.table.insertRow(row)
path_item = QTableWidgetItem(path.name)
path_item.setFlags(path_item.flags() & ~Qt.ItemIsEditable)
path_item.setData(PATH_ROLE, str(path))
path_item.setData(SUMMARY_ROLE, self.summary_to_dict(summary))
path_item.setToolTip(f"{path}\n\n{self.base_summary_tooltip(summary)}")
path_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter)
time_item = QTableWidgetItem("")
filament_item = QTableWidgetItem("")
for item in (time_item, filament_item):
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.table.setCellWidget(row, ORDER_COLUMN, self.create_order_widget(row))
self.table.setItem(row, FILE_COLUMN, path_item)
self.table.setCellWidget(row, COPIES_COLUMN, self.create_copies_spin(row, copies))
self.table.setItem(row, TIME_COLUMN, time_item)
self.table.setItem(row, FILAMENT_COLUMN, filament_item)
self.table.setRowHeight(row, 30)
self.update_row_stats(row)
finally:
self._updating_table = False
self.update_order_controls()
if self.table.rowCount() == 1:
self.table.selectRow(0)
return True
def summary_to_dict(self, summary: ThreeMfSummary) -> dict[str, Any]:
"""将 ThreeMfSummary 对象转换为可序列化的字典(用于 QTableWidgetItem 数据角色)。"""
return {
"source_3mf": str(summary.source_3mf),
"plate_count": summary.plate_count,
"prediction_seconds": summary.prediction_seconds,
"weight_grams": summary.weight_grams,
"filament_used_m": summary.filament_used_m,
"filament_used_g": summary.filament_used_g,
}
def base_summary_tooltip(self, summary: ThreeMfSummary) -> str:
"""生成 3MF 文件的基本摘要信息(用于工具提示)。"""
return (
f"基础板数:{summary.plate_count}\n"
f"基础时间:{format_duration(summary.prediction_seconds)}\n"
f"基础耗材:{format_filament(summary.weight_grams, summary.filament_used_m)}"
)
def get_row_copies(self, row: int) -> int:
"""获取指定行的份数值。"""
widget = self.table.cellWidget(row, COPIES_COLUMN)
if isinstance(widget, QSpinBox):
return max(1, int(widget.value()))
item = self.table.item(row, COPIES_COLUMN)
if item is None:
return 1
try:
return max(1, int(item.text().strip()))
except Exception:
return 1
def set_row_copies(self, row: int, copies: int) -> None:
"""设置指定行的份数值(通过微调框或文本)。"""
value = max(1, int(copies))
widget = self.table.cellWidget(row, COPIES_COLUMN)
if isinstance(widget, QSpinBox):
widget.setValue(value)
return
item = self.table.item(row, COPIES_COLUMN)
if item is not None:
item.setText(str(value))
def on_row_copies_changed(self, row: int, copies: int) -> None:
"""份数变更时:刷新该行统计、总合计和输出预览。"""
if self._updating_table:
return
self._updating_table = True
try:
self.update_row_stats(row)
finally:
self._updating_table = False
self.update_total_summary()
self.update_output_preview()
def update_row_stats(self, row: int) -> None:
"""根据当前份数重新计算并更新该行的时间列和耗材列。"""
path_item = self.table.item(row, FILE_COLUMN)
if path_item is None:
return
data = path_item.data(SUMMARY_ROLE)
if isinstance(data, ThreeMfSummary):
summary = data
elif isinstance(data, dict):
summary = three_mf_summary_from_mapping(data)
else:
return
copies = self.get_row_copies(row)
total_prediction = None if summary.prediction_seconds is None else summary.prediction_seconds * copies
total_weight = None if summary.weight_grams is None else summary.weight_grams * copies
total_used_m = None if summary.filament_used_m is None else summary.filament_used_m * copies
self.table.item(row, TIME_COLUMN).setText(format_duration(total_prediction))
self.table.item(row, FILAMENT_COLUMN).setText(format_filament(total_weight, total_used_m))
def on_table_item_changed(self, item: QTableWidgetItem) -> None:
"""表格单元格内容变更事件:份数列手动编辑时同步更新统计。"""
if self._updating_table:
return
if item.column() == COPIES_COLUMN:
self._updating_table = True
try:
copies = self.get_row_copies(item.row())
item.setText(str(copies))
self.update_row_stats(item.row())
finally:
self._updating_table = False
self.update_total_summary()
self.update_output_preview()
def selected_rows(self) -> list[int]:
"""返回当前选中行的索引列表(去重、升序)。"""
return sorted({index.row() for index in self.table.selectedIndexes()})
def selected_row(self) -> int | None:
"""返回当前选中的第一行索引,无选中时返回 None。"""
rows = self.selected_rows()
return rows[0] if rows else None
def remove_selected(self) -> None:
"""删除选中的表格行,并智能选择下一行。"""
rows = sorted(self.selected_rows(), reverse=True)
next_row = min(rows) if rows else None
for row in rows:
self.table.removeRow(row)
self.update_order_controls()
if next_row is not None and self.table.rowCount() > 0:
self.table.selectRow(min(next_row, self.table.rowCount() - 1))
self.update_total_summary()
self.update_output_preview()
self.update_thumbnail_preview()
def remove_all(self) -> None:
"""清空所有输入行。"""
self.table.setRowCount(0)
self.update_order_controls()
self.update_total_summary()
self.update_output_preview()
self.update_thumbnail_preview()
def move_selected(self, delta: int) -> None:
"""移动选中行(delta 为正则下移,为负则上移)。"""
row = self.selected_row()
if row is None:
return
self.move_row(row, delta)
def move_row(self, row: int, delta: int) -> None:
"""将指定行移动 delta 个位置,保持份数和其他数据不变。"""
new_row = row + delta
if new_row < 0 or new_row >= self.table.rowCount():
return
copies = self.get_row_copies(row)
row_items: list[QTableWidgetItem | None] = []
for col in range(self.table.columnCount()):
item = self.table.item(row, col)
row_items.append(item.clone() if item is not None else None)
self._updating_table = True
try:
self.table.removeRow(row)
self.table.insertRow(new_row)
for col, item in enumerate(row_items):
if item is not None:
self.table.setItem(new_row, col, item)
self.table.setCellWidget(new_row, COPIES_COLUMN, self.create_copies_spin(new_row, copies))
self.table.setRowHeight(new_row, 38)
finally:
self._updating_table = False
self.update_order_controls()
self.table.selectRow(new_row)
self.update_total_summary()
self.update_output_preview()
self.update_thumbnail_preview()
def apply_default_copies_to_selected(self) -> None:
"""将默认份数应用到所有选中行。"""
rows = self.selected_rows()
if not rows:
return
self._updating_table = True
try:
for row in rows:
self.set_row_copies(row, self.default_copies_spin.value())
self.update_row_stats(row)
finally:
self._updating_table = False
self.update_total_summary()
self.update_output_preview()
# =========================================================================
# 输出配置
# =========================================================================
def choose_output_dir(self) -> None:
"""通过文件夹选择对话框设置输出目录。"""
start_dir = self.output_dir_edit.text().strip()
first_path = self.row_path(0) if self.table.rowCount() > 0 else None
if not start_dir and first_path is not None:
start_dir = str(first_path.parent)
directory = QFileDialog.getExistingDirectory(self, "选择输出目录", start_dir or "")
if directory:
self.output_dir_edit.setText(directory)
def collect_jobs(self) -> list[PlateJob]:
"""从表格中收集所有 PlateJob(文件路径 + 份数)。"""
jobs: list[PlateJob] = []
for row in range(self.table.rowCount()):
path = self.row_path(row)
if path is None:
continue
jobs.append(PlateJob(path, self.get_row_copies(row)))
return jobs
def summary_for_path(self, path: Path) -> ThreeMfSummary:
"""根据文件路径获取 ThreeMfSummary(优先从缓存表格数据中读取)。"""
normalized = str(path)
for row in range(self.table.rowCount()):
row_path = self.row_path(row)
item = self.table.item(row, FILE_COLUMN)
if row_path is not None and str(row_path) == normalized and item is not None:
data = item.data(SUMMARY_ROLE)
if isinstance(data, ThreeMfSummary):
return data
if isinstance(data, dict):
return three_mf_summary_from_mapping(data)
return read_3mf_summary(path)
def output_naming_options(self) -> OutputNamingOptions:
"""从 UI 控件收集输出命名选项。"""
return OutputNamingOptions(
output_directory=self.output_dir_edit.text().strip(),
filename_rule=self.output_name_edit.text().strip() or DEFAULT_OUTPUT_PATTERN,
)
def current_total_summary(self) -> OutputSummary:
"""计算当前所有作业的合计摘要(板数、时间、耗材)。"""
return summarize_jobs_for_output(self.collect_jobs(), self.summary_for_path)
def update_total_summary(self) -> None:
"""刷新底部合计统计标签。"""
summary = self.current_total_summary()
self.total_summary_label.setText(
"合计:"
f"{summary.plate_count} 板 | "
f"时间:{format_duration(summary.prediction_seconds)} | "
f"耗材:{format_filament(summary.weight_grams, summary.filament_used_m)}"
)
def update_output_preview(self) -> None:
"""刷新输出文件路径预览标签。
合并模式下显示单个输出路径;
独立批处理模式下显示"第一个输出路径 | N 个输出文件"
"""
jobs = self.collect_jobs()
if not jobs:
self.output_preview_label.setText("-")
return
try:
if self.individual_batch_check.isChecked():
first_path = resolve_output_path([jobs[0]], self.output_naming_options(), self.summary_for_path)
self.output_preview_label.setText(
f"{first_path} | {len(jobs)} 个输出文件"
)
else:
output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path)
self.output_preview_label.setText(str(output_path))
except Exception as exc:
self.output_preview_label.setText(str(exc))
# =========================================================================
# 构建流程
# =========================================================================
def build_options_for_output(self, output_path: Path) -> BuildOptions:
"""根据当前 UI 选项构建 BuildOptions 对象。"""
swap_gcode_path = self.swap_gcode_combo.currentData()
if not swap_gcode_path:
raise ValueError("请在 swap_gcode 文件夹中放入至少一个换盘 G-code 文件并选择它。")
cool_bed_temp = self.cool_bed_spin.value() if self.bed_cooldown_check.isChecked() else None
eject_wait_seconds = self.wait_spin.value() if self.eject_wait_check.isChecked() else 0
return BuildOptions(
swap_gcode=Path(swap_gcode_path),
output_3mf=output_path,
cool_bed_temp=cool_bed_temp,
wait_after_eject_seconds=eject_wait_seconds,
show_plate_number=self.show_plate_number_check.isChecked(),
swap_after_final=self.swap_final_check.isChecked(),
metadata_mode=self.metadata_combo.currentData(),
apply_gcode_patches=self.patch_check.isChecked(),
zip_compress_level=int(self.zip_level_combo.currentData() or DEFAULT_ZIP_COMPRESS_LEVEL),
)
def log_build_result(self, result: Any) -> None:
"""将单次构建结果信息写入日志。"""
self.log.append(f"输出:{result.output_3mf}")
self.log.append(f"板数:{result.plate_count}")
self.log.append(f"预估源文件时间:{format_duration(result.total_prediction_seconds)}")
self.log.append(f"预估源文件耗材:{format_filament(result.total_weight_grams)}")
self.log.append(f"G-code MD5{result.gcode_md5}")
def show_success_toast(self, message: str) -> None:
"""显示构建成功的浮动提示条。"""
self.success_toast.show_message(message)
def build_output(self) -> None:
"""主构建入口:根据批处理模式分发到合并构建或独立构建。"""
jobs = self.collect_jobs()
if not jobs:
QMessageBox.warning(self, APP_NAME, "请至少添加一个 3MF 文件。")
return
try:
self.save_current_settings()
if self.individual_batch_check.isChecked():
self.build_individual_outputs(jobs)
else:
self.build_combined_output(jobs)
except Exception as exc:
QMessageBox.critical(self, APP_NAME, str(exc))
self.log.append(f"错误:{exc}")
return
def build_combined_output(self, jobs: list[PlateJob]) -> None:
"""合并构建模式:将所有输入文件打包到单个输出 3MF 中。"""
output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path)
options = self.build_options_for_output(output_path)
result = build_packed_3mf(jobs, options)
self.log_build_result(result)
self.show_success_toast("已创建打包的 3MF 文件。")
if self.clear_after_build_check.isChecked():
self.remove_all()
def build_individual_outputs(self, jobs: list[PlateJob]) -> None:
"""独立批处理模式:每行输入构建一个独立的输出 3MF,支持并行处理。"""
used_paths: set[Path] = set()
success_count = 0
errors: list[str] = []
tasks: list[IndividualBuildTask] = []
for job in jobs:
try:
output_path = make_unique_for_run(
resolve_output_path([job], self.output_naming_options(), self.summary_for_path),
used_paths,
)
options = self.build_options_for_output(output_path)
except Exception as exc:
errors.append(f"{job.source_3mf.name}: {exc}")
self.log.append(f"构建 {job.source_3mf} 时出错:{exc}")
continue
tasks.append(IndividualBuildTask(job, options))
if tasks:
worker_count = individual_batch_worker_count(len(tasks))
self.log.append(f"正在使用 {worker_count} 个工作线程构建 {len(tasks)} 个独立输出。")
batch_result = run_individual_batch_builds(tasks, max_workers=worker_count)
for result in batch_result.results:
self.log_build_result(result)
success_count = len(batch_result.results)
for failure in batch_result.failures:
errors.append(f"{failure.job.source_3mf.name}: {failure.error}")
self.log.append(f"构建 {failure.job.source_3mf} 时出错:{failure.error}")
if errors:
QMessageBox.warning(
self,
APP_NAME,
f"已构建 {success_count} 个文件,但有 {len(errors)} 个文件失败。\n\n"
+ "\n".join(errors[:8])
+ ("\n..." if len(errors) > 8 else ""),
)
return
self.show_success_toast(f"已创建 {success_count} 个打包的 3MF 文件。")
if self.clear_after_build_check.isChecked():
self.remove_all()
# =========================================================================
# 窗口级拖放事件
# =========================================================================
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
"""主窗口拖入事件:接受包含文件 URL 的拖放。"""
if event.mimeData().hasUrls():
event.acceptProposedAction()
return
super().dragEnterEvent(event)
def dropEvent(self, event: QDropEvent) -> None:
"""主窗口释放事件:将拖放的文件路径添加到输入列表。"""
urls: list[QUrl] = event.mimeData().urls()
paths = [Path(url.toLocalFile()) for url in urls if url.toLocalFile()]
self.add_paths(paths)
event.acceptProposedAction()
def closeEvent(self, event: Any) -> None:
"""窗口关闭前保存当前设置。"""
self.save_current_settings()
super().closeEvent(event)
def main() -> int:
"""应用程序入口:初始化 QApplication,创建并显示主窗口,进入事件循环。"""
freeze_support()
app = QApplication(sys.argv)
app.setFont(QFont("Segoe UI", 10))
window = MainWindow()
window.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())