8 Commits

9 changed files with 229 additions and 726 deletions
+117 -148
View File
@@ -27,17 +27,17 @@ 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.QtCore import QEvent, Qt, QUrl
from PySide6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QFont, QIcon, QIntValidator, QKeyEvent, QPixmap
from PySide6.QtWidgets import (
QApplication,
QAbstractItemView,
QCheckBox,
QComboBox,
QDialog,
QFileDialog,
QFormLayout,
QGridLayout,
QGraphicsOpacityEffect,
QGroupBox,
QHBoxLayout,
QHeaderView,
@@ -132,103 +132,6 @@ def first_preview_image_member(member_names: list[str], gcode_member: str | 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: 14px 24px;
font-size: 16px;
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 文件的表格控件。
@@ -345,7 +248,6 @@ class MainWindow(QMainWindow):
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()
@@ -486,9 +388,11 @@ class MainWindow(QMainWindow):
self.remove_button.clicked.connect(self.remove_selected)
self.remove_all_button.clicked.connect(self.remove_all)
self.apply_default_copies_button.clicked.connect(self.apply_default_copies_to_selected)
self.repack_3mf_button = QPushButton(t("gui.options.repack_3mf"))
self.repack_3mf_button.clicked.connect(self.repack_3mf_folder)
self.build_button.clicked.connect(self.build_output)
for button in (self.add_button, self.remove_button, self.remove_all_button, self.apply_default_copies_button):
for button in (self.add_button, self.remove_button, self.remove_all_button, self.apply_default_copies_button, self.repack_3mf_button):
button.setMinimumHeight(32)
file_buttons.addWidget(button)
file_buttons.addStretch(1)
@@ -512,10 +416,13 @@ class MainWindow(QMainWindow):
self.swap_gcode_combo.setMinimumWidth(240)
self.swap_gcode_combo.setMaximumWidth(440)
self.refresh_button = QPushButton(t("gui.options.refresh"))
self.view_gcode_button = QPushButton(t("gui.options.view_gcode"))
self.open_folder_button = QPushButton(t("gui.options.open_folder"))
self.refresh_button.setFixedWidth(self.refresh_button.sizeHint().width())
self.view_gcode_button.setFixedWidth(self.view_gcode_button.sizeHint().width())
self.open_folder_button.setFixedWidth(self.open_folder_button.sizeHint().width())
self.refresh_button.clicked.connect(self.load_swap_gcode_to_combo)
self.view_gcode_button.clicked.connect(self.view_swap_gcode)
self.open_folder_button.clicked.connect(self.open_swap_gcode_folder)
# 默认份数
@@ -619,6 +526,7 @@ class MainWindow(QMainWindow):
grid.addWidget(self.swap_gcode_combo, 0, 1, 1, 2)
swap_btn_row = QHBoxLayout()
swap_btn_row.addWidget(self.refresh_button)
swap_btn_row.addWidget(self.view_gcode_button)
swap_btn_row.addWidget(self.open_folder_button)
swap_btn_row.addStretch(1)
grid.addLayout(swap_btn_row, 0, 3)
@@ -690,36 +598,15 @@ class MainWindow(QMainWindow):
root.addWidget(self.output_group)
# =====================================================================
# --- 日志区 ---
# =====================================================================
self.log = QTextEdit()
self.log.setReadOnly(True)
self.log.setMinimumHeight(80)
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)
"""已废弃——底部日志区已移除。"""
pass
def resizeEvent(self, event: QEvent) -> None:
"""窗口大小变化时,更新垂直扩展策略并刷新缩略图。"""
@@ -869,8 +756,6 @@ class MainWindow(QMainWindow):
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(t("gui.log.no_swap_files", dir=str(default_swap_gcode_dir())))
def open_path(self, path: Path) -> None:
"""跨平台打开文件或文件夹(Windows/Mac/Linux)。"""
@@ -889,6 +774,35 @@ class MainWindow(QMainWindow):
folder.mkdir(parents=True, exist_ok=True)
self.open_path(folder)
def view_swap_gcode(self) -> None:
"""在弹出的只读窗口中展示当前选中的换盘 G-code 内容。"""
gcode_path = self.swap_gcode_combo.currentData()
if not gcode_path:
return
try:
text = Path(gcode_path).read_text(encoding="utf-8-sig", errors="replace")
except OSError as exc:
QMessageBox.warning(self, t("gui.dialog.warning.title"), str(exc))
return
dialog = QDialog(self)
dialog.setWindowTitle(t("gui.dialog.view_gcode.title") + " - " + Path(gcode_path).name)
dialog.resize(700, 480)
layout = QVBoxLayout(dialog)
editor = QTextEdit()
editor.setReadOnly(True)
font = QFont("Consolas", 10)
editor.setFont(font)
editor.setText(text)
layout.addWidget(editor)
close_btn = QPushButton(t("gui.button.close"))
close_btn.clicked.connect(dialog.close)
btn_row = QHBoxLayout()
btn_row.addStretch(1)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
dialog.exec()
def open_patch_config(self) -> None:
"""打开 G-code 补丁配置文件。"""
path = default_patch_config_path()
@@ -897,6 +811,57 @@ class MainWindow(QMainWindow):
return
self.open_path(path)
def repack_3mf_folder(self) -> None:
"""选择解压后的3MF文件夹,自动更新MD5并重新打包为.3mf"""
import hashlib
import re
import os
import zipfile
folder = QFileDialog.getExistingDirectory(self, t("gui.dialog.repack_select_folder"))
if not folder:
return
src_dir = Path(folder)
ct_path = src_dir / "[Content_Types].xml"
if not ct_path.exists():
QMessageBox.warning(self, t("gui.dialog.warning.title"), t("gui.dialog.repack_no_content_types"))
return
try:
# 更新所有 plate_N.gcode.md5
gcode_re = re.compile(r"^(.+[/\\])?Metadata[/\\]plate_(\d+)\.gcode$")
metadata_dir = src_dir / "Metadata"
if metadata_dir.is_dir():
for gcode_file in sorted(metadata_dir.glob("plate_*.gcode")):
if not gcode_re.match(str(gcode_file)):
continue
h = hashlib.md5()
with open(gcode_file, "rb") as f:
h.update(f.read())
md5_path = Path(str(gcode_file) + ".md5")
md5_path.write_text(h.hexdigest(), encoding="ascii")
# 打包:[Content_Types].xml 必须是第一个条目
output = src_dir.parent / (src_dir.name.rstrip("/\\") + ".3mf")
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as z:
z.write(ct_path, "[Content_Types].xml")
for root, _dirs, files in os.walk(str(src_dir)):
for fn in files:
fp = Path(root) / fn
arcname = fp.relative_to(src_dir).as_posix()
if arcname == "[Content_Types].xml":
continue
z.write(str(fp), arcname)
QMessageBox.information(
self,
t("gui.dialog.repack_success.title"),
t("gui.dialog.repack_success.text", path=str(output)),
)
except Exception as exc:
QMessageBox.critical(self, t("gui.dialog.warning.title"), str(exc))
# =========================================================================
# 文件输入管理(表格行操作)
# =========================================================================
@@ -1034,8 +999,6 @@ class MainWindow(QMainWindow):
added += 1
else:
skipped += 1
if added or skipped:
self.log.append(t("gui.log.added_files", added=added, skipped=skipped))
self.update_total_summary()
self.update_output_preview()
@@ -1045,7 +1008,7 @@ class MainWindow(QMainWindow):
try:
summary = read_3mf_summary(path)
except Exception as exc:
self.log.append(t("gui.log.skipped_file", path=str(path), reason=str(exc)))
QMessageBox.warning(self, t("gui.dialog.warning.title"), t("gui.log.skipped_file", path=path.name, reason=str(exc)))
return False
row = self.table.rowCount()
self._updating_table = True
@@ -1348,17 +1311,19 @@ class MainWindow(QMainWindow):
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(t("gui.log.output", path=str(result.output_3mf)))
self.log.append(t("gui.log.plates", count=result.plate_count))
self.log.append(t("gui.log.time", time=format_duration(result.total_prediction_seconds)))
self.log.append(t("gui.log.filament", filament=format_filament(result.total_weight_grams)))
self.log.append(t("gui.log.md5", md5=result.gcode_md5))
def _show_build_result_dialog(self, title: str, lines: list[str]) -> None:
"""在弹出的对话框中展示构建结果信息。"""
QMessageBox.information(self, title, "\n".join(lines))
def show_success_toast(self, message: str) -> None:
"""显示构建成功的浮动提示条"""
self.success_toast.show_message(message)
def log_build_result(self, result: Any) -> list[str]:
"""返回单次构建结果的文本行列表"""
return [
t("gui.log.output", path=str(result.output_3mf)),
t("gui.log.plates", count=result.plate_count),
t("gui.log.time", time=format_duration(result.total_prediction_seconds)),
t("gui.log.filament", filament=format_filament(result.total_weight_grams)),
t("gui.log.md5", md5=result.gcode_md5),
]
def build_output(self) -> None:
"""主构建入口:根据批处理模式分发到合并构建或独立构建。"""
@@ -1374,7 +1339,6 @@ class MainWindow(QMainWindow):
self.build_combined_output(jobs)
except Exception as exc:
QMessageBox.critical(self, t("gui.dialog.warning.title"), str(exc))
self.log.append(t("gui.log.build_error", path=type(exc).__name__, error=str(exc)))
return
def build_combined_output(self, jobs: list[PlateJob]) -> None:
@@ -1382,15 +1346,13 @@ class MainWindow(QMainWindow):
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(t("gui.toast.combined_success"))
self._show_build_result_dialog(t("gui.dialog.build_result.title"), self.log_build_result(result))
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:
@@ -1402,20 +1364,21 @@ class MainWindow(QMainWindow):
options = self.build_options_for_output(output_path)
except Exception as exc:
errors.append(f"{job.source_3mf.name}: {exc}")
self.log.append(t("gui.log.build_error", path=str(job.source_3mf), error=str(exc)))
continue
tasks.append(IndividualBuildTask(job, options))
all_lines: list[str] = []
if tasks:
worker_count = individual_batch_worker_count(len(tasks))
self.log.append(t("gui.log.building_individual", count=len(tasks), workers=worker_count))
all_lines.append(t("gui.log.building_individual", count=len(tasks), workers=worker_count))
batch_result = run_individual_batch_builds(tasks, max_workers=worker_count)
for result in batch_result.results:
self.log_build_result(result)
all_lines.extend(self.log_build_result(result))
all_lines.append("")
success_count = len(batch_result.results)
for failure in batch_result.failures:
all_lines.append(t("gui.log.build_error", path=str(failure.job.source_3mf), error=str(failure.error)))
errors.append(f"{failure.job.source_3mf.name}: {failure.error}")
self.log.append(t("gui.log.build_error", path=str(failure.job.source_3mf), error=str(failure.error)))
if errors:
QMessageBox.warning(
@@ -1426,7 +1389,10 @@ class MainWindow(QMainWindow):
+ ("\n..." if len(errors) > 8 else ""),
)
return
self.show_success_toast(t("gui.toast.batch_success", count=success_count))
if all_lines:
self._show_build_result_dialog(t("gui.dialog.build_result.title"), all_lines)
if self.clear_after_build_check.isChecked():
self.remove_all()
if self.clear_after_build_check.isChecked():
self.remove_all()
@@ -1484,15 +1450,18 @@ class MainWindow(QMainWindow):
self.remove_button.setText(t("gui.button.remove"))
self.remove_all_button.setText(t("gui.button.remove_all"))
self.apply_default_copies_button.setText(t("gui.button.apply_copies"))
self.repack_3mf_button.setText(t("gui.options.repack_3mf"))
self.build_button.setText(t("gui.button.build"))
# 打包选项区
self.options_group.setTitle(t("gui.options.group.title"))
self.refresh_button.setText(t("gui.options.refresh"))
self.open_folder_button.setText(t("gui.options.open_folder"))
self.view_gcode_button.setText(t("gui.options.view_gcode"))
# 重新计算按钮宽度以适应翻译后的文本长度
self.refresh_button.setFixedWidth(self.refresh_button.sizeHint().width())
self.open_folder_button.setFixedWidth(self.open_folder_button.sizeHint().width())
self.view_gcode_button.setFixedWidth(self.view_gcode_button.sizeHint().width())
self.bed_cooldown_check.setText(t("gui.options.bed_cooldown"))
self.eject_wait_check.setText(t("gui.options.eject_wait"))
self.show_plate_number_check.setText(t("gui.options.plate_number"))
+11 -11
View File
@@ -1,6 +1,5 @@
{
"_lang": "English",
"gui.file_group.title": "Input 3MF Files",
"gui.file_group.table.header.order": "Order",
"gui.file_group.table.header.file": "3MF File",
@@ -15,18 +14,15 @@
"gui.file_group.table.tooltip_base_filament": "Base filament: {filament}",
"gui.file_group.total_summary": "Total: {plates} plate(s) | Time: {time} | Filament: {filament}",
"gui.file_group.total_summary.empty": "Total: 0 plates | Time: 0s | Filament: 0.00 g",
"gui.button.add_3mf": "Add 3MF",
"gui.button.remove": "Remove",
"gui.button.remove_all": "Remove All",
"gui.button.apply_copies": "Apply Default Copies to Selected",
"gui.button.build": "Build 3MF",
"gui.preview.title": "Selected Thumbnail",
"gui.preview.select_hint": "Select an input file",
"gui.preview.no_image": "No preview image",
"gui.preview.unavailable": "Preview unavailable",
"gui.options.group.title": "Packing Options",
"gui.options.swap_gcode.label": "Swap G-code",
"gui.options.refresh": "Refresh",
@@ -60,7 +56,6 @@
"gui.options.skip_duplicates": "Skip duplicate file paths when adding inputs",
"gui.options.input_handling": "Input handling",
"gui.options.language": "Language",
"gui.output.group.title": "Output",
"gui.output.dir.label": "Output directory",
"gui.output.dir.placeholder": "Leave empty to write next to the input file",
@@ -71,7 +66,6 @@
"gui.output.filename_rule.help.tooltip": "Show filename token help",
"gui.output.preview": "Preview",
"gui.output.batch_preview": "{first} | {count} output file(s)",
"gui.dialog.individual_batch.title": "Individual Batch Mode",
"gui.dialog.individual_batch.text": "Individual batch mode treats every input row as a separate build.\n\nExample: if you add 20 single-plate 3MF files and set copies to 5, Build 3MF will create 20 separate packed files. Each output contains only that source file repeated 5 times.\n\nThis is useful for quickly batch-converting many independent 3MF jobs into multi-copy SwapMod packs. It does not combine all input files into one 3MF.",
"gui.dialog.filename_help.title": "Output Filename Rule",
@@ -85,10 +79,8 @@
"gui.dialog.add_files_title": "Add 3MF files",
"gui.dialog.file_filter.3mf": "3MF files (*.3mf)",
"gui.dialog.file_filter.all": "All files (*)",
"gui.toast.combined_success": "The packed 3MF file was created.",
"gui.toast.batch_success": "Created {count} packed 3MF file(s).",
"gui.log.added_files": "Added {added} input file(s). Skipped {skipped}.",
"gui.log.skipped_file": "Skipped {path}: {reason}",
"gui.log.no_swap_files": "No swap G-code files found in {dir}",
@@ -99,6 +91,14 @@
"gui.log.filament": "Estimated source filament: {filament}",
"gui.log.md5": "G-code MD5: {md5}",
"gui.log.build_error": "Error building {path}: {error}",
"format.unknown": "Unknown"
}
"format.unknown": "Unknown",
"gui.button.close": "Close",
"gui.dialog.view_gcode.title": "View Swap G-code",
"gui.options.view_gcode": "View",
"gui.dialog.build_result.title": "Build Result",
"gui.options.repack_3mf": "Repack 3MF",
"gui.dialog.repack_select_folder": "Select unpacked 3MF folder",
"gui.dialog.repack_success.title": "Repack Complete",
"gui.dialog.repack_success.text": "3MF has been repacked:\n{path}",
"gui.dialog.repack_no_content_types": "[Content_Types].xml not found. Not a valid unpacked 3MF directory."
}
+11 -11
View File
@@ -1,6 +1,5 @@
{
"_lang": "简体中文",
"gui.file_group.title": "输入 3MF 文件",
"gui.file_group.table.header.order": "顺序",
"gui.file_group.table.header.file": "3MF 文件",
@@ -15,22 +14,20 @@
"gui.file_group.table.tooltip_base_filament": "基础耗材:{filament}",
"gui.file_group.total_summary": "合计:{plates} 板 | 时间:{time} | 耗材:{filament}",
"gui.file_group.total_summary.empty": "合计:0 板 | 时间:0s | 耗材:0.00 g",
"gui.button.add_3mf": "添加 3MF",
"gui.button.remove": "移除",
"gui.button.remove_all": "全部移除",
"gui.button.apply_copies": "应用默认份数至选中行",
"gui.button.build": "构建 3MF",
"gui.preview.title": "选中文件的缩略图",
"gui.preview.select_hint": "选择一个输入文件",
"gui.preview.no_image": "无预览图",
"gui.preview.unavailable": "预览不可用",
"gui.options.group.title": "打包选项",
"gui.options.swap_gcode.label": "换盘 G-code",
"gui.options.refresh": "刷新",
"gui.options.open_folder": "打开文件夹",
"gui.options.view_gcode": "查看",
"gui.options.default_copies": "新输入的默认份数",
"gui.options.bed_cooldown": "等待热床降温",
"gui.options.bed_cooldown.label": "热床降温",
@@ -60,7 +57,6 @@
"gui.options.skip_duplicates": "添加输入时跳过重复文件路径",
"gui.options.input_handling": "输入处理",
"gui.options.language": "语言",
"gui.output.group.title": "输出",
"gui.output.dir.label": "输出目录",
"gui.output.dir.placeholder": "留空则写入输入文件所在目录",
@@ -71,7 +67,6 @@
"gui.output.filename_rule.help.tooltip": "显示文件名标记帮助",
"gui.output.preview": "预览",
"gui.output.batch_preview": "{first} | {count} 个输出文件",
"gui.dialog.individual_batch.title": "独立批处理模式",
"gui.dialog.individual_batch.text": "独立批处理模式将每一行输入作为独立的构建任务。\n\n示例:如果你添加了 20 个单板 3MF 文件并设置份数为 5,点击\"构建 3MF\"将创建 20 个独立的打包文件。每个输出文件仅包含该源文件重复 5 次的内容。\n\n这个功能适用于快速将大量独立的 3MF 作业批量转换为多份数的 SwapMod 包。它不会将所有输入文件合并到一个 3MF 中。",
"gui.dialog.filename_help.title": "输出文件名规则",
@@ -85,10 +80,8 @@
"gui.dialog.add_files_title": "添加 3MF 文件",
"gui.dialog.file_filter.3mf": "3MF 文件 (*.3mf)",
"gui.dialog.file_filter.all": "所有文件 (*)",
"gui.toast.combined_success": "已创建打包的 3MF 文件。",
"gui.toast.batch_success": "已创建 {count} 个打包的 3MF 文件。",
"gui.log.added_files": "已添加 {added} 个输入文件。跳过 {skipped} 个。",
"gui.log.skipped_file": "已跳过 {path}{reason}",
"gui.log.no_swap_files": "在 {dir} 中未找到换盘 G-code 文件",
@@ -99,6 +92,13 @@
"gui.log.filament": "预估源文件耗材:{filament}",
"gui.log.md5": "G-code MD5{md5}",
"gui.log.build_error": "构建 {path} 时出错:{error}",
"format.unknown": "未知"
}
"format.unknown": "未知",
"gui.button.close": "关闭",
"gui.dialog.view_gcode.title": "查看换盘 G-code",
"gui.dialog.build_result.title": "构建结果",
"gui.options.repack_3mf": "重新打包 3MF",
"gui.dialog.repack_select_folder": "选择解压后的 3MF 文件夹",
"gui.dialog.repack_success.title": "重新打包完成",
"gui.dialog.repack_success.text": "已重新打包为 3MF 文件:\n{path}",
"gui.dialog.repack_no_content_types": "未找到 [Content_Types].xml,不是有效的 3MF 解压目录。"
}
+90
View File
@@ -0,0 +1,90 @@
"""将解压后的 3MF 文件夹重新打包为 .3mf 文件的工具脚本。
用法:
python repack_3mf.py "解压目录" "output.3mf"
会自动更新 Metadata/plate_N.gcode.md5 并确保 [Content_Types].xml 为 ZIP 第一条目。
如果省略输出路径,自动在解压目录同级生成同名 .3mf。
"""
from __future__ import annotations
import hashlib
import os
import re
import sys
import zipfile
from pathlib import Path
GCODE_MEMBER_RE = re.compile(r"^Metadata[\\/]plate_(\d+)\.gcode$")
def update_md5(src_dir: Path) -> None:
"""更新所有 plate_N.gcode 对应的 .md5 文件。"""
metadata = src_dir / "Metadata"
if not metadata.is_dir():
return
for gcode_file in sorted(metadata.glob("plate_*.gcode")):
if not GCODE_MEMBER_RE.match(str(gcode_file.relative_to(src_dir)).replace("\\", "/")):
continue
h = hashlib.md5()
with open(gcode_file, "rb") as f:
h.update(f.read())
md5_path = Path(str(gcode_file) + ".md5")
md5_path.write_text(h.hexdigest(), encoding="ascii")
print(f" MD5 已更新: {md5_path.name}{h.hexdigest()}")
def repack(src_dir: Path, output: Path) -> None:
"""将解压目录重新打包为 3MF。"""
ct_path = src_dir / "[Content_Types].xml"
if not ct_path.exists():
raise FileNotFoundError(f"未找到 [Content_Types].xml,请确认 {src_dir} 是有效的 3MF 解压目录")
print(f"源目录: {src_dir}")
update_md5(src_dir)
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as z:
z.write(ct_path, "[Content_Types].xml")
count = 1
for root, _dirs, files in os.walk(str(src_dir)):
for fn in files:
fp = Path(root) / fn
arcname = fp.relative_to(src_dir).as_posix()
if arcname == "[Content_Types].xml":
continue
z.write(str(fp), arcname)
count += 1
size_mb = output.stat().st_size / (1024 * 1024)
print(f"输出文件: {output} ({size_mb:.1f} MB, {count} 条目)")
print("打包完成。")
def main() -> int:
if len(sys.argv) < 2:
print(__doc__)
return 1
src = Path(sys.argv[1])
if not src.is_dir():
print(f"错误: 目录不存在 — {src}")
return 1
if len(sys.argv) >= 3:
output = Path(sys.argv[2])
else:
output = src.parent / (src.name.rstrip("/\\") + ".3mf")
try:
repack(src, output)
except Exception as exc:
print(f"错误: {exc}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-132
View File
@@ -1,132 +0,0 @@
;==========================================================
; A1 SwapMod 换板 G-code — 类型 B:双轮抖板
; 特点:G380 S3/S2 交替下压、双轮 Shake 抖板(最大3.5mm偏移)、无 M400/M17
;==========================================================
; 开始换板
; --- 阶段1:Y 轴归零并退到后端 ---
G28 Y ; Y 轴归零(寻找限位开关)
G1 Y262 F1200 ; 推到 Y262(后端位置,靠近 Z 轴立柱)
; --- 阶段2Z 轴交替下压(G380 S3/S2 ×4轮),压紧打印板 ---
G91 ; 切换相对坐标模式
G380 S3 Z-25 F1200 ; 力传感器碰撞退出:Z 轴下降 25mm 直到碰到板
G380 S2 Z85 F1200 ; 力传感器目标位移动:Z 轴上升 85mm 到预设力值
G380 S3 Z-20 F1200 ; 第二轮下压碰撞检测
G380 S2 Z85 F1200 ; 第二轮力控上升
G380 S3 Z-20 F1200 ; 第三轮下压
G380 S2 Z85 F1200 ; 第三轮上升
G380 S3 Z-20 F1200 ; 第四轮下压(确保板完全贴合热床)
G380 S2 Z85 F1200 ; 第四轮上升
G1 Z-5 F1200 ; Z 轴下降 5mm 释放上升张力
G90 ; 切回绝对坐标模式
; --- 阶段3:驻留稳定 ---
G4 P2000 ; 驻留 2 秒,让板稳定贴合
; --- 阶段4:前推弹板 — 利用惯性将板从热床上推离 ---
G1 Y15 F2000 ; 先推到前端附近 Y15
G1 Y150 F1200 ; 后退一段
G1 Y40 F1200 ; 再推向前端
; 以下注释掉的 Y260 是备选方案
; G1 Y260 F3000
; 用触力传感器推到最前端 Y264(力控,不会硬撞)
G380 S2 Y264 F2000 ; A1 专有:以预设力矩正向推进到 Y264
; --- 阶段5:Z 轴上撤退让,为弹射留出空间 ---
G91 ; 切相对坐标
; G380 S3 Z-55 F1200 ; 注释掉:原用触力退出方式
G1 Z-55 F1200 ; Z 轴下降 55mm(使喷嘴远离板面)
G90 ; 切回绝对坐标
; --- 阶段6:Y 轴快速移动 + 微调定位 ---
G1 Y120 F4000 ; 快速移动到 Y120
G28 Y ; Y 轴再次归零校准
; 使用 G1 而非 G380 是为了防止触力传感器干扰定位精度
G1 Y-1 F1200 ; Y 轴后移 1mm(为抖板准备起始位置)
; G380 S3 Y-1 F1200 ; 注释掉:原触力退出方式
G4 P500 ; 驻留 500ms
; ═══════════════════════════════════════════
; 抖板 第1轮:微小 Y 轴往复振动,确保板完全脱离
; 振幅逐步增大:1mm → 3.5mm
; ═══════════════════════════════════════════
; 开始 振动触发换板
G1 Y1 F100 ; 前进 1mm(慢速)
G1 Y0 F20000 ; 快速退回
G4 P500 ; 驻留 500ms
G1 Y1.5 F100 ; 前进 1.5mm
G1 Y0 F20000 ; 快速退回
G4 P500
G1 Y2 F100 ; 前进 2mm
G1 Y0 F20000 ; 快速退回
G4 P500
G1 Y2.5 F100 ; 前进 2.5mm
G1 Y0 F20000 ; 快速退回
G4 P500
G1 Y3 F100 ; 前进 3mm
G1 Y0 F10000 ; 退回(降速)
G4 P500
G1 Y3.5 F100 ; 前进 3.5mm(最大振幅)
G1 Y0 F5000 ; 退回(进一步降速)
; 结束 振动触发换板
; --- 阶段7:推板前移 + 力控校准 ---
G1 Y235 F1200 ; 推到 Y235
G1 Y110 F5000 ; 后退
G1 Y200 F1200 ; 再前推
; G1 Y266 F1500 ; 注释掉:备选位置
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
; 将打印板缩回到最前端后,驻留 1 秒
G4 P1000 ; 驻留 1 秒
; --- 阶段8:再次弹射 ---
G1 Y40 F4000 ; 推到 Y40
G1 Y-2 F5000 ; ⚡ 高速反向弹射 2mm
; ═══════════════════════════════════════════
; 抖板 第2轮:再次振动,确保没有残留的板
; 振幅:1mm → 3.5mm(与第1轮相同)
; ═══════════════════════════════════════════
; 开始 振动触发换板
G1 Y1 F100
G1 Y0 F20000
G4 P500
G1 Y1.5 F100
G1 Y0 F20000
G4 P500
G1 Y2 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F20000
G4 P500
G1 Y3 F100
G1 Y0 F10000
G4 P500
G1 Y3.5 F100
G1 Y0 F5000
; 以下为注释掉的备选抖板参数
; G1 Y2.5 F100
; G1 Y0 F8000
; G4 P1000
; G1 Y3.5 F100
; G1 Y0 F5000
; 结束 振动触发换板
; --- 阶段9:归位到待机位置 ---
G1 Y120 F2000 ; 回到 Y120 中间待机位置
; 结束换板
-140
View File
@@ -1,140 +0,0 @@
;==========================================================
; A1 SwapMod 换板 G-code — 类型 C1:安全换板(标准版)
; 特点:M400/M17 保护、G1 Z- + G380 S2 纯力控下压、双轮抖板(2.5mm偏移)
;==========================================================
; 开始换板
; --- 阶段1:Y 轴归零并退到后端 ---
G28 Y ; Y 轴归零(寻找限位开关)
G1 Y262 F2000 ; 推到 Y262(后端位置,速度 F2000)
; --- 阶段2:运动保护 — 等所有运动停止 + 降低 Z 电机电流 ---
M400 ; 等待所有运动队列清空
M17 Z0.4 ; 将 Z 轴电机电流降至 0.4A,降低阻力,防止后续 Y 轴移动时 Z 轴硬怼损伤打印件
; --- 阶段3Z 轴下压(G1 Z- + G380 S2 纯力控 ×4轮),压紧打印板 ---
G91 ; 切换相对坐标模式
G1 Z-35 F1200 ; Z 轴下降 35mm
G380 S2 Z95 ; 力控上升 95mm 到预设力值
G1 Z-25 ; Z 轴再降 25mm
G380 S2 Z90 ; 力控上升 90mm
G1 Z-25 ; 第三轮
G380 S2 Z90
G1 Z-30 ; 第四轮
G380 S2 Z95
G1 Z-5 ; Z 轴微降 5mm 释放张力
G90 ; 切回绝对坐标
; --- 阶段4:驻留稳定 ---
G4 P2000 ; 驻留 2 秒
; --- 阶段5:前推弹板 — 利用惯性将板推离 ---
G1 Y15 F800 ; 缓慢推到前端 Y15
G1 Y150 F1200 ; 后退到 Y150
; 此行控制卷料尾端的悬垂距离。值越小,悬垂越少。
G1 Y40 F1000 ; 推到 Y40
; 平台通过力控确保即使没有板也能正常装入新板
; G1 Y260 F3000 ; 注释掉:备选方案
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
; --- 阶段6Z 轴上撤退让 ---
G91 ; 切相对坐标
G1 Z-70 F1200 ; Z 轴下降 70mm(使喷嘴远离板面,为弹射留空间)
G90 ; 切回绝对坐标
; --- 阶段7:Y 轴高速移动 + 微调到弹射起始位 ---
G1 Y200 F10000 ; 高速推到 Y200
G1 Y25 F4000 ; 减速靠前
; 使用 G1 而非 G380,防止触力传感器干扰定位
G1 Y-1 F20000 ; 高速后退 1mm(为抖板准备起始位)
; G380 S3 Y-1 F1200 ; 注释掉:原触力退出方式
G4 P500 ; 驻留 500ms
; ═══════════════════════════════════════════
; 抖板 第1轮:微小 Y 轴往复振动,确保板完全脱离
; 振幅逐步增大:1mm → 2.5mm(比类型B更保守)
; ═══════════════════════════════════════════
G28 Y ; 先 Y 轴归零校准
; 开始 振动触发换板
G1 Y1 F100 ; 前进 1mm(慢速)
G1 Y0 F20000 ; 快速退回
G4 P500
G1 Y1.5 F100 ; 前进 1.5mm
G1 Y0 F20000 ; 快速退回
G4 P500
G1 Y2 F100 ; 前进 2mm
G1 Y0 F20000 ; 快速退回
G4 P500
G1 Y2.5 F100 ; 前进 2.5mm
G1 Y0 F20000 ; 快速退回
G4 P500
G1 Y2.5 F100 ; 再次 2.5mm(巩固)
G1 Y0 F10000 ; 退回(降速)
G4 P500
; 注释掉的更大振幅
; G1 Y3.5 F100
; G1 Y0 F5000
; 结束 振动触发换板
; --- 阶段8:推板前移 + 力控校准 ---
G1 Y235 F1200 ; 推到 Y235
G1 Y110 F5000 ; 后退
G1 Y200 F1200 ; 再前推
; G1 Y266 F1500 ; 注释掉:备选
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
; 将打印板缩回到最前端后,驻留 1 秒
G4 P1000
; --- 阶段9:第2轮弹射 ---
G1 Y25 F4000 ; 推到 Y25
G1 Y-2 F20000 ; ⚡ 高速反向弹射 2mm(F20000,最快的弹射速度)
; G1 Y-2 F1000 ; 慢速备选
G4 P500 ; 驻留 500ms
; ═══════════════════════════════════════════
; 抖板 第2轮:再次振动确保无残留
; 振幅:1mm → 2.5mm
; ═══════════════════════════════════════════
; 开始 振动触发换板
G1 Y1 F100
G1 Y0 F20000
G4 P500
G1 Y1.5 F100
G1 Y0 F20000
G4 P500
G1 Y2 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F10000
G4 P500
; 结束 振动触发换板
; --- 阶段10:归位 ---
; 推到前端
G1 Y262 F1500 ; 推到 Y262
G1 Y120 F2000 ; 回到 Y120 待机位置
; 结束换板
-133
View File
@@ -1,133 +0,0 @@
;==========================================================
; A1 SwapMod 换板 G-code — 类型 C2:安全换板(慢速版)
; 特点:与 C1 标准版完全相同,仅第2轮弹射速度从 F20000 降为 F1000
; 更温和的弹射,减少对板和打印机的冲击
;==========================================================
; 开始换板
; --- 阶段1:Y 轴归零并退到后端 ---
G28 Y ; Y 轴归零(寻找限位开关)
G1 Y262 F2000 ; 推到 Y262(后端位置)
; --- 阶段2:运动保护 ---
M400 ; 等待所有运动队列清空
M17 Z0.4 ; 将 Z 轴电机电流降至 0.4A,降低阻力,防止碰撞损伤打印件
; --- 阶段3Z 轴下压(G1 Z- + G380 S2 纯力控 ×4轮)---
G91 ; 切换相对坐标
G1 Z-35 F1200 ; Z 轴下降 35mm
G380 S2 Z95 ; 力控上升 95mm
G1 Z-25 ; 第二轮
G380 S2 Z90
G1 Z-25 ; 第三轮
G380 S2 Z90
G1 Z-30 ; 第四轮
G380 S2 Z95
G1 Z-5 ; Z 轴微降释放张力
G90 ; 切回绝对坐标
; --- 阶段4:驻留稳定 ---
G4 P2000 ; 驻留 2 秒
; --- 阶段5:前推弹板 ---
G1 Y15 F800 ; 缓慢推到前端 Y15
G1 Y150 F1200 ; 后退到 Y150
; 此行控制卷料尾端的悬垂距离。值越小,悬垂越少。
G1 Y40 F1000 ; 推到 Y40
; 平台通过力控确保即使没有板也能正常装入新板
; G1 Y260 F3000
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
; --- 阶段6Z 轴上撤退让 ---
G91 ; 切相对坐标
G1 Z-70 F1200 ; Z 轴下降 70mm(为弹射留空间)
G90 ; 切回绝对坐标
; --- 阶段7:Y 轴高速移动 + 微调定位 ---
G1 Y200 F10000 ; 高速推到 Y200
G1 Y25 F4000 ; 减速靠前
; 使用 G1 而非 G380,防止触力传感器干扰定位
G1 Y-1 F20000 ; 高速后退 1mm
G4 P500 ; 驻留 500ms
; --- 抖板校准归零 ---
G28 Y ; Y 轴归零校准
; ═══════════════════════════════════════════
; 抖板 第1轮:振动确保板完全脱离(1→2.5mm)
; ═══════════════════════════════════════════
; 开始 振动触发换板
G1 Y1 F100
G1 Y0 F20000
G4 P500
G1 Y1.5 F100
G1 Y0 F20000
G4 P500
G1 Y2 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F10000
G4 P500
; G1 Y3.5 F100 ; 注释掉:更大的振幅(备选)
; G1 Y0 F5000
; 结束 振动触发换板
; --- 阶段8:推板 + 力控校准 ---
G1 Y235 F1200
G1 Y110 F5000
G1 Y200 F1200
; G1 Y266 F1500
G380 S2 Y264 F2000 ; 力控推到最前端
; 驻留 1 秒
G4 P1000
; --- 阶段9:第2轮弹射(🐌 慢速版关键差异)---
G1 Y25 F4000 ; 推到 Y25
; G1 Y-2 F20000 ; 注释掉:原标准版高速弹射
G1 Y-2 F1000 ; 🐌 慢速弹射(F1000 vs 标准版 F20000
; 更温和,减少对板和结构的冲击
G4 P500
; ═══════════════════════════════════════════
; 抖板 第2轮:再次振动(1→2.5mm)
; ═══════════════════════════════════════════
; 开始 振动触发换板
G1 Y1 F100
G1 Y0 F20000
G4 P500
G1 Y1.5 F100
G1 Y0 F20000
G4 P500
G1 Y2 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F10000
G4 P500
; 结束 振动触发换板
; --- 阶段10:归位 ---
; 推到前端
G1 Y262 F1500
G1 Y120 F2000 ; 回到 Y120 待机位置
; 结束换板
-151
View File
@@ -1,151 +0,0 @@
;==========================================================
; A1 SwapMod 换板 G-code — 类型 C3:安全换板(安全Z版)⭐ 推荐
; 特点:基于 C2 慢速版,尾部追加 Z 轴安全回位(Z30),防止下一板打印时碰撞
; 本变体由 QQ 群用户 风信子号 提供
;==========================================================
;===================================
; 此变体由 风信子号 友情提供
;===================================
; 开始换板
; --- 阶段1:Y 轴归零并退到后端 ---
G28 Y ; Y 轴归零(寻找限位开关)
G1 Y262 F2000 ; 推到 Y262(后端位置)
; --- 阶段2:运动保护 ---
M400 ; 等待所有运动队列清空
M17 Z0.4 ; 将 Z 轴电机电流降至 0.4A,降低阻力,防止碰撞损伤打印件
; --- 阶段3Z 轴下压(G1 Z- + G380 S2 纯力控 ×4轮)---
G91 ; 切换相对坐标
G1 Z-35 F1200 ; Z 轴下降 35mm
G380 S2 Z95 ; 力控上升 95mm
G1 Z-25 ; 第二轮
G380 S2 Z90
G1 Z-25 ; 第三轮
G380 S2 Z90
G1 Z-30 ; 第四轮
G380 S2 Z95
G1 Z-5 ; Z 轴微降释放张力
G90 ; 切回绝对坐标
; --- 阶段4:驻留稳定 ---
G4 P2000 ; 驻留 2 秒
; --- 阶段5:前推弹板 ---
G1 Y15 F800 ; 缓慢推到前端 Y15
G1 Y150 F1200 ; 后退到 Y150
; 此行控制卷料尾端的悬垂距离。值越小,悬垂越少。
G1 Y40 F1000 ; 推到 Y40
; 平台通过力控确保即使没有板也能正常装入新板
; G1 Y260 F3000
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
;=================================================
; !!!!!! Z 轴下降,离开顶部
;=================================================
; --- 阶段6Z 轴上撤退让 ---
G91 ; 切相对坐标
G1 Z-70 F1200 ; Z 轴下降 70mm(为弹射留空间)
G90 ; 切回绝对坐标
; --- 阶段7:Y 轴高速移动 + 微调定位 ---
G1 Y200 F10000 ; 高速推到 Y200
G1 Y25 F4000 ; 减速靠前
; 使用 G1 而非 G380,防止触力传感器干扰定位
G1 Y-1 F20000 ; 高速后退 1mm
G4 P500 ; 驻留 500ms
; --- 抖板校准归零 ---
G28 Y ; Y 轴归零校准
; ═══════════════════════════════════════════
; 抖板 第1轮:振动确保板完全脱离(1→2.5mm)
; ═══════════════════════════════════════════
; 开始 振动触发换板
G1 Y1 F100
G1 Y0 F20000
G4 P500
G1 Y1.5 F100
G1 Y0 F20000
G4 P500
G1 Y2 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F10000
G4 P500
; G1 Y3.5 F100 ; 注释掉:备选更大振幅
; G1 Y0 F5000
; 结束 振动触发换板
; --- 阶段8:推板 + 力控校准 ---
G1 Y235 F1200
G1 Y110 F5000
G1 Y200 F1200
; G1 Y266 F1500
G380 S2 Y264 F2000 ; 力控推到最前端
; 驻留 1 秒
G4 P1000
; --- 阶段9:第2轮弹射(慢速)---
G1 Y25 F4000 ; 推到 Y25
; G1 Y-2 F20000 ; 注释掉:标准版高速
G1 Y-2 F1000 ; 🐌 慢速弹射
G4 P500
; ═══════════════════════════════════════════
; 抖板 第2轮:再次振动(1→2.5mm)
; ═══════════════════════════════════════════
; 开始 振动触发换板
G1 Y1 F100
G1 Y0 F20000
G4 P500
G1 Y1.5 F100
G1 Y0 F20000
G4 P500
G1 Y2 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F10000
G4 P500
; 结束 振动触发换板
; --- 阶段10:归位 ---
; 推到前端
G1 Y262 F1500
G1 Y120 F2000 ; 回到 Y120 待机位置
; 结束换板
; ═══════════════════════════════════════════
; 🔐 安全 Z 轴回位(C3 独有)
; 换板完成后 Z 轴在低位(约 Z-70),下一板开始时可能碰撞
; 预先升到 Z30 确保安全间距
; ═══════════════════════════════════════════
G4 P3000 ; 驻留 3 秒,等待板完全稳定
M400 ; 等待所有运动完成
G90 ; 确保绝对坐标
G1 Z30 F600 ; 🔐 Z 轴升至 30mm 安全高度
M400 ; 再次确认到位