Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9d2a0cbf1 | |||
| 5e6be787c7 | |||
| 2438eca047 | |||
| f066ca24fa | |||
| 116dcfe45f | |||
| db517b69f7 | |||
| ed02b48d80 | |||
| 68aa056359 | |||
| 2fed301a6c | |||
| dd79918940 | |||
| 015f2636b3 | |||
| 935665e7e8 | |||
| ca436b577b | |||
| f6b118293c |
+2
-1
@@ -16,4 +16,5 @@ Thumbs.db
|
||||
*.bak
|
||||
/settings.json
|
||||
/reference
|
||||
codec.py
|
||||
codec.py/code/
|
||||
code
|
||||
|
||||
@@ -19,7 +19,7 @@ A1 Swap Mod Packer 是一个开源的 3MF 打包工具,专为 Bambu Lab A1 Swa
|
||||
## 主要功能
|
||||
|
||||
- 拖放式批量打包 GUI。
|
||||
- 可重复执行的自动化 CLI。
|
||||
- 简洁高效的桌面 GUI。
|
||||
- 支持多个 `.3mf` 输入文件。
|
||||
- 每个文件独立份数。
|
||||
- 自动从 `Metadata/slice_info.config` 读取并汇总时间和耗材。
|
||||
@@ -65,24 +65,6 @@ python -m a1_swap_mod_packer.gui
|
||||
python run_gui.py
|
||||
```
|
||||
|
||||
启动 CLI:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli --help
|
||||
```
|
||||
|
||||
或
|
||||
|
||||
```bash
|
||||
python run_cli.py
|
||||
```
|
||||
|
||||
查看版本:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli --version
|
||||
```
|
||||
|
||||
## 构建 Windows 可执行文件
|
||||
|
||||
仓库包含 Windows 构建脚本:
|
||||
@@ -102,7 +84,6 @@ build/onefile/
|
||||
```text
|
||||
build/onefile/
|
||||
a1packer.exe
|
||||
a1packer-cli.exe
|
||||
gcode_patches.ini
|
||||
swap_gcode/
|
||||
x.png 输出预览图示例
|
||||
@@ -145,10 +126,9 @@ py -3.13 -m venv .venv
|
||||
build_win.cmd
|
||||
```
|
||||
|
||||
构建脚本执行两次 Nuitka 编译:
|
||||
构建脚本执行一次 Nuitka 编译:
|
||||
|
||||
- GUI:`run_gui.py` → `build/onefile/a1packer.exe`
|
||||
- CLI:`run_cli.py` → `build/onefile/a1packer-cli.exe`
|
||||
|
||||
GUI 编译启用 PySide6 插件并包含当前界面所需的 Qt 插件组:
|
||||
|
||||
@@ -158,22 +138,7 @@ platforms,imageformats,styles,iconengines
|
||||
|
||||
### 构建后验证
|
||||
|
||||
发布目录前,先确认 CLI 能识别外部资源:
|
||||
|
||||
```cmd
|
||||
build\onefile\a1packer-cli.exe --version
|
||||
build\onefile\a1packer-cli.exe list-swap-gcode
|
||||
```
|
||||
|
||||
第二条命令应列出:
|
||||
|
||||
```text
|
||||
build/onefile/swap_gcode/
|
||||
```
|
||||
|
||||
中的文件。
|
||||
|
||||
同时启动一次 `build/onefile/a1packer.exe`,确认:
|
||||
启动一次 `build/onefile/a1packer.exe`,确认:
|
||||
|
||||
- 换盘 G-code 下拉框列出已复制的模板。
|
||||
- 修改 GUI 选项会创建或更新 `build/onefile/settings.json`。
|
||||
@@ -206,7 +171,7 @@ GUI 中使用方法:
|
||||
|
||||
## 可编辑 G-code 补丁
|
||||
|
||||
GUI 和 CLI 都读取固定补丁文件:
|
||||
GUI 读取固定补丁文件:
|
||||
|
||||
```text
|
||||
gcode_patches.ini
|
||||
@@ -375,12 +340,6 @@ gcode_patches.ini
|
||||
|
||||
如果预览图缺失或无法读取,打包器保留可用基础预览,仍尝试施加面板标签。
|
||||
|
||||
CLI 可通过以下选项禁用预览重写:
|
||||
|
||||
```bash
|
||||
--no-preview-label
|
||||
```
|
||||
|
||||
### 批处理模式
|
||||
|
||||
#### 合并模式
|
||||
@@ -506,112 +465,3 @@ settings.json
|
||||
- 构建成功后清空输入列表。
|
||||
- 输出目录。
|
||||
- 输出文件名规则。
|
||||
|
||||
## CLI 使用示例
|
||||
|
||||
列出可用的换盘 G-code 文件:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli list-swap-gcode
|
||||
```
|
||||
|
||||
单源文件 × 5 份:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "SC05720_01.gcode(1).3mf" 5 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
--cool-bed 45 \
|
||||
--wait 45 \
|
||||
--show-plate-number \
|
||||
-o "5 Plates - SC05720_01.gcode(1).3mf"
|
||||
```
|
||||
|
||||
多源文件合并输出:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 2 \
|
||||
--item "B.3mf" 3 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
-o "5 Plates - A_and_B.3mf"
|
||||
```
|
||||
|
||||
使用累加元数据模式:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 5 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
--metadata-mode sum \
|
||||
-o "5 Plates - A.3mf"
|
||||
```
|
||||
|
||||
禁用 G-code 补丁:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 5 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
--no-gcode-patches \
|
||||
-o "5 Plates - A.3mf"
|
||||
```
|
||||
|
||||
位置参数输入 + 统一份数:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
"A.3mf" "B.3mf" \
|
||||
--copies 2 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
-o "4 Plates - A_and_B.3mf"
|
||||
```
|
||||
|
||||
指定换盘 G-code 目录和 ZIP 压缩级别:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 5 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
--swap-gcode-dir "D:\SwapMod\swap_gcode" \
|
||||
--zip-level 9 \
|
||||
-o "5 Plates - A.3mf"
|
||||
```
|
||||
|
||||
禁用最后换盘和预览标签重写:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 5 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
--no-swap-after-final \
|
||||
--no-preview-label \
|
||||
-o "5 Plates - A.3mf"
|
||||
```
|
||||
|
||||
禁用换盘后等待时间:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 5 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
--no-eject-wait \
|
||||
-o "5 Plates - A.3mf"
|
||||
```
|
||||
|
||||
CLI 常用选项速查:
|
||||
|
||||
| 选项 | 含义 |
|
||||
|---|---|
|
||||
| `--version` | 打印当前版本 |
|
||||
| `list-swap-gcode` | 列出换盘 G-code 目录中的文件 |
|
||||
| `--item PATH COPIES` | 添加一个带独立份数的输入;可多次使用 |
|
||||
| 位置参数 `inputs` + `--copies N` | 添加多个使用相同份数的输入 |
|
||||
| `--swap-gcode-dir DIR` | 使用自定义换盘 G-code 目录 |
|
||||
| `--no-bed-cooldown` | 不在换盘块前插入 `M190` |
|
||||
| `--no-swap-after-final` | 最后一块盘后不执行换盘块 |
|
||||
| `--no-eject-wait` | 不插入换盘后等待时间(`G4 P...` 行) |
|
||||
| `--line-ending lf\|crlf` | 选择生成 G-code 的换行符;默认 `crlf` |
|
||||
| `--zip-level 1-9` | zlib-ng Deflate 压缩级别;默认 `7` |
|
||||
| `--no-preview-label` | 不重写预览图标签/合成图 |
|
||||
| `--no-gcode-patches` | 不应用 `gcode_patches.ini` 中的规则 |
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
# 应用程序显示名称
|
||||
APP_NAME = "A1 Swap Mod Packer"
|
||||
# 语义化版本号
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.6.1"
|
||||
# 窗口标题:应用名称 + 版本号
|
||||
APP_TITLE = f"{APP_NAME} v{__version__}"
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"""命令行接口模块:定义参数解析器和命令处理函数。
|
||||
|
||||
提供 `build`(构建打包 3MF)和 `list-swap-gcode`(列出换盘 G-code 文件)
|
||||
两个子命令,以及 `main` 入口函数。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import APP_NAME, APP_TITLE, __version__
|
||||
from .core import (
|
||||
BuildOptions,
|
||||
DEFAULT_ZIP_COMPRESS_LEVEL,
|
||||
PlateJob,
|
||||
build_packed_3mf,
|
||||
list_swap_gcode_files,
|
||||
)
|
||||
from .paths import default_patch_config_path, default_swap_gcode_dir
|
||||
|
||||
|
||||
def parse_item(values: list[str]) -> PlateJob:
|
||||
"""解析 --item 参数的键值对,生成一个 PlateJob 实例。
|
||||
|
||||
Args:
|
||||
values: 包含两个字符串的列表 [路径, 份数]
|
||||
|
||||
Returns:
|
||||
PlateJob: 对应的板作业对象
|
||||
|
||||
Raises:
|
||||
argparse.ArgumentTypeError: 参数格式或类型不合法时抛出
|
||||
"""
|
||||
# --item 必须提供恰好两个值:路径和份数
|
||||
if len(values) != 2:
|
||||
raise argparse.ArgumentTypeError("每个 --item 需要一个路径和一个份数。")
|
||||
path = Path(values[0])
|
||||
# 份数必须可以解析为整数
|
||||
try:
|
||||
copies = int(values[1])
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(f"无效的份数:{values[1]}") from exc
|
||||
return PlateJob(path, copies)
|
||||
|
||||
|
||||
def build_command(args: argparse.Namespace) -> int:
|
||||
"""执行 'build' 子命令:收集输入、组装选项、调用构建。
|
||||
|
||||
Args:
|
||||
args: 解析后的命令行命名空间
|
||||
|
||||
Returns:
|
||||
int: 退出码,成功为 0
|
||||
"""
|
||||
jobs: list[PlateJob] = []
|
||||
# 处理 --item 参数(每个可独立指定份数)
|
||||
for item in args.item or []:
|
||||
jobs.append(parse_item(item))
|
||||
# 处理位置参数输入(共用 --copies 份数)
|
||||
for input_path in args.inputs or []:
|
||||
jobs.append(PlateJob(Path(input_path), args.copies))
|
||||
# 没有输入文件时直接报错退出
|
||||
if not jobs:
|
||||
raise SystemExit("未提供输入 3MF 文件。")
|
||||
# 根据 --no-bed-cooldown 决定是否写入床温指令
|
||||
cool_bed_temp = None if args.no_bed_cooldown else args.cool_bed
|
||||
options = BuildOptions(
|
||||
swap_gcode=args.swap_gcode,
|
||||
output_3mf=Path(args.output),
|
||||
cool_bed_temp=cool_bed_temp,
|
||||
wait_after_eject_seconds=0 if args.no_eject_wait else args.wait,
|
||||
show_plate_number=args.show_plate_number,
|
||||
swap_after_final=not args.no_swap_after_final,
|
||||
metadata_mode=args.metadata_mode,
|
||||
line_ending=args.line_ending,
|
||||
add_preview_label=not args.no_preview_label,
|
||||
apply_gcode_patches=not args.no_gcode_patches,
|
||||
swap_gcode_dir=Path(args.swap_gcode_dir) if args.swap_gcode_dir else None,
|
||||
zip_compress_level=args.zip_level,
|
||||
)
|
||||
# 调用核心构建函数
|
||||
result = build_packed_3mf(jobs, options)
|
||||
# 输出构建结果摘要
|
||||
print(f"输出:{result.output_3mf}")
|
||||
print(f"板数:{result.plate_count}")
|
||||
print(f"G-code MD5:{result.gcode_md5}")
|
||||
if result.total_prediction_seconds is not None:
|
||||
print(f"源文件打印时间:{int(result.total_prediction_seconds)} 秒")
|
||||
if result.total_weight_grams is not None:
|
||||
print(f"源文件耗材重量:{result.total_weight_grams:.2f} g")
|
||||
return 0
|
||||
|
||||
|
||||
def list_swap_gcode_command(args: argparse.Namespace) -> int:
|
||||
"""执行 'list-swap-gcode' 子命令:列出可用的换盘 G-code 文件。
|
||||
|
||||
Args:
|
||||
args: 解析后的命令行命名空间
|
||||
|
||||
Returns:
|
||||
int: 退出码,成功为 0
|
||||
"""
|
||||
# 解析目录:优先使用命令行参数,否则使用默认目录
|
||||
directory = Path(args.swap_gcode_dir) if args.swap_gcode_dir else default_swap_gcode_dir()
|
||||
files = list_swap_gcode_files(directory)
|
||||
# 目录为空时给出提示
|
||||
if not files:
|
||||
print(f"在 {directory} 中未找到换盘 G-code 文件")
|
||||
return 0
|
||||
for path in files:
|
||||
print(path.name)
|
||||
return 0
|
||||
|
||||
|
||||
def create_parser() -> argparse.ArgumentParser:
|
||||
"""创建并配置命令行参数解析器。
|
||||
|
||||
包含两个子命令:
|
||||
- build: 构建打包的 3MF 文件
|
||||
- list-swap-gcode: 列出换盘 G-code 文件
|
||||
|
||||
Returns:
|
||||
argparse.ArgumentParser: 配置完成的参数解析器
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="a1-swap-mod-packer",
|
||||
description=f"{APP_TITLE} - 将重复的 Bambu A1 SwapMod 面板打包为一个 3MF 作业。",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"{APP_NAME} {__version__}")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# ---- build 子命令 ----
|
||||
build = subparsers.add_parser("build", help="构建一个打包的 3MF 文件。")
|
||||
# 位置参数:输入文件路径(共用统一的份数)
|
||||
build.add_argument("inputs", nargs="*", help="输入 3MF 文件。使用 --copies 为所有文件设置相同的份数。")
|
||||
# --item 参数:每个文件可独立指定份数
|
||||
build.add_argument("--item", nargs=2, action="append", metavar=("PATH", "COPIES"), help="添加一个带有独立份数的输入 3MF 文件。可重复使用。")
|
||||
build.add_argument("-o", "--output", required=True, help="输出 3MF 路径。")
|
||||
# 换盘 G-code 相关
|
||||
build.add_argument("--swap-gcode", required=True, help="换盘 G-code 文件名,位于 swap_gcode 中,或显式文件路径。")
|
||||
build.add_argument("--swap-gcode-dir", default=None, help=f"模板目录。默认值:{default_swap_gcode_dir()}")
|
||||
# 份数与温度
|
||||
build.add_argument("--copies", type=int, default=1, help="位置参数输入文件的复制份数。")
|
||||
build.add_argument("--cool-bed", type=int, default=45, help="执行换盘 G-code 前等待的床温。")
|
||||
build.add_argument("--no-bed-cooldown", action="store_true", help="不在换料代码前插入 M190。")
|
||||
# 时间与编号
|
||||
build.add_argument("--wait", type=int, default=45, help="板换盘后等待的秒数。")
|
||||
build.add_argument("--no-eject-wait", action="store_true", help="不插入换盘后等待指令 G4。")
|
||||
build.add_argument("--show-plate-number", action="store_true", help="每板号向 M73 R 值增加 100 小时。")
|
||||
# 行为开关
|
||||
build.add_argument("--no-swap-after-final", action="store_true", help="最后一块盘后不执行换盘 G-code。")
|
||||
build.add_argument("--metadata-mode", choices=("source", "sum"), default="sum", help="如何写入 slice_info 的 prediction 和 weight。")
|
||||
build.add_argument("--line-ending", choices=("lf", "crlf"), default="crlf", help="生成 G-code 的换行符。")
|
||||
build.add_argument("--zip-level", type=int, choices=range(1, 10), default=DEFAULT_ZIP_COMPRESS_LEVEL, metavar="1-9", help="输出 3MF 的 zlib-ng Deflate 压缩级别。默认值:7。")
|
||||
build.add_argument("--no-preview-label", action="store_true", help="不重写预览图标签/合成图。")
|
||||
build.add_argument("--no-gcode-patches", action="store_true", help=f"不应用来自 {default_patch_config_path()} 的可编辑补丁。")
|
||||
# 绑定命令处理函数
|
||||
build.set_defaults(func=build_command)
|
||||
|
||||
# ---- list-swap-gcode 子命令 ----
|
||||
list_cmd = subparsers.add_parser("list-swap-gcode", help="列出 swap_gcode 目录中的文件。")
|
||||
list_cmd.add_argument("--swap-gcode-dir", default=None, help=f"模板目录。默认值:{default_swap_gcode_dir()}")
|
||||
list_cmd.set_defaults(func=list_swap_gcode_command)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""命令行入口函数:解析参数并分发到对应的子命令处理函数。
|
||||
|
||||
Args:
|
||||
argv: 命令行参数列表,为 None 时使用 sys.argv
|
||||
|
||||
Returns:
|
||||
int: 程序退出码,0 表示成功,1 表示失败
|
||||
"""
|
||||
# 构建解析器并解析参数
|
||||
parser = create_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
# 根据子命令调用绑定的处理函数
|
||||
return int(args.func(args))
|
||||
except Exception as exc:
|
||||
# 异常信息输出到 stderr,返回非零退出码
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+259
-132
@@ -58,6 +58,7 @@ 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 .i18n import t, switch as i18n_switch, current_locale
|
||||
from .batch import IndividualBuildTask, individual_batch_worker_count, run_individual_batch_builds
|
||||
from .core import (
|
||||
BuildOptions,
|
||||
@@ -349,6 +350,11 @@ class MainWindow(QMainWindow):
|
||||
self.load_swap_gcode_to_combo()
|
||||
self.restore_settings_to_ui()
|
||||
self.connect_option_signals()
|
||||
# 确保语言状态与保存的 locale 一致
|
||||
saved_locale = self.language_combo.currentData()
|
||||
if saved_locale:
|
||||
i18n_switch(saved_locale)
|
||||
self._refresh_ui_texts()
|
||||
self._loading_settings = False
|
||||
self.update_total_summary()
|
||||
self.update_output_preview()
|
||||
@@ -392,10 +398,22 @@ class MainWindow(QMainWindow):
|
||||
root.setSpacing(4)
|
||||
self.root_layout = root
|
||||
|
||||
# ---- 顶部栏:语言选择(右上角) ----
|
||||
top_bar = QHBoxLayout()
|
||||
top_bar.addStretch(1)
|
||||
self.language_label = QLabel(t("gui.options.language") + ":")
|
||||
self.language_combo = QComboBox()
|
||||
self.language_combo.addItem("简体中文", "zh_CN")
|
||||
self.language_combo.addItem("English", "en")
|
||||
self.language_combo.setFixedWidth(120)
|
||||
top_bar.addWidget(self.language_label)
|
||||
top_bar.addWidget(self.language_combo)
|
||||
root.addLayout(top_bar)
|
||||
|
||||
# =====================================================================
|
||||
# --- 文件输入区 ---
|
||||
# =====================================================================
|
||||
file_group = QGroupBox("输入 3MF 文件")
|
||||
file_group = QGroupBox(t("gui.file_group.title"))
|
||||
self.file_group = file_group
|
||||
file_layout = QVBoxLayout(file_group)
|
||||
file_body = QHBoxLayout()
|
||||
@@ -404,7 +422,7 @@ class MainWindow(QMainWindow):
|
||||
table_layout = QVBoxLayout()
|
||||
self.table = DropTableWidget(self.add_paths, self.remove_selected)
|
||||
self.table.setColumnCount(5)
|
||||
self.table.setHorizontalHeaderLabels(["顺序", "3MF 文件", "份数", "时间", "耗材"])
|
||||
self.table.setHorizontalHeaderLabels([t("gui.file_group.table.header.order"), t("gui.file_group.table.header.file"), t("gui.file_group.table.header.copies"), t("gui.file_group.table.header.time"), t("gui.file_group.table.header.filament")])
|
||||
self.table.horizontalHeader().setSectionResizeMode(ORDER_COLUMN, QHeaderView.ResizeToContents)
|
||||
self.table.horizontalHeader().setSectionResizeMode(FILE_COLUMN, QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().setSectionResizeMode(COPIES_COLUMN, QHeaderView.ResizeToContents)
|
||||
@@ -416,20 +434,20 @@ class MainWindow(QMainWindow):
|
||||
self.table.setMinimumHeight(120)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
self.table.setToolTip("将 .3mf 文件或文件夹拖放到此处。文件夹将添加所有顶层 .3mf 文件。")
|
||||
self.table.setToolTip(t("gui.file_group.table.tooltip"))
|
||||
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")
|
||||
self.total_summary_label = QLabel(t("gui.file_group.total_summary.empty"))
|
||||
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.preview_group = QGroupBox(t("gui.preview.title"))
|
||||
preview_layout = QVBoxLayout(self.preview_group)
|
||||
self.thumbnail_label = QLabel(t("gui.preview.select_hint"))
|
||||
self.thumbnail_label.setAlignment(Qt.AlignCenter)
|
||||
self.thumbnail_label.setFixedSize(240, 170)
|
||||
self.thumbnail_label.setStyleSheet(
|
||||
@@ -447,16 +465,16 @@ class MainWindow(QMainWindow):
|
||||
preview_layout.addWidget(self.thumbnail_label)
|
||||
preview_layout.addWidget(self.thumbnail_name_label)
|
||||
preview_layout.addStretch(1)
|
||||
file_body.addWidget(preview_group)
|
||||
file_body.addWidget(self.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.add_button = QPushButton(t("gui.button.add_3mf"))
|
||||
self.remove_button = QPushButton(t("gui.button.remove"))
|
||||
self.remove_all_button = QPushButton(t("gui.button.remove_all"))
|
||||
self.apply_default_copies_button = QPushButton(t("gui.button.apply_copies"))
|
||||
self.build_button = QPushButton(t("gui.button.build"))
|
||||
self.build_button.setMinimumHeight(48)
|
||||
self.build_button.setMinimumWidth(160)
|
||||
build_font = self.build_button.font()
|
||||
@@ -464,13 +482,13 @@ class MainWindow(QMainWindow):
|
||||
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.add_button.clicked.connect(self.add_files)
|
||||
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.build_button.clicked.connect(self.build_output)
|
||||
|
||||
for button in (add_button, remove_button, remove_all_button, apply_default_copies_button):
|
||||
for button in (self.add_button, self.remove_button, self.remove_all_button, self.apply_default_copies_button):
|
||||
button.setMinimumHeight(32)
|
||||
file_buttons.addWidget(button)
|
||||
file_buttons.addStretch(1)
|
||||
@@ -481,8 +499,8 @@ class MainWindow(QMainWindow):
|
||||
# =====================================================================
|
||||
# --- 打包选项区 ---
|
||||
# =====================================================================
|
||||
options_group = QGroupBox("打包选项")
|
||||
grid = QGridLayout(options_group)
|
||||
self.options_group = QGroupBox(t("gui.options.group.title"))
|
||||
grid = QGridLayout(self.options_group)
|
||||
grid.setColumnStretch(1, 1)
|
||||
grid.setColumnStretch(3, 1)
|
||||
grid.setHorizontalSpacing(16)
|
||||
@@ -493,12 +511,12 @@ class MainWindow(QMainWindow):
|
||||
self.swap_gcode_combo = QComboBox()
|
||||
self.swap_gcode_combo.setMinimumWidth(240)
|
||||
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.refresh_button = QPushButton(t("gui.options.refresh"))
|
||||
self.open_folder_button = QPushButton(t("gui.options.open_folder"))
|
||||
self.refresh_button.setFixedWidth(self.refresh_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.open_folder_button.clicked.connect(self.open_swap_gcode_folder)
|
||||
|
||||
# 默认份数
|
||||
self.default_copies_edit = QLineEdit("1")
|
||||
@@ -506,7 +524,7 @@ class MainWindow(QMainWindow):
|
||||
self.default_copies_edit.setFixedWidth(64)
|
||||
|
||||
# 热床降温
|
||||
self.bed_cooldown_check = QCheckBox("等待热床降温")
|
||||
self.bed_cooldown_check = QCheckBox(t("gui.options.bed_cooldown"))
|
||||
self.bed_cooldown_check.setChecked(True)
|
||||
self.cool_bed_edit = QLineEdit("45")
|
||||
self.cool_bed_edit.setValidator(QIntValidator(0, 120))
|
||||
@@ -514,11 +532,12 @@ class MainWindow(QMainWindow):
|
||||
bed_row = QHBoxLayout()
|
||||
bed_row.addWidget(self.bed_cooldown_check)
|
||||
bed_row.addWidget(self.cool_bed_edit)
|
||||
bed_row.addWidget(QLabel("°C"))
|
||||
self.bed_cooldown_suffix_label = QLabel(t("gui.options.bed_cooldown.suffix"))
|
||||
bed_row.addWidget(self.bed_cooldown_suffix_label)
|
||||
bed_row.addStretch(1)
|
||||
|
||||
# 换盘后等待时间(勾选后启用,不勾选则不插入等待指令)
|
||||
self.eject_wait_check = QCheckBox("换盘后等待")
|
||||
self.eject_wait_check = QCheckBox(t("gui.options.eject_wait"))
|
||||
self.eject_wait_check.setChecked(True)
|
||||
self.wait_edit = QLineEdit("30")
|
||||
self.wait_edit.setValidator(QIntValidator(0, 3600))
|
||||
@@ -526,70 +545,71 @@ class MainWindow(QMainWindow):
|
||||
eject_wait_row = QHBoxLayout()
|
||||
eject_wait_row.addWidget(self.eject_wait_check)
|
||||
eject_wait_row.addWidget(self.wait_edit)
|
||||
eject_wait_row.addWidget(QLabel("秒"))
|
||||
self.wait_suffix_label = QLabel(t("gui.options.wait_suffix"))
|
||||
eject_wait_row.addWidget(self.wait_suffix_label)
|
||||
eject_wait_row.addStretch(1)
|
||||
|
||||
# 剩余时间板号显示
|
||||
self.show_plate_number_check = QCheckBox("在剩余时间的百位显示当前板号")
|
||||
self.show_plate_number_check = QCheckBox(t("gui.options.plate_number"))
|
||||
self.show_plate_number_check.setChecked(True)
|
||||
|
||||
# 最后换盘
|
||||
self.swap_final_check = QCheckBox("最后一块盘后也执行换盘 G-code")
|
||||
self.swap_final_check = QCheckBox(t("gui.options.final_swap"))
|
||||
self.swap_final_check.setChecked(True)
|
||||
|
||||
# G-code 补丁
|
||||
self.patch_check = QCheckBox("应用可编辑的 G-code 补丁")
|
||||
self.patch_check.setToolTip("使用 gcode_patches.ini")
|
||||
self.patch_check = QCheckBox(t("gui.options.gcode_patches"))
|
||||
self.patch_check.setToolTip(t("gui.options.gcode_patches.tooltip"))
|
||||
self.patch_check.setChecked(True)
|
||||
patch_row = QHBoxLayout()
|
||||
open_patch_button = QPushButton("打开配置文件")
|
||||
open_patch_button.clicked.connect(self.open_patch_config)
|
||||
self.open_patch_button = QPushButton(t("gui.options.open_config"))
|
||||
self.open_patch_button.clicked.connect(self.open_patch_config)
|
||||
patch_row.addWidget(self.patch_check)
|
||||
patch_row.addWidget(open_patch_button)
|
||||
patch_row.addWidget(self.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.addItem(t("gui.options.metadata.source"), "source")
|
||||
self.metadata_combo.addItem(t("gui.options.metadata.sum"), "sum")
|
||||
self.metadata_combo.setFixedWidth(220)
|
||||
|
||||
# ZIP 压缩级别
|
||||
self.zip_level_combo = QComboBox()
|
||||
for level in range(1, 10):
|
||||
self.zip_level_combo.addItem(f"级别 {level}", level)
|
||||
self.zip_level_combo.addItem(t("gui.options.zip_compression.level_format", level=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.zip_level_combo.setToolTip(t("gui.options.zip_compression.tooltip"))
|
||||
|
||||
# 独立批处理模式
|
||||
self.individual_batch_check = QCheckBox("独立批处理模式")
|
||||
self.individual_batch_check = QCheckBox(t("gui.options.batch_mode"))
|
||||
self.individual_batch_check.setToolTip(
|
||||
"将每行输入构建为独立的输出文件,使用该行的份数。"
|
||||
t("gui.options.batch_mode.tooltip")
|
||||
)
|
||||
|
||||
# 构建后清空 / 跳过重复
|
||||
self.clear_after_build_check = QCheckBox("构建成功后清空输入列表")
|
||||
self.clear_after_build_check = QCheckBox(t("gui.options.clear_after_build"))
|
||||
self.clear_after_build_check.setChecked(False)
|
||||
self.skip_duplicates_check = QCheckBox("添加输入时跳过重复文件路径")
|
||||
self.skip_duplicates_check = QCheckBox(t("gui.options.skip_duplicates"))
|
||||
self.skip_duplicates_check.setChecked(True)
|
||||
|
||||
# 标签列固定宽度(左对齐),控件列自动对齐(stretch=1 平分空间)
|
||||
grid.setColumnMinimumWidth(0, 120)
|
||||
grid.setColumnMinimumWidth(2, 90)
|
||||
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),
|
||||
(t("gui.options.swap_gcode.label"), 0, 0),
|
||||
(t("gui.options.default_copies"), 1, 0),
|
||||
(t("gui.options.eject_wait.label"), 2, 0),
|
||||
(t("gui.options.final_swap.label"), 3, 0),
|
||||
(t("gui.options.metadata.label"), 4, 0),
|
||||
(t("gui.options.batch_mode.label"), 5, 0),
|
||||
(t("gui.options.bed_cooldown.label"), 1, 2),
|
||||
(t("gui.options.plate_number.label"), 2, 2),
|
||||
(t("gui.options.gcode_patches.label"), 3, 2),
|
||||
(t("gui.options.zip_compression.label"), 4, 2),
|
||||
(t("gui.options.input_handling"), 5, 2),
|
||||
(t("gui.options.clear_after_build.label"), 6, 0),
|
||||
]:
|
||||
label = QLabel(label_text)
|
||||
label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
@@ -598,8 +618,8 @@ class MainWindow(QMainWindow):
|
||||
# 第 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.addWidget(self.refresh_button)
|
||||
swap_btn_row.addWidget(self.open_folder_button)
|
||||
swap_btn_row.addStretch(1)
|
||||
grid.addLayout(swap_btn_row, 0, 3)
|
||||
|
||||
@@ -625,45 +645,50 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# 第 6 行:构建成功后清空输入列表(独立一行)
|
||||
grid.addWidget(self.clear_after_build_check, 6, 1, 1, 3)
|
||||
root.addWidget(options_group)
|
||||
|
||||
root.addWidget(self.options_group)
|
||||
|
||||
# =====================================================================
|
||||
# --- 输出区 ---
|
||||
# =====================================================================
|
||||
output_group = QGroupBox("输出")
|
||||
output_layout = QFormLayout(output_group)
|
||||
self.output_group = QGroupBox(t("gui.output.group.title"))
|
||||
output_layout = QFormLayout(self.output_group)
|
||||
|
||||
# 输出目录
|
||||
output_dir_row = QHBoxLayout()
|
||||
self.output_dir_edit = QLineEdit()
|
||||
self.output_dir_edit.setPlaceholderText("留空则写入输入文件所在目录")
|
||||
self.output_dir_edit.setPlaceholderText(t("gui.output.dir.placeholder"))
|
||||
self.output_dir_edit.setMinimumWidth(240)
|
||||
self.output_dir_edit.setMaximumWidth(520)
|
||||
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)
|
||||
self.browse_output_dir_button = QPushButton(t("gui.output.dir.browse"))
|
||||
self.browse_output_dir_button.setFixedWidth(self.browse_output_dir_button.sizeHint().width())
|
||||
self.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)
|
||||
output_dir_row.addWidget(self.browse_output_dir_button)
|
||||
self.output_dir_label = QLabel(t("gui.output.dir.label"))
|
||||
output_layout.addRow(self.output_dir_label, 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.setPlaceholderText(t("gui.output.filename_rule.placeholder"))
|
||||
self.output_name_edit.setMinimumWidth(240)
|
||||
self.output_name_edit.setMaximumWidth(520)
|
||||
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)
|
||||
self.output_rule_help_button = QPushButton(t("gui.output.filename_rule.help"))
|
||||
self.output_rule_help_button.setFixedWidth(34)
|
||||
self.output_rule_help_button.setToolTip(t("gui.output.filename_rule.help.tooltip"))
|
||||
self.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)
|
||||
filename_rule_row.addWidget(self.output_rule_help_button)
|
||||
self.output_filename_rule_label = QLabel(t("gui.output.filename_rule"))
|
||||
output_layout.addRow(self.output_filename_rule_label, filename_rule_row)
|
||||
|
||||
# 输出预览
|
||||
self.output_preview_label = QLabel("-")
|
||||
output_layout.addRow("预览", self.output_preview_label)
|
||||
root.addWidget(output_group)
|
||||
self.output_preview_label_title = QLabel(t("gui.output.preview"))
|
||||
output_layout.addRow(self.output_preview_label_title, self.output_preview_label)
|
||||
|
||||
root.addWidget(self.output_group)
|
||||
|
||||
# =====================================================================
|
||||
# --- 日志区 ---
|
||||
@@ -725,6 +750,7 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
self.language_combo.currentIndexChanged.connect(self.on_language_changed)
|
||||
|
||||
def restore_settings_to_ui(self) -> None:
|
||||
"""从加载的设置字典恢复所有 UI 控件的状态。"""
|
||||
@@ -742,6 +768,10 @@ class MainWindow(QMainWindow):
|
||||
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)))
|
||||
saved_locale = str(options.get("locale", "zh_CN"))
|
||||
locale_index = self.language_combo.findData(saved_locale)
|
||||
if locale_index >= 0:
|
||||
self.language_combo.setCurrentIndex(locale_index)
|
||||
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")
|
||||
@@ -780,6 +810,7 @@ class MainWindow(QMainWindow):
|
||||
"individual_batch_mode": self.individual_batch_check.isChecked(),
|
||||
"clear_after_build": self.clear_after_build_check.isChecked(),
|
||||
"skip_duplicates": self.skip_duplicates_check.isChecked(),
|
||||
"locale": self.language_combo.currentData() or "zh_CN",
|
||||
"output_directory": self.output_dir_edit.text().strip(),
|
||||
"output_filename_rule": self.output_name_edit.text().strip() or DEFAULT_OUTPUT_PATTERN,
|
||||
}
|
||||
@@ -806,30 +837,16 @@ class MainWindow(QMainWindow):
|
||||
if state != 0:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"独立批处理模式",
|
||||
"独立批处理模式将每一行输入作为独立的构建任务。\n\n"
|
||||
"示例:如果你添加了 20 个单盘 3MF 文件并设置份数为 5,"
|
||||
"点击“构建 3MF”将创建 20 个独立的打包文件。"
|
||||
"每个输出文件仅包含该源文件重复 5 次的内容。\n\n"
|
||||
"这个功能适用于快速将大量独立的 3MF 作业批量转换为多份数的 "
|
||||
"SwapMod 包。它不会将所有输入文件合并到一个 3MF 中。",
|
||||
t("gui.dialog.individual_batch.title"),
|
||||
t("gui.dialog.individual_batch.text"),
|
||||
)
|
||||
|
||||
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"
|
||||
"在独立批处理模式下,这些标记会针对每行输入单独计算。",
|
||||
t("gui.dialog.filename_help.title"),
|
||||
t("gui.dialog.filename_help.text"),
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
@@ -853,7 +870,7 @@ class MainWindow(QMainWindow):
|
||||
if index >= 0:
|
||||
self.swap_gcode_combo.setCurrentIndex(index)
|
||||
if not files:
|
||||
self.log.append(f"在 {default_swap_gcode_dir()} 中未找到换盘 G-code 文件")
|
||||
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)。"""
|
||||
@@ -876,7 +893,7 @@ class MainWindow(QMainWindow):
|
||||
"""打开 G-code 补丁配置文件。"""
|
||||
path = default_patch_config_path()
|
||||
if not path.exists():
|
||||
QMessageBox.information(self, APP_NAME, f"补丁配置文件不存在:\n{path}")
|
||||
QMessageBox.information(self, t("gui.dialog.warning.title"), t("gui.dialog.patch_config_not_found", path=str(path)))
|
||||
return
|
||||
self.open_path(path)
|
||||
|
||||
@@ -886,7 +903,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def add_files(self) -> None:
|
||||
"""通过文件对话框添加 3MF 文件到输入列表。"""
|
||||
files, _ = QFileDialog.getOpenFileNames(self, "添加 3MF 文件", "", "3MF 文件 (*.3mf);;所有文件 (*)")
|
||||
files, _ = QFileDialog.getOpenFileNames(self, t("gui.dialog.add_files_title"), "", f'{t("gui.dialog.file_filter.3mf")};;{t("gui.dialog.file_filter.all")}')
|
||||
self.add_paths([Path(file_name) for file_name in files])
|
||||
|
||||
def create_order_widget(self, row: int) -> QWidget:
|
||||
@@ -902,7 +919,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
up_button = QToolButton()
|
||||
up_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp))
|
||||
up_button.setToolTip("上移")
|
||||
up_button.setToolTip(t("gui.file_group.table.move_up"))
|
||||
up_button.setAutoRaise(True)
|
||||
up_button.setFixedSize(22, 22)
|
||||
up_button.setEnabled(row > 0)
|
||||
@@ -910,7 +927,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
down_button = QToolButton()
|
||||
down_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
|
||||
down_button.setToolTip("下移")
|
||||
down_button.setToolTip(t("gui.file_group.table.move_down"))
|
||||
down_button.setAutoRaise(True)
|
||||
down_button.setFixedSize(28, 28)
|
||||
down_button.setEnabled(row < self.table.rowCount() - 1)
|
||||
@@ -968,18 +985,18 @@ class MainWindow(QMainWindow):
|
||||
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_label.setText(t("gui.preview.select_hint"))
|
||||
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.setText(t("gui.preview.unavailable"))
|
||||
self.thumbnail_label.setToolTip(str(exc))
|
||||
return
|
||||
if pixmap is None:
|
||||
self.thumbnail_label.setText("无预览图")
|
||||
self.thumbnail_label.setText(t("gui.preview.no_image"))
|
||||
self.thumbnail_label.setToolTip("")
|
||||
return
|
||||
scaled = pixmap.scaled(self.thumbnail_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
@@ -1018,7 +1035,7 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
skipped += 1
|
||||
if added or skipped:
|
||||
self.log.append(f"已添加 {added} 个输入文件。跳过 {skipped} 个。")
|
||||
self.log.append(t("gui.log.added_files", added=added, skipped=skipped))
|
||||
self.update_total_summary()
|
||||
self.update_output_preview()
|
||||
|
||||
@@ -1028,7 +1045,7 @@ class MainWindow(QMainWindow):
|
||||
try:
|
||||
summary = read_3mf_summary(path)
|
||||
except Exception as exc:
|
||||
self.log.append(f"已跳过 {path}:{exc}")
|
||||
self.log.append(t("gui.log.skipped_file", path=str(path), reason=str(exc)))
|
||||
return False
|
||||
row = self.table.rowCount()
|
||||
self._updating_table = True
|
||||
@@ -1072,11 +1089,11 @@ class MainWindow(QMainWindow):
|
||||
|
||||
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)}"
|
||||
)
|
||||
return "\n".join((
|
||||
t("gui.file_group.table.tooltip_base_plates", count=summary.plate_count),
|
||||
t("gui.file_group.table.tooltip_base_time", time=format_duration(summary.prediction_seconds)),
|
||||
t("gui.file_group.table.tooltip_base_filament", filament=format_filament(summary.weight_grams, summary.filament_used_m))
|
||||
))
|
||||
|
||||
def get_row_copies(self, row: int) -> int:
|
||||
"""获取指定行的份数值。"""
|
||||
@@ -1237,7 +1254,7 @@ class MainWindow(QMainWindow):
|
||||
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 "")
|
||||
directory = QFileDialog.getExistingDirectory(self, t("gui.dialog.choose_output_dir"), start_dir or "")
|
||||
if directory:
|
||||
self.output_dir_edit.setText(directory)
|
||||
|
||||
@@ -1280,10 +1297,10 @@ class MainWindow(QMainWindow):
|
||||
"""刷新底部合计统计标签。"""
|
||||
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)}"
|
||||
t("gui.file_group.total_summary",
|
||||
plates=summary.plate_count,
|
||||
time=format_duration(summary.prediction_seconds),
|
||||
filament=format_filament(summary.weight_grams, summary.filament_used_m))
|
||||
)
|
||||
|
||||
def update_output_preview(self) -> None:
|
||||
@@ -1300,7 +1317,7 @@ class MainWindow(QMainWindow):
|
||||
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)} 个输出文件"
|
||||
t("gui.output.batch_preview", first=str(first_path), count=len(jobs))
|
||||
)
|
||||
else:
|
||||
output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path)
|
||||
@@ -1316,7 +1333,7 @@ class MainWindow(QMainWindow):
|
||||
"""根据当前 UI 选项构建 BuildOptions 对象。"""
|
||||
swap_gcode_path = self.swap_gcode_combo.currentData()
|
||||
if not swap_gcode_path:
|
||||
raise ValueError("请在 swap_gcode 文件夹中放入至少一个换盘 G-code 文件并选择它。")
|
||||
raise ValueError(t("gui.dialog.no_swap_gcode"))
|
||||
cool_bed_temp = int(self.cool_bed_edit.text() or 45) if self.bed_cooldown_check.isChecked() else None
|
||||
eject_wait_seconds = int(self.wait_edit.text() or 30) if self.eject_wait_check.isChecked() else 0
|
||||
return BuildOptions(
|
||||
@@ -1333,11 +1350,11 @@ class MainWindow(QMainWindow):
|
||||
|
||||
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}")
|
||||
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_success_toast(self, message: str) -> None:
|
||||
"""显示构建成功的浮动提示条。"""
|
||||
@@ -1347,7 +1364,7 @@ class MainWindow(QMainWindow):
|
||||
"""主构建入口:根据批处理模式分发到合并构建或独立构建。"""
|
||||
jobs = self.collect_jobs()
|
||||
if not jobs:
|
||||
QMessageBox.warning(self, APP_NAME, "请至少添加一个 3MF 文件。")
|
||||
QMessageBox.warning(self, t("gui.dialog.warning.title"), t("gui.dialog.no_files"))
|
||||
return
|
||||
try:
|
||||
self.save_current_settings()
|
||||
@@ -1356,8 +1373,8 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
self.build_combined_output(jobs)
|
||||
except Exception as exc:
|
||||
QMessageBox.critical(self, APP_NAME, str(exc))
|
||||
self.log.append(f"错误:{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:
|
||||
@@ -1366,7 +1383,7 @@ class MainWindow(QMainWindow):
|
||||
options = self.build_options_for_output(output_path)
|
||||
result = build_packed_3mf(jobs, options)
|
||||
self.log_build_result(result)
|
||||
self.show_success_toast("已创建打包的 3MF 文件。")
|
||||
self.show_success_toast(t("gui.toast.combined_success"))
|
||||
if self.clear_after_build_check.isChecked():
|
||||
self.remove_all()
|
||||
|
||||
@@ -1385,31 +1402,31 @@ 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(f"构建 {job.source_3mf} 时出错:{exc}")
|
||||
self.log.append(t("gui.log.build_error", path=str(job.source_3mf), error=str(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)} 个独立输出。")
|
||||
self.log.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)
|
||||
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}")
|
||||
self.log.append(t("gui.log.build_error", path=str(failure.job.source_3mf), error=str(failure.error)))
|
||||
|
||||
if errors:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
APP_NAME,
|
||||
f"已构建 {success_count} 个文件,但有 {len(errors)} 个文件失败。\n\n"
|
||||
t("gui.dialog.warning.title"),
|
||||
t("gui.dialog.batch_partial_success", success=success_count, failed=len(errors))
|
||||
+ "\n".join(errors[:8])
|
||||
+ ("\n..." if len(errors) > 8 else ""),
|
||||
)
|
||||
return
|
||||
self.show_success_toast(f"已创建 {success_count} 个打包的 3MF 文件。")
|
||||
self.show_success_toast(t("gui.toast.batch_success", count=success_count))
|
||||
if self.clear_after_build_check.isChecked():
|
||||
self.remove_all()
|
||||
|
||||
@@ -1436,6 +1453,116 @@ class MainWindow(QMainWindow):
|
||||
self.save_current_settings()
|
||||
super().closeEvent(event)
|
||||
|
||||
def on_language_changed(self) -> None:
|
||||
"""语言切换时重新加载翻译并刷新 UI 文本。"""
|
||||
new_locale = self.language_combo.currentData()
|
||||
if new_locale:
|
||||
i18n_switch(new_locale)
|
||||
self._refresh_ui_texts()
|
||||
self.save_current_settings()
|
||||
|
||||
def _refresh_ui_texts(self) -> None:
|
||||
"""刷新所有可通过 t() 获取的 UI 文本。"""
|
||||
# 文件输入区
|
||||
self.file_group.setTitle(t("gui.file_group.title"))
|
||||
self.table.setHorizontalHeaderLabels([
|
||||
t("gui.file_group.table.header.order"),
|
||||
t("gui.file_group.table.header.file"),
|
||||
t("gui.file_group.table.header.copies"),
|
||||
t("gui.file_group.table.header.time"),
|
||||
t("gui.file_group.table.header.filament"),
|
||||
])
|
||||
self.table.setToolTip(t("gui.file_group.table.tooltip"))
|
||||
|
||||
# 缩略图区
|
||||
self.preview_group.setTitle(t("gui.preview.title"))
|
||||
if self.thumbnail_label.text() and not self.thumbnail_label.pixmap():
|
||||
self.thumbnail_label.setText(t("gui.preview.select_hint"))
|
||||
|
||||
# 按钮
|
||||
self.add_button.setText(t("gui.button.add_3mf"))
|
||||
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.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.refresh_button.setFixedWidth(self.refresh_button.sizeHint().width())
|
||||
self.open_folder_button.setFixedWidth(self.open_folder_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"))
|
||||
self.swap_final_check.setText(t("gui.options.final_swap"))
|
||||
self.patch_check.setText(t("gui.options.gcode_patches"))
|
||||
self.patch_check.setToolTip(t("gui.options.gcode_patches.tooltip"))
|
||||
self.open_patch_button.setText(t("gui.options.open_config"))
|
||||
self.individual_batch_check.setText(t("gui.options.batch_mode"))
|
||||
self.individual_batch_check.setToolTip(t("gui.options.batch_mode.tooltip"))
|
||||
self.clear_after_build_check.setText(t("gui.options.clear_after_build"))
|
||||
self.skip_duplicates_check.setText(t("gui.options.skip_duplicates"))
|
||||
self.bed_cooldown_suffix_label.setText(t("gui.options.bed_cooldown.suffix"))
|
||||
self.wait_suffix_label.setText(t("gui.options.wait_suffix"))
|
||||
|
||||
# 元数据下拉框
|
||||
self.metadata_combo.setItemText(0, t("gui.options.metadata.source"))
|
||||
self.metadata_combo.setItemText(1, t("gui.options.metadata.sum"))
|
||||
# ZIP 压缩下拉框
|
||||
for level in range(1, 10):
|
||||
self.zip_level_combo.setItemText(level - 1, t("gui.options.zip_compression.level_format", level=level))
|
||||
self.zip_level_combo.setToolTip(t("gui.options.zip_compression.tooltip"))
|
||||
|
||||
# 网格标签遍历
|
||||
self._refresh_grid_labels()
|
||||
|
||||
# 输出区
|
||||
self.output_group.setTitle(t("gui.output.group.title"))
|
||||
self.output_dir_edit.setPlaceholderText(t("gui.output.dir.placeholder"))
|
||||
self.browse_output_dir_button.setText(t("gui.output.dir.browse"))
|
||||
self.browse_output_dir_button.setFixedWidth(self.browse_output_dir_button.sizeHint().width())
|
||||
self.output_name_edit.setPlaceholderText(t("gui.output.filename_rule.placeholder"))
|
||||
self.output_rule_help_button.setToolTip(t("gui.output.filename_rule.help.tooltip"))
|
||||
self.output_dir_label.setText(t("gui.output.dir.label"))
|
||||
self.output_filename_rule_label.setText(t("gui.output.filename_rule"))
|
||||
self.output_preview_label_title.setText(t("gui.output.preview"))
|
||||
|
||||
# 语言标签
|
||||
self.language_label.setText(t("gui.options.language") + ":")
|
||||
|
||||
# 动态文本刷新
|
||||
self.update_total_summary()
|
||||
self.update_output_preview()
|
||||
self.update_thumbnail_preview()
|
||||
|
||||
def _refresh_grid_labels(self) -> None:
|
||||
"""遍历打包选项网格,刷新所有 QLabel 文本。"""
|
||||
grid = self.options_group.layout()
|
||||
key_map = {
|
||||
(0, 0): "gui.options.swap_gcode.label",
|
||||
(1, 0): "gui.options.default_copies",
|
||||
(1, 2): "gui.options.bed_cooldown.label",
|
||||
(2, 0): "gui.options.eject_wait.label",
|
||||
(2, 2): "gui.options.plate_number.label",
|
||||
(3, 0): "gui.options.final_swap.label",
|
||||
(3, 2): "gui.options.gcode_patches.label",
|
||||
(4, 0): "gui.options.metadata.label",
|
||||
(4, 2): "gui.options.zip_compression.label",
|
||||
(5, 0): "gui.options.batch_mode.label",
|
||||
(5, 2): "gui.options.input_handling",
|
||||
(6, 0): "gui.options.clear_after_build.label",
|
||||
}
|
||||
for i in range(grid.count()):
|
||||
item = grid.itemAt(i)
|
||||
if item.widget() and isinstance(item.widget(), QLabel):
|
||||
label = item.widget()
|
||||
row, col, _, _ = grid.getItemPosition(i)
|
||||
key = key_map.get((row, col))
|
||||
if key:
|
||||
label.setText(t(key))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""应用程序入口:初始化 QApplication,创建并显示主窗口,进入事件循环。"""
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""国际化模块:提供 t(key, **kwargs) 翻译函数,支持语言切换。
|
||||
|
||||
模块导入时自动加载 zh_CN 作为默认语言。
|
||||
通过 switch(locale) 切换语言,t() 返回当前语言的翻译文本。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_I18N_DIR = Path(__file__).parent
|
||||
_translations: dict[str, str] = {}
|
||||
_current_locale: str = "zh_CN"
|
||||
|
||||
|
||||
def load(locale: str) -> None:
|
||||
"""加载指定语言的翻译文件。"""
|
||||
global _translations, _current_locale
|
||||
_current_locale = locale
|
||||
path = _I18N_DIR / f"{locale}.json"
|
||||
if path.exists():
|
||||
_translations = json.loads(path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
_translations = {}
|
||||
|
||||
|
||||
def switch(locale: str) -> None:
|
||||
"""切换当前语言并返回新语言代码。"""
|
||||
load(locale)
|
||||
|
||||
|
||||
def current_locale() -> str:
|
||||
"""返回当前语言代码。"""
|
||||
return _current_locale
|
||||
|
||||
|
||||
def t(key: str, **kwargs: object) -> str:
|
||||
"""获取翻译文本,支持 {name} 插值。
|
||||
|
||||
如果 key 不存在,返回 key 本身作为回退。
|
||||
"""
|
||||
text = str(_translations.get(key, key))
|
||||
if kwargs:
|
||||
text = text.format(**{k: v for k, v in kwargs.items()})
|
||||
return text
|
||||
|
||||
|
||||
# 模块导入时自动加载默认中文翻译
|
||||
load("zh_CN")
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"_lang": "English",
|
||||
|
||||
"gui.file_group.title": "Input 3MF Files",
|
||||
"gui.file_group.table.header.order": "Order",
|
||||
"gui.file_group.table.header.file": "3MF File",
|
||||
"gui.file_group.table.header.copies": "Copies",
|
||||
"gui.file_group.table.header.time": "Time",
|
||||
"gui.file_group.table.header.filament": "Filament",
|
||||
"gui.file_group.table.tooltip": "Drag .3mf files or folders here. Folders add all top-level .3mf files.",
|
||||
"gui.file_group.table.move_up": "Move up",
|
||||
"gui.file_group.table.move_down": "Move down",
|
||||
"gui.file_group.table.tooltip_base_plates": "Base plates: {count}",
|
||||
"gui.file_group.table.tooltip_base_time": "Base time: {time}",
|
||||
"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",
|
||||
"gui.options.open_folder": "Open Folder",
|
||||
"gui.options.default_copies": "Default copies for new inputs",
|
||||
"gui.options.bed_cooldown": "Wait for bed cooldown",
|
||||
"gui.options.bed_cooldown.label": "Bed cooldown",
|
||||
"gui.options.bed_cooldown.suffix": "°C",
|
||||
"gui.options.eject_wait": "Wait after swap",
|
||||
"gui.options.eject_wait.label": "Wait after swap",
|
||||
"gui.options.wait_suffix": "sec",
|
||||
"gui.options.plate_number": "Show current plate in the hundreds digit of remaining time",
|
||||
"gui.options.plate_number.label": "Plate number",
|
||||
"gui.options.final_swap": "Run swap G-code after the last plate",
|
||||
"gui.options.final_swap.label": "Final swap",
|
||||
"gui.options.gcode_patches": "Apply editable G-code patches",
|
||||
"gui.options.gcode_patches.label": "G-code patches",
|
||||
"gui.options.gcode_patches.tooltip": "Uses gcode_patches.ini",
|
||||
"gui.options.open_config": "Open Config",
|
||||
"gui.options.metadata.label": "3MF metadata",
|
||||
"gui.options.metadata.source": "Keep source prediction and weight",
|
||||
"gui.options.metadata.sum": "Sum prediction and filament",
|
||||
"gui.options.zip_compression.label": "ZIP compression",
|
||||
"gui.options.zip_compression.level_format": "Level {level}",
|
||||
"gui.options.zip_compression.tooltip": "zlib-ng Deflate compression level for the output 3MF.",
|
||||
"gui.options.batch_mode": "Individual batch mode",
|
||||
"gui.options.batch_mode.label": "Batch mode",
|
||||
"gui.options.batch_mode.tooltip": "Build each input row as a separate output file, using that row's copy count.",
|
||||
"gui.options.clear_after_build": "Clear input list after successful build",
|
||||
"gui.options.clear_after_build.label": "After build",
|
||||
"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",
|
||||
"gui.output.dir.browse": "Browse",
|
||||
"gui.output.filename_rule": "Output filename rule",
|
||||
"gui.output.filename_rule.placeholder": "Use tokens such as {source}, {sources}, {plates}, {copies}, {date}, {time}",
|
||||
"gui.output.filename_rule.help": "?",
|
||||
"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",
|
||||
"gui.dialog.filename_help.text": "Available filename tokens:\n\n{source} - First input file stem, without .3mf\n{sources} - Source name summary. For multiple different inputs, it becomes first_source_and_N_more\n{plates} - Total plate count in the output\n{copies} - Total copy count used for the output\n{date} - Current date as YYYYMMDD\n{time} - Current time as HHMMSS\n\nDefault rule:\n{plates}-Plates-{sources}.3mf\n\nIn individual batch mode, these tokens are calculated separately for each input row.",
|
||||
"gui.dialog.warning.title": "A1 Swap Mod Packer",
|
||||
"gui.dialog.no_files": "Please add at least one 3MF file.",
|
||||
"gui.dialog.no_swap_gcode": "Please put at least one swap G-code file in the swap_gcode folder and select it.",
|
||||
"gui.dialog.patch_config_not_found": "Patch config does not exist yet:\n{path}",
|
||||
"gui.dialog.batch_partial_success": "Built {success} file(s), but {failed} file(s) failed.\n\n",
|
||||
"gui.dialog.choose_output_dir": "Choose output directory",
|
||||
"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}",
|
||||
"gui.log.building_individual": "Building {count} individual output(s) with {workers} worker(s).",
|
||||
"gui.log.output": "Output: {path}",
|
||||
"gui.log.plates": "Plates: {count}",
|
||||
"gui.log.time": "Estimated source time: {time}",
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"_lang": "简体中文",
|
||||
|
||||
"gui.file_group.title": "输入 3MF 文件",
|
||||
"gui.file_group.table.header.order": "顺序",
|
||||
"gui.file_group.table.header.file": "3MF 文件",
|
||||
"gui.file_group.table.header.copies": "份数",
|
||||
"gui.file_group.table.header.time": "时间",
|
||||
"gui.file_group.table.header.filament": "耗材",
|
||||
"gui.file_group.table.tooltip": "将 .3mf 文件或文件夹拖放到此处。文件夹将添加所有顶层 .3mf 文件。",
|
||||
"gui.file_group.table.move_up": "上移",
|
||||
"gui.file_group.table.move_down": "下移",
|
||||
"gui.file_group.table.tooltip_base_plates": "基础板数:{count}",
|
||||
"gui.file_group.table.tooltip_base_time": "基础时间:{time}",
|
||||
"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.default_copies": "新输入的默认份数",
|
||||
"gui.options.bed_cooldown": "等待热床降温",
|
||||
"gui.options.bed_cooldown.label": "热床降温",
|
||||
"gui.options.bed_cooldown.suffix": "°C",
|
||||
"gui.options.eject_wait": "换盘后等待",
|
||||
"gui.options.eject_wait.label": "换盘后等待时间",
|
||||
"gui.options.wait_suffix": "秒",
|
||||
"gui.options.plate_number": "在剩余时间的百位显示当前板号",
|
||||
"gui.options.plate_number.label": "剩余时间板号",
|
||||
"gui.options.final_swap": "最后一块盘后也执行换盘 G-code",
|
||||
"gui.options.final_swap.label": "最后换盘",
|
||||
"gui.options.gcode_patches": "应用可编辑的 G-code 补丁",
|
||||
"gui.options.gcode_patches.label": "G-code 补丁",
|
||||
"gui.options.gcode_patches.tooltip": "使用 gcode_patches.ini",
|
||||
"gui.options.open_config": "打开配置文件",
|
||||
"gui.options.metadata.label": "3MF 元数据",
|
||||
"gui.options.metadata.source": "保留原始预测和重量",
|
||||
"gui.options.metadata.sum": "累加预测和耗材用量",
|
||||
"gui.options.zip_compression.label": "ZIP 压缩",
|
||||
"gui.options.zip_compression.level_format": "级别 {level}",
|
||||
"gui.options.zip_compression.tooltip": "输出 3MF 的 zlib-ng Deflate 压缩级别。",
|
||||
"gui.options.batch_mode": "独立批处理模式",
|
||||
"gui.options.batch_mode.label": "批处理模式",
|
||||
"gui.options.batch_mode.tooltip": "将每行输入构建为独立的输出文件,使用该行的份数。",
|
||||
"gui.options.clear_after_build": "构建成功后清空输入列表",
|
||||
"gui.options.clear_after_build.label": "构建成功后",
|
||||
"gui.options.skip_duplicates": "添加输入时跳过重复文件路径",
|
||||
"gui.options.input_handling": "输入处理",
|
||||
"gui.options.language": "语言",
|
||||
|
||||
"gui.output.group.title": "输出",
|
||||
"gui.output.dir.label": "输出目录",
|
||||
"gui.output.dir.placeholder": "留空则写入输入文件所在目录",
|
||||
"gui.output.dir.browse": "浏览",
|
||||
"gui.output.filename_rule": "输出文件名规则",
|
||||
"gui.output.filename_rule.placeholder": "可使用 {source}、{sources}、{plates}、{copies}、{date}、{time} 等标记",
|
||||
"gui.output.filename_rule.help": "?",
|
||||
"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": "输出文件名规则",
|
||||
"gui.dialog.filename_help.text": "可用的文件名标记:\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在独立批处理模式下,这些标记会针对每行输入单独计算。",
|
||||
"gui.dialog.warning.title": "A1 Swap Mod Packer",
|
||||
"gui.dialog.no_files": "请至少添加一个 3MF 文件。",
|
||||
"gui.dialog.no_swap_gcode": "请在 swap_gcode 文件夹中放入至少一个换盘 G-code 文件并选择它。",
|
||||
"gui.dialog.patch_config_not_found": "补丁配置文件不存在:{path}",
|
||||
"gui.dialog.batch_partial_success": "已构建 {success} 个文件,但有 {failed} 个文件失败。\n\n",
|
||||
"gui.dialog.choose_output_dir": "选择输出目录",
|
||||
"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 文件",
|
||||
"gui.log.building_individual": "正在使用 {workers} 个工作线程构建 {count} 个独立输出。",
|
||||
"gui.log.output": "输出:{path}",
|
||||
"gui.log.plates": "板数:{count}",
|
||||
"gui.log.time": "预估源文件时间:{time}",
|
||||
"gui.log.filament": "预估源文件耗材:{filament}",
|
||||
"gui.log.md5": "G-code MD5:{md5}",
|
||||
"gui.log.build_error": "构建 {path} 时出错:{error}",
|
||||
|
||||
"format.unknown": "未知"
|
||||
}
|
||||
+9
-16
@@ -3,15 +3,16 @@ chcp 65001 >nul
|
||||
setlocal
|
||||
|
||||
set "PY=.venv\Scripts\python.exe"
|
||||
set "DIST=build\onefile"
|
||||
set "DIST=build\a1packer"
|
||||
|
||||
if not exist "%DIST%" mkdir "%DIST%"
|
||||
|
||||
"%PY%" -m nuitka ^
|
||||
--mode=onefile ^
|
||||
--mode=standalone ^
|
||||
--msvc=latest ^
|
||||
--enable-plugin=pyside6 ^
|
||||
--include-qt-plugins=platforms,imageformats,styles,iconengines ^
|
||||
--include-data-dir="a1_swap_mod_packer/i18n=a1_swap_mod_packer/i18n" ^
|
||||
--windows-console-mode=disable ^
|
||||
--output-dir="%DIST%" ^
|
||||
--output-filename=a1packer.exe ^
|
||||
@@ -21,26 +22,18 @@ if errorlevel 1 exit /b %ERRORLEVEL%
|
||||
|
||||
echo.
|
||||
echo GUI build done.
|
||||
echo Next step is building CLI.
|
||||
|
||||
"%PY%" -m nuitka ^
|
||||
--mode=onefile ^
|
||||
--msvc=latest ^
|
||||
--output-dir="%DIST%" ^
|
||||
--output-filename=a1packer-cli.exe ^
|
||||
--remove-output ^
|
||||
run_cli.py
|
||||
if errorlevel 1 exit /b %ERRORLEVEL%
|
||||
|
||||
robocopy "swap_gcode" "%DIST%\swap_gcode" /E
|
||||
set "OUTPUT=%DIST%\a1packer.dist"
|
||||
robocopy "swap_gcode" "%OUTPUT%\swap_gcode" /E
|
||||
if %ERRORLEVEL% GEQ 8 exit /b %ERRORLEVEL%
|
||||
|
||||
copy /Y "gcode_patches.ini" "%DIST%\gcode_patches.ini" >nul
|
||||
copy /Y "gcode_patches.ini" "%OUTPUT%\gcode_patches.ini" >nul
|
||||
if errorlevel 1 exit /b %ERRORLEVEL%
|
||||
|
||||
copy /Y "x.png" "%DIST%\x.png" >nul
|
||||
copy /Y "x.png" "%OUTPUT%\x.png" >nul
|
||||
if errorlevel 1 exit /b %ERRORLEVEL%
|
||||
|
||||
echo.
|
||||
echo Build done: %DIST%
|
||||
echo Build done: %OUTPUT%
|
||||
echo The entire "%OUTPUT%" folder is the portable distribution.
|
||||
exit /b 0
|
||||
|
||||
+8
-52
@@ -151,7 +151,7 @@ G4 P45000 ; 等 45 秒(可选)
|
||||
|
||||
### 合成预览图(可选)
|
||||
|
||||
如果启用了预览(CLI 中默认开启):
|
||||
如果启用了预览:
|
||||
|
||||
1. 从最多 9 个**不同输入文件**中各取一张 `plate_N.png`(优先取活动面板的预览图)
|
||||
2. 按网格拼接(1/2/4/6/9 宫格)
|
||||
@@ -189,66 +189,36 @@ BuildResult(
|
||||
|
||||
---
|
||||
|
||||
## 两种使用方式
|
||||
## 使用方式
|
||||
|
||||
核心构建函数只有一个:`build_packed_3mf(jobs, options)`,CLI 和 GUI 会在调用它之前做不同的准备工作。
|
||||
核心构建函数只有一个:`build_packed_3mf(jobs, options)`,GUI 在调用它之前做不同的准备工作。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph CLI["CLI 入口: run_cli.py → cli.py"]
|
||||
C1["argparse 解析参数"] --> C2["收集 --item / 位置参数"]
|
||||
C2 --> C3["构造 BuildOptions"]
|
||||
C3 --> BUILD["build_packed_3mf()"]
|
||||
end
|
||||
subgraph GUI["GUI 入口: run_gui.py → gui.py"]
|
||||
G1["拖放/添加文件 → 表格"] --> G2["每行: 3MF路径 + 份数"]
|
||||
G2 --> G3["控件值 → BuildOptions"]
|
||||
G3 --> G4{"批处理模式?"}
|
||||
G4 -- 合并 --> BUILD
|
||||
G4 -- 合并 --> BUILD["build_packed_3mf()"]
|
||||
G4 -- 独立 --> G5["逐行调 build_packed_3mf()<br/>ProcessPoolExecutor 并行"]
|
||||
end
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
一条命令搞定。指定输入文件、份数、输出路径和各项参数:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 3 \
|
||||
--swap-gcode "LX_1.02.26.gcode" \
|
||||
--cool-bed 45 \
|
||||
--wait 45 \
|
||||
--show-plate-number \
|
||||
-o "3 Plates - A.3mf"
|
||||
```
|
||||
|
||||
CLI 做了这些:
|
||||
|
||||
1. `argparse` 解析参数(文件、份数、换盘模板、冷却温度、等待秒数...)
|
||||
2. 拼成 `PlateJob` 列表和 `BuildOptions`
|
||||
3. 调一次 `build_packed_3mf()`(合并模式)
|
||||
4. 把结果打印到 stdout
|
||||
|
||||
新增 `--no-eject-wait` 选项可禁用换盘后等待时间(`G4 P...` 行),与 GUI 中的勾选框对应。
|
||||
|
||||
**所有输入合并成 1 个输出**。参数靠命令行传,没有交互、没有预览。
|
||||
|
||||
### GUI
|
||||
|
||||
窗口操作,参数靠控件选、文件靠拖放。最终也是调 `build_packed_3mf()`,但准备阶段和 CLI 不同:
|
||||
窗口操作,参数靠控件选、文件靠拖放。最终也是调 `build_packed_3mf()`,准备工作包括:
|
||||
|
||||
#### GUI 独有的准备阶段
|
||||
|
||||
1. **文件管理** — 表格里每行一个源文件 + 份数,可拖放、排序、移除
|
||||
2. **元数据预读** — 添加文件时就解析 `slice_info.config`,表格直接显示时间和耗材(CLI 不显示这个,只在构建后输出)
|
||||
2. **元数据预读** — 添加文件时就解析 `slice_info.config`,表格直接显示时间和耗材
|
||||
3. **缩略图预览** — 选中行 3MF → 右侧显示 `plate_N.png` 缩略图
|
||||
4. **输出路径自动算** — 默认留空,自动写到第一个输入文件旁边。文件名按规则 `{plates} Plates - {sources}.3mf` 生成(CLI 必须手动用 `-o`)
|
||||
4. **输出路径自动算** — 默认留空,自动写到第一个输入文件旁边。文件名按规则 `{plates} Plates - {sources}.3mf` 生成
|
||||
5. **设置持久化** — 控件值自动存 `settings.json`,重启恢复
|
||||
|
||||
#### 合并模式(默认)
|
||||
|
||||
和 CLI 一样,所有行合并调一次 `build_packed_3mf()`:
|
||||
所有行合并调一次 `build_packed_3mf()`:
|
||||
|
||||
```
|
||||
A.3mf 份数 3
|
||||
@@ -269,17 +239,3 @@ C.3mf 份数 5 → build_packed_3mf([C]) → 5 Plates - C.3mf
|
||||
- 每行自动算各自的输出路径(文件名规则 + 去重防覆盖)
|
||||
- 弹出进度日志,记录每个文件的构建结果
|
||||
- 有关闭全局预览标签的选项
|
||||
|
||||
#### GUI vs CLI 能力速查
|
||||
|
||||
| 能力 | CLI | GUI |
|
||||
|------|:---:|:---:|
|
||||
| 合并模式 | ✅ | ✅ |
|
||||
| 独立批处理 | ❌ | ✅ |
|
||||
| 拖放添加文件 | ❌ | ✅ |
|
||||
| 表格编辑份数 | ❌ | ✅(每行 SpinBox) |
|
||||
| 元数据预显示 | ❌ | ✅(表格直接看时间/耗材) |
|
||||
| 缩略图预览 | ❌ | ✅ |
|
||||
| 输出路径自动生成 | ❌ | ✅(文件名规则) |
|
||||
| 设置持久化 | ❌ | ✅(settings.json) |
|
||||
| 脚本/自动化友好 | ✅ | ❌ |
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# i18n 国际化实施计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 A1 Swap Mod Packer 添加中英文国际化支持,用户通过下拉框切换语言,切换即时生效并持久化。
|
||||
|
||||
**Architecture:** 新建 `i18n/` 模块,提供 `t(key, **kwargs)` 翻译函数。GUI 中所有硬编码中文替换为 `t("key")` 调用。在打包选项区添加语言下拉框,切换时重新加载翻译并刷新 UI。
|
||||
|
||||
**Tech Stack:** Python 标准库 json + PySide6 QComboBox
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 所有翻译键命名遵循 `模块.区域.属性` 层级
|
||||
- `t()` 在键缺失时返回键名本身作为回退
|
||||
- 语言选择持久化到 `settings.json` 的 `locale` 字段,默认 `zh_CN`
|
||||
- 数值格式化(时间、耗材)保持不翻译,仅在 None 时返回翻译后的占位符
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 操作 | 职责 |
|
||||
|------|------|------|
|
||||
| `a1_swap_mod_packer/i18n/__init__.py` | 新建 | 翻译加载器 + `t()` 函数 |
|
||||
| `a1_swap_mod_packer/i18n/zh_CN.json` | 新建 | 简体中文翻译表 |
|
||||
| `a1_swap_mod_packer/i18n/en.json` | 新建 | 英文翻译表 |
|
||||
| `a1_swap_mod_packer/gui.py` | 修改 | 字符串替换为 `t()` + 语言下拉框 |
|
||||
| `a1_swap_mod_packer/gcode.py` | 修改 | `format_duration`/`format_filament` 的 None 返回翻译 |
|
||||
| `a1_swap_mod_packer/core.py` | 修改 | 导出 `i18n` 模块的 `t` |
|
||||
|
||||
---
|
||||
@@ -1,4 +0,0 @@
|
||||
from a1_swap_mod_packer.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+1
-19
@@ -2,39 +2,21 @@
|
||||
|
||||
验证内容:
|
||||
- APP_TITLE 使用统一的 __version__ 构建
|
||||
- CLI 的 --help 和 --version 输出均使用共享的版本号
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import unittest
|
||||
|
||||
from a1_swap_mod_packer import APP_NAME, APP_TITLE, __version__
|
||||
from a1_swap_mod_packer.cli import create_parser
|
||||
|
||||
|
||||
class VersionDisplayTest(unittest.TestCase):
|
||||
"""测试版本信息在应用标题和 CLI 中的一致性。"""
|
||||
"""测试版本信息的一致性。"""
|
||||
|
||||
def test_app_title_uses_shared_version(self) -> None:
|
||||
"""验证 APP_TITLE 由 APP_NAME 与 __version__ 拼接而成。"""
|
||||
self.assertEqual(APP_TITLE, f"{APP_NAME} v{__version__}")
|
||||
|
||||
def test_cli_help_and_version_use_shared_version(self) -> None:
|
||||
"""验证 CLI 帮助信息和 --version 输出中均包含统一版本号。"""
|
||||
parser = create_parser()
|
||||
# 帮助信息应包含 APP_TITLE
|
||||
self.assertIn(APP_TITLE, parser.format_help())
|
||||
|
||||
stdout = io.StringIO()
|
||||
# --version 参数触发 SystemExit(0) 并打印版本信息
|
||||
with self.assertRaises(SystemExit) as caught, contextlib.redirect_stdout(stdout):
|
||||
parser.parse_args(["--version"])
|
||||
|
||||
self.assertEqual(caught.exception.code, 0) # 正常退出码
|
||||
self.assertEqual(stdout.getvalue().strip(), f"{APP_NAME} {__version__}") # 版本信息匹配
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user