50 Commits

Author SHA1 Message Date
yw1573 a9d2a0cbf1 chore: 版本号调整至 0.6.1 2026-07-29 14:19:36 +08:00
yw1573 5e6be787c7 build: Nuitka改为standalone模式并更新应用图标
onefile自解压易被杀软误报,改为standalone目录分发;
更新x.png应用图标。
2026-07-29 14:17:42 +08:00
yw1573 2438eca047 fix(build): Nuitka打包包含i18n翻译文件 2026-07-29 14:04:11 +08:00
yw1573 f066ca24fa refactor: 移除局域网发送至打印机功能 2026-07-29 12:22:19 +08:00
yw1573 116dcfe45f fix(printer): 修复A1打印发送,绕过bambulabs_api的ftp URL问题,改用sdcard路径 2026-07-28 16:25:21 +08:00
yw1573 db517b69f7 fix(i18n): 语言切换后重新计算固定宽度按钮尺寸 2026-07-28 16:04:00 +08:00
yw1573 ed02b48d80 fix(i18n): 首启根据settings.json的locale信息正确加载语言 2026-07-28 15:55:59 +08:00
yw1573 68aa056359 fix(i18n): 语言切换时全面刷新所有UI文本控件 2026-07-28 15:51:54 +08:00
yw1573 2fed301a6c chore: 版本号升至 0.7.0 2026-07-28 15:41:36 +08:00
yw1573 dd79918940 style(gui): 语言下拉框移至窗口右上角 2026-07-28 15:39:30 +08:00
yw1573 015f2636b3 feat(i18n): 新增中英文国际化支持,语言下拉框即时切换 2026-07-28 15:18:58 +08:00
yw1573 935665e7e8 refactor: 移除CLI模块,项目改为纯GUI桌面应用 2026-07-28 14:56:49 +08:00
yw1573 ca436b577b chore: 排除code目录,仅本地参考 2026-07-28 14:46:29 +08:00
yw1573 f6b118293c feat(printer): 新增局域网发送3MF至A1打印机功能 2026-07-28 14:45:11 +08:00
yw1573 51c105b8bb docs: 更新自述文件 2026-07-28 14:04:56 +08:00
yw1573 6c2aa50193 docs: 更新README和文档至v0.6.0,含最新GUI变更 2026-07-28 13:57:10 +08:00
yw1573 28bdcddf44 refactor(gui): 全部QSpinBox替换为QLineEdit+QIntValidator,去除箭头按钮 2026-07-28 12:02:02 +08:00
yw1573 dea88feb7f fix(gui): 同时设置app和window图标确保显示 2026-07-28 11:48:09 +08:00
yw1573 e204ce145e style(gui): 进一步压缩全局控件尺寸和间距 2026-07-28 11:40:44 +08:00
yw1573 2d86a3fa0d feat(gui): 添加应用图标 x.png 2026-07-28 11:31:59 +08:00
yw1573 8d7811aeb5 style(gui): 压缩输入文件表格尺寸,缩小窗口至680px 2026-07-28 11:23:09 +08:00
yw1573 74f751a2d5 style(gui): 构建成功后清空输入列表移至独立行 2026-07-28 11:11:25 +08:00
yw1573 4c430f5984 style(gui): 换盘G-code下拉框与下方控件列对齐 2026-07-28 11:08:25 +08:00
yw1573 dc1ddb5f04 style(gui): 增大标签列最小宽度改善对齐 2026-07-28 11:02:09 +08:00
yw1573 d1598d2a5a style(gui): 打包选项标签左对齐,控件列自动对齐 2026-07-28 10:59:13 +08:00
yw1573 c1a3152b4c style: 最后一块板改为最后一块盘 2026-07-28 10:52:17 +08:00
yw1573 edbbdd00a2 style: 最后换料改为最后换盘 2026-07-28 10:50:08 +08:00
yw1573 6b9fd8a93c style: 换料G-code改为换盘G-code,弹射后等待改为换盘后等待 2026-07-28 10:47:08 +08:00
yw1573 8e2797dde6 style(planning): 默认输出文件名模板空格改为连字符 2026-07-28 10:31:06 +08:00
yw1573 b749bdd807 fix(gui,gcode): 无数据时格式化显示0s和0.00g替代未知
format_duration/format_filament 接收 None 时返回零值字符串而非未知,
GUI 初始合计标签同步更新。
2026-07-28 10:26:48 +08:00
yw1573 002b8117d8 fix(gui,cli,gcode): 元数据默认累加模式,格式化Unknown→未知
- BuildOptions.metadata_mode 默认 sum(累加预测和耗材用量)
- CLI --metadata-mode 默认 sum
- GUI 设置回退值默认 sum
- format_duration/format_filament 空值返回 未知 而非 Unknown
2026-07-28 10:12:37 +08:00
yw1573 e00e77a16a feat(gui,cli): 弹射后等待增加开关控制,版本升至0.6.0
- GUI: 弹射后等待改为勾选框+微调框模式,与热床降温统一
- CLI: 新增 --no-eject-wait 参数跳过弹射等待
- 不勾选时 wait_after_eject_seconds=0,build_swap_block 跳过 G4
- __init__.py: 版本号 0.5.0 → 0.6.0
2026-07-28 09:54:12 +08:00
yw1573 6a06245f19 fix(gui): 修复对话框中文引号导致的SyntaxError
点击构建 3MF将创建中的 ASCII 双引号与 Python 字符串界定符冲突,
替换为 Unicode 弯引号 “构建 3MF”(U+201C/U+201D)。
2026-07-28 09:49:24 +08:00
yw1573 4ae33cbe53 docs: 为全部13个源码模块和5个测试文件添加简体中文注释
- 每个文件补充模块级 docstring、类/函数 docstring、关键逻辑行内注释
- 注释风格:"中文说明" 或 # 中文说明
- 代码结构和逻辑完全不变
2026-07-28 09:39:55 +08:00
yw1573 b7a6f42189 docs: 新增 A1 Swap Mod Packer 工作原理文档
从输入到输出的完整处理流水线:解压读取→展开→预处理(缓存)→
逐份加工(M73偏移+插入换料块)→拼接→写入(元数据+预览+压缩+原子写入)
2026-07-28 09:20:18 +08:00
yw1573 e9378ce7bd docs: 为全部5份换板G-code添加逐段中文注释
标注每段功能(归零/下压/前推/抖板/弹射/归位)、
关键指令含义(G380/M400/M17/M211)、
三个类型间的差异点。
2026-07-27 11:31:21 +08:00
yw1573 27fade244e docs: README 全文翻译为简体中文 2026-07-27 11:27:02 +08:00
yw1573 c5849af14e feat(gui,cli): 界面全面中文化并优化打包选项为紧凑网格布局
- gui.py: 全部用户可见文本直接写中文,选项区从 QFormLayout 改为 QGridLayout 两列布局,缩小窗口默认高度至720px,收紧控件间距
- cli.py: argparse 帮助文本和输出消息全部中文化
- docs: 新增 A1 SwapMod 换板 G-code 内容分析文档,按三类策略(基础/双轮抖板/安全换板)对比5个模板差异
- swap_gcode: 新增 LX_0.01.01 基础弹板模板
2026-07-27 11:22:40 +08:00
hyyz17200 6ac5331932 Add Windows build script and update README with build instructions 2026-05-08 17:36:55 +08:00
hyyz17200 4ba654ca3f Add parallel processing for individual batch builds 2026-05-08 12:17:27 +08:00
hyyz17200 8126a9c608 Optimize G-code processing time by using M73OffsetTemplate for plate number offsets 2026-05-08 12:02:15 +08:00
hyyz17200 7dabdc676a Introduce caching for prepared G-code 2026-05-08 11:29:09 +08:00
hyyz17200 26b2591786 Use zlib-ng to do ZIP compression 2026-05-08 11:02:41 +08:00
hyyz17200 8bfaa1d1fe Enhance thumbnail generation 2026-05-08 02:37:59 +08:00
hyyz17200 61b2b7ae44 Show version number 2026-05-08 02:34:40 +08:00
hyyz17200 87167d2616 Added an experimental workaround for the Z-axis homing bug on the A1. Kindly provided by QQ group member "风信子号" 2026-05-08 02:06:37 +08:00
hyyz17200 b334b16c42 Enhance GUI: Add order controls, thumbnail preview, and improve more UI stuff 2026-05-08 02:02:47 +08:00
hyyz17200 5f3b5c716c Add SuccessToast as notifications 2026-05-08 01:05:04 +08:00
hyyz17200 614c65b30e Fix a bug: A1 refuses to print when packing any non first plate G-code 2026-05-08 00:48:13 +08:00
hyyz17200 287509411a Add screenshot and real photo
Add screenshot and real photo
2026-04-28 17:48:59 +08:00
35 changed files with 4536 additions and 791 deletions
+3 -1
View File
@@ -15,4 +15,6 @@ Thumbs.db
*.bak
/settings.json
codec.py
/reference
codec.py/code/
code
+276 -208
View File
@@ -1,87 +1,183 @@
# A1 Swap Mod Packer
A1 Swap Mod Packer is an open-source implementation 3MF packer for Bambu Lab A1 SwapMod workflows.
当前版本:**v0.6.0**
It takes one or more A1-sliced `.3mf` files, repeats their plate G-code according to copy counts, inserts an external SwapMod ejection/swap G-code block, and writes a new packed `.3mf` that can be sent to the printer.
A1 Swap Mod Packer 是一个开源的 3MF 打包工具,专为 Bambu Lab A1 SwapMod 工作流设计。
## Notes
它接收一个或多个 A1 切片后的 `.3mf` 文件,根据设定份数重复其面板 G-code,插入外部 SwapMod 弹射/换板 G-code 块,并输出一个新的打包 `.3mf` 文件,可直接发送至打印机。
- This project contains no proprietary code. All functionalities were implemented independently by comparing the states before and after 3MF file generation.
- Currently, this project has only been tested and verified on Windows 10.
- Always test a generated file on a safe/simple print before using it in production.
![A1 Swap Mod Packer 截图](https://images-tfb361lw6pyvz9bxue3s.oss-cn-chengdu.aliyuncs.com/obsidian/20260728140250243.png)
## Key features
![](https://images-tfb361lw6pyvz9bxue3s.oss-cn-chengdu.aliyuncs.com/obsidian/20260728140421994.png)
- GUI for drag-and-drop batch packing.
- CLI for repeatable automation.
- Multiple input `.3mf` files.
- Per-file copy count.
- Automatic time and filament summary from `Metadata/slice_info.config`.
- External SwapMod G-code template folder.
- Editable G-code patch file.
- Optional remaining-time plate-number encoding through `M73 R...`.
- Combined mode: all input rows become one packed 3MF.
- Individual batch mode: every input row becomes its own packed 3MF.
- GUI settings are saved to the program folder as `settings.json`.
## 注意事项
## Installation for source use
- 本项目不含任何闭源代码。所有功能均通过对比 3MF 文件生成前后的状态独立实现。
- 目前仅在 Windows 10 上测试验证过。
- 生产环境使用前,请先在安全/简单的打印任务上测试生成的文件。
Python 3.10 or newer is recommended.
## 主要功能
Install dependency:
- 拖放式批量打包 GUI。
- 简洁高效的桌面 GUI。
- 支持多个 `.3mf` 输入文件。
- 每个文件独立份数。
- 自动从 `Metadata/slice_info.config` 读取并汇总时间和耗材。
- 外部 SwapMod G-code 模板目录。
- 可编辑 G-code 补丁文件。
- 可选通过 `M73 R...` 编码剩余时间板号。
- GUI 中选中文件缩略图预览。
- 最多 9 个不同输入文件的预览图合成,带 `{plates} P` 绿色标签。
- 合并模式:所有输入行合并为一个打包 3MF。
- 独立批处理模式:每行输入各生成一个独立打包 3MF。
- zlib-ng Deflate ZIP 压缩,默认级别 7。
- GUI 设置保存到程序目录的 `settings.json`
## 源码安装
推荐 Python 3.13 及以上(Nuitka 4.x 对 3.14 仅实验性支持)。Python 3.10+ 亦可使用。
安装依赖:
```bash
pip install -r requirements.txt
```
Run the GUI:
当前运行时依赖:
| 依赖 | 用途 |
|---|---|
| `PySide6` | GUI 界面、拖放表格、文件对话框、缩略图预览、设置界面 |
| `Pillow` | 读取和合成输出 3MF 中的 PNG 预览图,包括 `{plates} P` 标签 |
| `zlib-ng` | ZIP Deflate 压缩后端,用于写入压缩 3MF 并控制压缩级别 |
当前 Python 源码版本不需要 Java 运行时、Bambu Studio SDK、外部压缩工具或加密/模板解码器。
启动 GUI
```bash
python -m a1_swap_mod_packer.gui
```
or
```bash
python run_gui.py
```
Run the CLI:
## 构建 Windows 可执行文件
```bash
python -m a1_swap_mod_packer.cli --help
仓库包含 Windows 构建脚本:
```cmd
build_win.cmd
```
or
脚本使用 Nuitka onefile 模式,生成如下便携发布目录:
```bash
python run_cli.py
```text
build/onefile/
```
## Swap G-code templates
预期输出:
The GUI automatically scans this fixed folder:
```text
build/onefile/
a1packer.exe
gcode_patches.ini
swap_gcode/
x.png 输出预览图示例
settings.json 可选;GUI 保存设置后自动生成
```
外部资源刻意保留在可执行文件外部。不要将这些文件打包进 onefile 二进制:
```text
swap_gcode/
gcode_patches.ini
settings.json
```
打包为 exe 时,程序会从 exe 所在目录解析这些路径。
### Windows 构建要求
已验证的构建环境:
- Windows 10。
- Python 3.10 及以上。
- 虚拟环境 `.venv/`
- `requirements.txt` 中的运行时依赖。
- Nuitka 支持 onefile 模式。
- Microsoft C++ Build Tools / Visual Studio Build Tools(含 MSVC 编译器和 Windows SDK)。
在干净的虚拟环境中安装 Python 依赖:
```cmd
py -3.13 -m venv .venv
.venv\Scripts\python.exe -m pip install --upgrade pip
.venv\Scripts\python.exe -m pip install -r requirements.txt
.venv\Scripts\python.exe -m pip install "Nuitka[onefile]"
```
然后执行:
```cmd
build_win.cmd
```
构建脚本执行一次 Nuitka 编译:
- GUI`run_gui.py``build/onefile/a1packer.exe`
GUI 编译启用 PySide6 插件并包含当前界面所需的 Qt 插件组:
```text
platforms,imageformats,styles,iconengines
```
### 构建后验证
启动一次 `build/onefile/a1packer.exe`,确认:
- 换盘 G-code 下拉框列出已复制的模板。
- 修改 GUI 选项会创建或更新 `build/onefile/settings.json`
- 首次启动前 `settings.json` 不存在时程序仍能正常运行。
## 换盘 G-code 模板
GUI 会自动扫描以下固定目录:
```text
swap_gcode/
```
Plain UTF-8 G-code files are read directly.
UTF-8 文本文件直接读取。当前扫描器接受以下后缀:
In the GUI:
```text
.gcode
.nc
.ngc
.txt
```
1. Put template files in `swap_gcode/`.
2. Click **Refresh** next to **Swap G-code**.
3. Select the template from the dropdown.
当前源码版本不解码加密的或厂商模板的 G-code 归档。
## Editable G-code patches
GUI 中使用方法:
The GUI and CLI read the fixed patch file:
1. 将模板文件放入 `swap_gcode/`
2. 点击 **换盘 G-code** 旁的 **刷新**
3. 从下拉框中选择模板。
## 可编辑 G-code 补丁
GUI 读取固定补丁文件:
```text
gcode_patches.ini
```
Default rule:
默认规则:
```ini
[patch.a1_start_y]
@@ -92,181 +188,194 @@ replace = G0 Y250 F3000 ; Patched
max_count = 1
```
Meaning:
含义:
- Search after the optional `flag` line.
- Replace the first matching `find` line with `replace`.
- Apply at most `max_count` replacements.
- 从可选的 `flag` 行之后开始查找。
- 将第一个匹配 `find` 的行替换为 `replace`
- 最多替换 `max_count` 次。
You can disable this in the GUI with **G-code patches**, or edit the INI file and keep the option enabled.
可在 GUI 中通过 **G-code 补丁** 复选框禁用,或编辑 INI 文件并保持启用。
The swap insertion marker can also be edited:
换板插入标记也可编辑:
```ini
[swap]
insert_before_marker = ;=====printer finish sound=========
```
## GUI guide
## GUI 使用指南
### Input 3MF files
### 输入 3MF 文件
The input table supports:
输入表格支持:
- **Add 3MF**: choose one or more `.3mf` files.
- Drag and drop `.3mf` files into the table.
- Drag and drop a folder into the table. The GUI adds top-level `.3mf` files from that folder.
- **Remove**: remove selected rows.
- **Remove All**: clear the full input list.
- **Move Up / Move Down**: change input order.
- **Apply Default Copies to Selected**: overwrite selected rows with the current default copy count.
- **添加 3MF**:选择一个或多个 `.3mf` 文件。
- 拖放 `.3mf` 文件到表格中。
- 拖放文件夹到表格中。GUI 会添加该文件夹内所有顶层 `.3mf` 文件。
- **移除**:移除选中行。
- **全部移除**:清空全部输入列表。
- **上移 / 下移**:调整输入顺序。
- **应用默认份数至选中行**:将当前默认份数覆盖到选中行。
Columns:
列说明:
- **3MF path**: source file path.
- **Copies**: how many times this source should be repeated.
- **Time**: estimated print time multiplied by copies.
- **Filament**: estimated filament multiplied by copies.
- **3MF 文件**:源文件路径。
- **份数**:该源文件重复的次数。
- **时间**:预估打印时间 × 份数。
- **耗材**:预估耗材用量 × 份数。
The time and filament values are read from:
时间和耗材数据读取自:
```text
Metadata/slice_info.config
```
If the source 3MF lacks this metadata, the GUI shows `Unknown`.
如果源 3MF 缺少此元数据,GUI 显示"未知"。
The summary line under the input list shows the total plate count, estimated time, and filament for the current table.
输入列表下方的汇总行显示当前表格的总板数、预估时间和耗材。
### Build 3MF button
右侧缩略图面板显示选中输入文件的活动面板预览(若 3MF 中包含)。
The **Build 3MF** button is placed below the input list on the right side for fast batch operation.
### 构建 3MF 按钮
In normal combined mode, one click creates one output 3MF containing all input rows.
**构建 3MF** 按钮位于输入列表右下方,便于快速批量操作。
In individual batch mode, one click creates one output 3MF per input row.
正常合并模式下,一次点击生成一个包含所有输入行的输出 3MF。
### Swap G-code
独立批处理模式下,一次点击为每行输入各生成一个输出 3MF。
Selects the ejection/swap G-code block inserted into each repeated plate.
### 换盘 G-code
The dropdown is generated from files in:
选择插入到每个重复面板中的弹射/换板 G-code 块。
下拉框内容来自:
```text
swap_gcode/
```
Buttons:
按钮:
- **Refresh**: rescan the folder.
- **Open Folder**: open the template folder.
- **刷新**:重新扫描目录。
- **打开文件夹**:打开模板目录。
### Default copies for new inputs
### 新输入的默认份数
Sets the default copy count applied to newly added or dragged-in files.
设置添加到或拖入的新文件的默认份数。
This does not automatically change existing rows. Use **Apply Default Copies to Selected** for existing rows.
这不会自动更改已有行。对已有行使用 **应用默认份数至选中行**
### Bed cooldown
> 提示:所有数值输入框均为纯数字输入(QLineEdit),无上下箭头,可直接键入任意数值。
Controls whether the tool inserts a bed temperature wait before the SwapMod G-code block.
### 热床降温
Enabled example:
控制是否在 SwapMod G-code 块之前插入热床等待温度。
启用示例:
```gcode
M190 S45
```
If disabled, no `M190` line is added by the packer.
禁用后,打包器不添加 `M190` 行。
### Wait after ejection
### 换盘后等待时间
Adds a dwell after the SwapMod G-code block.
勾选框控制是否在换盘 G-code 块之后插入驻留时间。
Example for 45 seconds:
默认 30 秒,可通过纯数字输入框(QLineEdit)调整:
```gcode
G4 P45000
G4 P30000
```
### Remaining-time plate number
### 剩余时间板号
When enabled, the packer offsets `M73 ... R...` remaining-time values by:
启用后,打包器按以下公式偏移 `M73 ... R...` 剩余时间值:
```text
plate_number × 100 hours × 60 minutes
板号 × 100 小时 × 60 分钟
```
This allows the A1 remaining-time hour hundreds digit to display the current plate number.
这使得 A1 剩余时间的百位能显示当前板号。
Example concept:
示例:
- Plate 1: +6000 minutes
- Plate 2: +12000 minutes
- Plate 3: +18000 minutes
- 第 1 板:+6000 分钟
- 第 2 板:+12000 分钟
- 第 3 板:+18000 分钟
### Final swap
### 最后换盘
When enabled, the swap/ejection block is also inserted after the final repeated plate.
启用后,换板/弹射块也会插入到最后一个重复面板之后。
When disabled, the final repeated plate ends normally without running the swap G-code block.
禁用后,最后一个重复面板正常结束,不执行换盘 G-code 块。
### G-code patches
### G-code 补丁
Applies rules from:
应用来自以下文件的规则:
```text
gcode_patches.ini
```
The **Open Config** button opens that fixed file for editing.
**打开配置文件** 按钮打开该固定文件进行编辑。
### 3MF metadata
### 3MF 元数据
Options:
选项:
- **Keep source prediction and weight**
Leaves `slice_info.config` prediction and weight values from the base 3MF unchanged. This best matches the observed vendor packer behavior.
- **累加预测和耗材用量(默认)**
使用所有重复面板的总和更新第一块面板的元数据。从统计角度看更合理,适合 SwapMod 场景。
- **Sum prediction and filament**
Updates the first plate metadata using the sum of all repeated source plates. This is more logically correct for statistics, but may differ from the vendor software.
- **保留原始预测和重量**
保持基础 3MF 中 `slice_info.config` 的预测和重量值不变。
### Batch mode
### 预览图处理
#### Combined mode
默认情况下,输出 3MF 保留基础归档中对应当前输出面板的预览成员,并用以下内容重写这些 PNG 预览:
Default behavior.
- 最多 9 个不同输入文件的预览图合成;
- 一个简短的绿色标签(如 `5 P`),施加在最终合成图上。
All input rows are packed into one output file.
如果预览图缺失或无法读取,打包器保留可用基础预览,仍尝试施加面板标签。
Example:
### 批处理模式
#### 合并模式
默认行为。
所有输入行打包为一个输出文件。
示例:
```text
A.3mf copies 2
B.3mf copies 3
A.3mf 份数 2
B.3mf 份数 3
```
Output:
输出:
```text
one packed 3MF containing A, A, B, B, B
一个打包 3MF,包含 A, A, B, B, B
```
#### Individual batch mode
#### 独立批处理模式
When enabled, the GUI shows an explanation popup.
启用时,GUI 显示说明弹窗。
Every input row is treated as an independent build.
每行输入被视为独立构建。
Example:
示例:
```text
A.3mf copies 5
B.3mf copies 5
C.3mf copies 5
A.3mf 份数 5
B.3mf 份数 5
C.3mf 份数 5
```
Output:
输出:
```text
5 Plates - A.3mf
@@ -274,53 +383,55 @@ Output:
5 Plates - C.3mf
```
This is intended for quickly converting a large batch of independent single-plate 3MF files into multi-copy SwapMod packs.
此模式用于快速将大量独立的单板 3MF 文件批量转换为多份数 SwapMod 包。
It does **not** combine all inputs into one file.
GUI 并行构建这些独立输出,并发数受 CPU 核心数及安全上限限制。
### Input handling
它**不会**将所有输入合并为一个文件。
Options:
### 输入处理
- **Skip duplicate file paths when adding inputs**
Prevents accidentally adding the same path multiple times.
- **添加输入时跳过重复文件路径**
防止意外多次添加同一路径。
- **Clear input list after successful build**
Clears the table after a successful build. In individual batch mode, the list is cleared only if all outputs are built successfully.
### 构建成功后
### Output directory
- **清空输入列表**
构建成功后清空表格。在独立批处理模式下,仅当所有输出构建成功后才会清空。
If this field is empty:
### 输出目录
- Combined mode writes next to the first input file.
- Individual batch mode writes each output next to its own input file.
如果此字段为空:
If a directory is selected, all outputs go to that directory.
- 合并模式写入第一个输入文件同目录。
- 独立批处理模式将每个输出写入各自输入文件同目录。
### Output filename rule
如果选择了目录,所有输出都写入该目录。
Default:
### 输出文件名规则
默认规则:
```text
{plates} Plates - {sources}.3mf
```
Click the `?` button next to the rule field to show token help.
点击规则字段旁的 `?` 按钮显示标记帮助。
Available tokens:
可用标记:
| Token | Meaning |
| 标记 | 含义 |
|---|---|
| `{source}` | First input file stem, without `.3mf` |
| `{sources}` | Source summary. One source uses its stem; multiple sources become `first_source_and_N_more` |
| `{plates}` | Total plate count in this output |
| `{copies}` | Total copy count in this output |
| `{date}` | Current date as `YYYYMMDD` |
| `{time}` | Current time as `HHMMSS` |
| `{source}` | 第一个输入文件的文件名(不含 `.3mf` |
| `{sources}` | 源文件摘要。单个源使用其文件名;多个源则显示为 `first_source_and_N_more` |
| `{plates}` | 本输出中的总板数 |
| `{copies}` | 本输出中的总份数 |
| `{date}` | 当前日期,格式 `YYYYMMDD` |
| `{time}` | 当前时间,格式 `HHMMSS` |
In individual batch mode, tokens are calculated per input row.
在独立批处理模式下,标记按每行输入单独计算。
Examples:
示例:
```text
{plates} Plates - {sources}.3mf
@@ -328,72 +439,29 @@ SwapMod - {source} - x{copies}.3mf
{date}_{time}_{source}.3mf
```
## GUI settings
## GUI 设置
The GUI writes settings to the program folder:
GUI 将设置写入程序目录:
```text
settings.json
```
This is intentional so a portable extracted folder or future packaged `.exe` build keeps its options next to the application.
这是有意为之,以便便携式解压目录或打包的 `.exe` 版本能将选项保持在应用程序旁边。
Saved options include:
- Selected Swap G-code file.
- Default copies.
- Bed cooldown settings.
- Wait after ejection.
- Remaining-time plate-number switch.
- Final swap switch.
- G-code patch switch.
- Metadata mode.
- Individual batch mode.
- Input handling options.
- Output directory.
- Output filename rule.
## CLI examples
Build one source with five copies:
```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"
```
Build multiple sources into one output:
```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"
```
Use summed metadata instead of source metadata:
```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"
```
Disable editable G-code patches:
```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"
```
保存的选项包括:
- 当前选中的换盘 G-code 文件。
- 默认份数。
- 热床降温设置。
- 换盘后等待时间。
- 剩余时间板号开关。
- 最后换盘开关。
- G-code 补丁开关。
- 元数据模式。
- ZIP 压缩级别。
- 独立批处理模式。
- 输入处理选项。
- 构建成功后清空输入列表。
- 输出目录。
- 输出文件名规则。
+10 -1
View File
@@ -1,2 +1,11 @@
__version__ = "0.4.0"
"""A1 Swap Mod Packer 主包入口模块。
定义应用程序的名称和版本信息,供其他模块引用。
"""
# 应用程序显示名称
APP_NAME = "A1 Swap Mod Packer"
# 语义化版本号
__version__ = "0.6.1"
# 窗口标题:应用名称 + 版本号
APP_TITLE = f"{APP_NAME} v{__version__}"
+17
View File
@@ -1,16 +1,33 @@
"""ZIP 归档文件中 G-code 成员的读取与解析。
本模块提供了从 .3mf 或 .zip 归档文件中列出 G-code 文件路径的功能,
主要用于识别 Metadata 目录下按 plate_<编号>.gcode 命名的换板 G-code 文件。"""
from __future__ import annotations
import re
import zipfile
# 匹配 Metadata 目录下 plate_<数字>.gcode 格式的文件名
GCODE_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
# 匹配对应的 MD5 校验文件 plate_<数字>.gcode.md5
MD5_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode\.md5$")
def list_gcode_members(archive: zipfile.ZipFile) -> list[str]:
"""列出归档文件中所有 plate G-code 成员,按板号升序排列。
参数:
archive: 已打开的 ZIP 归档文件对象。
返回:
按 plate 编号从小到大排序的 G-code 文件名列表。
"""
members: list[tuple[int, str]] = []
for name in archive.namelist():
match = GCODE_MEMBER_RE.match(name)
if match:
# 提取板号并存储 (板号, 文件名) 元组
members.append((int(match.group(1)), name))
# 按板号升序排列后只返回文件名
return [name for _, name in sorted(members)]
+164
View File
@@ -0,0 +1,164 @@
"""批量构建模块:支持并行或串行地处理多个独立换板打包任务。
提供 dataclass 用于封装单个构建任务、失败信息和批量结果,
并包含工作进程数计算及并行/串行执行调度逻辑。
"""
from __future__ import annotations
import os
from concurrent.futures import ProcessPoolExecutor, as_completed
from dataclasses import dataclass
from typing import Sequence
from .builder import build_packed_3mf
from .models import BuildOptions, BuildResult, PlateJob
# 独立批量构建的默认最大并行工作进程数
INDIVIDUAL_BATCH_MAX_WORKERS = 8
@dataclass(frozen=True)
class IndividualBuildTask:
"""单个独立构建任务,包含一个板作业和对应的构建选项。"""
job: PlateJob
"""要处理的板作业"""
options: BuildOptions
"""构建选项配置"""
@dataclass(frozen=True)
class IndividualBuildFailure:
"""记录单个构建任务失败时的详细信息。"""
index: int
"""任务在原始列表中的索引"""
job: PlateJob
"""失败的板作业"""
error: str
"""错误描述信息"""
@dataclass(frozen=True)
class IndividualBatchBuildResult:
"""一批独立构建任务的整体结果汇总。"""
results: tuple[BuildResult, ...]
"""成功完成的所有构建结果"""
failures: tuple[IndividualBuildFailure, ...]
"""所有失败的构建任务记录"""
worker_count: int
"""本次批量构建使用的并行工作进程数"""
def individual_batch_worker_count(task_count: int, max_workers: int | None = None) -> int:
"""根据任务数量计算合适的并行工作进程数。
综合考虑用户指定的最大进程数、CPU 核心数和内置上限,
返回一个合理的并行度。
Args:
task_count: 待执行的任务总数
max_workers: 用户指定的最大并行工作进程数,为 None 时自动检测
Returns:
应使用的并行工作进程数,任务数 <= 0 时返回 0
"""
if task_count <= 0:
return 0
if max_workers is not None:
# 以用户指定值为上限,最少 1 个进程
return min(task_count, max(1, int(max_workers)))
# 自动检测:取任务数、CPU 核心数、内置上限三者中的最小值
detected_cpu_count = os.cpu_count() or 1
return min(task_count, max(1, detected_cpu_count), INDIVIDUAL_BATCH_MAX_WORKERS)
def _build_individual_task(task: IndividualBuildTask) -> BuildResult:
"""执行单个构建任务,调用核心构建函数。
Args:
task: 单个独立构建任务
Returns:
BuildResult: 构建结果
"""
return build_packed_3mf([task.job], task.options)
def _run_individual_batch_serial(
tasks: Sequence[IndividualBuildTask],
worker_count: int,
) -> IndividualBatchBuildResult:
"""串行方式执行一批独立构建任务。
逐个执行任务,捕获每个任务的异常并记录为失败。
Args:
tasks: 要执行的任务序列
worker_count: 工作进程数(此处仅用于结果记录)
Returns:
IndividualBatchBuildResult: 包含成功和失败记录的批量结果
"""
results: list[BuildResult] = []
failures: list[IndividualBuildFailure] = []
for index, task in enumerate(tasks):
try:
# 逐个同步执行构建
results.append(_build_individual_task(task))
except Exception as exc:
# 捕获任意异常,记录为失败项
failures.append(IndividualBuildFailure(index, task.job, str(exc)))
return IndividualBatchBuildResult(tuple(results), tuple(failures), worker_count)
def run_individual_batch_builds(
tasks: Sequence[IndividualBuildTask],
max_workers: int | None = None,
) -> IndividualBatchBuildResult:
"""并行执行一批独立构建任务。
当计算出的工作进程数 > 1 时,使用 ProcessPoolExecutor 并行处理;
否则回退到串行执行。并行模式下按原始索引位置收集结果。
Args:
tasks: 要执行的任务序列
max_workers: 最大并行工作进程数,为 None 时自动检测
Returns:
IndividualBatchBuildResult: 包含成功和失败记录的批量结果
"""
task_list = list(tasks)
worker_count = individual_batch_worker_count(len(task_list), max_workers)
# 单进程或无任务时,使用串行路径
if worker_count <= 1:
return _run_individual_batch_serial(task_list, worker_count)
# 预分配结果槽位,保证按原始顺序输出
ordered_results: list[BuildResult | None] = [None] * len(task_list)
failures: list[IndividualBuildFailure] = []
# 使用进程池并行执行
with ProcessPoolExecutor(max_workers=worker_count) as executor:
# 提交所有任务并记录 future 到索引的映射
future_to_index = {
executor.submit(_build_individual_task, task): index
for index, task in enumerate(task_list)
}
# 按完成顺序收集结果
for future in as_completed(future_to_index):
index = future_to_index[future]
task = task_list[index]
try:
# 获取结果,放入对应位置
ordered_results[index] = future.result()
except Exception as exc:
# 捕获任务执行异常,记录为失败
failures.append(IndividualBuildFailure(index, task.job, str(exc)))
# 过滤掉未成功的位置(None),保留有效结果
results = tuple(result for result in ordered_results if result is not None)
return IndividualBatchBuildResult(
tuple(results),
# 按原始索引排序失败记录,便于阅读
tuple(sorted(failures, key=lambda item: item.index)),
worker_count,
)
+673 -22
View File
@@ -1,26 +1,50 @@
"""换板 3MF 构建引擎 - 多板 G-code 合并与打包核心模块.
本模块是整个项目的核心引擎,负责将多个 3MF 切片文件中的 G-code
合并为一份带自动换板逻辑的打包 3MF 文件。主要功能包括:
1. 从源 3MF 文件中提取板层 G-code 及元数据(打印时间、耗材用量等)
2. 在板层之间插入换板 G-code 块(自动清空热床、暂停等待取件)
3. 应用自定义 G-code 补丁(M73 进度偏移、行尾格式转换等)
4. 生成多板拼接预览图(每板小缩略图拼成网格,并叠加板数标签)
5. 输出最终的 3MF 文件(使用 zlib-ng 加快 ZIP 压缩)
"""
from __future__ import annotations
import hashlib
import os
import re
import shutil
import tempfile
import zipfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
from .gcode import (
M73OffsetTemplate,
apply_line_ending,
apply_plate_number_offset,
build_swap_block,
insert_swap_block,
normalize_newlines,
prepare_m73_offset_template,
read_swap_gcode_file,
resolve_swap_gcode_path,
)
from .metadata import read_filament_metadata, read_slice_plate_metadata, safe_float, update_first_slice_info
from .models import BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource
from .metadata import (
read_filament_metadata,
read_model_settings_gcode_members,
read_slice_plate_metadata,
safe_float,
update_first_slice_info,
)
from .models import DEFAULT_ZIP_COMPRESS_LEVEL, BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource
from .patches import apply_gcode_patches, parse_patch_config
# PILPython Imaging Library)为可选依赖,用于生成预览图标签和缩略图拼接
# 若不可用则降级跳过图片处理
try:
from PIL import Image, ImageDraw, ImageFont
except Exception: # pragma: no cover
@@ -28,15 +52,122 @@ except Exception: # pragma: no cover
ImageDraw = None
ImageFont = None
# ---------------------------------------------------------------------------
# 模块级常量
# ---------------------------------------------------------------------------
# 匹配 Metadata/plate_N.png 或 Metadata/plate_N_small.png 的正则
PREVIEW_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)
# 合成预览图中最多展示的源文件数量(防止网格过于密集)
MAX_COMPOSITE_PREVIEW_INPUTS = 9
# ZIP 压缩等级合法范围
MIN_ZIP_COMPRESS_LEVEL = 1
MAX_ZIP_COMPRESS_LEVEL = 9
# ---------------------------------------------------------------------------
# 数据结构
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _PreparedPlateGcode:
"""预处理后的板层 G-code 数据(内部使用,不可变)。
属性:
text: 规范化并可选打过 G-code 补丁的 G-code 文本。
m73_template: 若启用板号显示,则为 M73 偏移模板,否则为 None。
"""
text: str
m73_template: M73OffsetTemplate | None = None
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
def normalized_zip_compress_level(level: int | None) -> int:
"""将 ZIP 压缩等级规范化为合法范围内的整数值。
输入:
level: 用户指定的压缩等级(0-9 或 None)。
输出:
夹逼在 [MIN_ZIP_COMPRESS_LEVEL, MAX_ZIP_COMPRESS_LEVEL] 之间的整数。
若 level 为 None 或类型错误,则使用默认值 DEFAULT_ZIP_COMPRESS_LEVEL。
"""
try:
value = int(level if level is not None else DEFAULT_ZIP_COMPRESS_LEVEL)
except (TypeError, ValueError):
value = DEFAULT_ZIP_COMPRESS_LEVEL
return min(MAX_ZIP_COMPRESS_LEVEL, max(MIN_ZIP_COMPRESS_LEVEL, value))
def load_zlib_ng_module() -> object:
"""加载 zlib-ng 模块以加速 ZIP 压缩。
输入:
无。
输出:
zlib_ng 模块对象。
异常:
RuntimeError: 若 zlib-ng 未安装。
"""
try:
from zlib_ng import zlib_ng
except ImportError as exc: # pragma: no cover - exercised when dependency is missing
raise RuntimeError("zlib-ng is required for 3MF ZIP compression. Install it with: pip install zlib-ng") from exc
return zlib_ng
@contextmanager
def use_zlib_ng_for_zipfile() -> object:
"""上下文管理器:用 zlib-ng 替换 zipfile 模块内的默认 zlib 实现。
在 with 块内,所有 zipfile 操作自动使用 zlib-ng 压缩算法,
退出时自动恢复原始 zlib。
输入:
无。
输出:
yield 的返回值(通常不使用)。
"""
original_zlib = zipfile.zlib
zipfile.zlib = load_zlib_ng_module()
try:
yield
finally:
zipfile.zlib = original_zlib
# ---------------------------------------------------------------------------
# 板源加载与展开
# ---------------------------------------------------------------------------
def load_plate_sources(job: PlateJob) -> list[PlateSource]:
"""从单个 PlateJob 的源 3MF 文件中加载所有板层的 G-code 及元数据。
输入:
job: 包含 source_3mf 路径和 copies 数量的 PlateJob 对象。
输出:
该 3MF 中每个 G-code 成员对应的 PlateSource 列表,每个 PlateSource
携带规范化的 G-code 文本、预测打印时间、耗材重量/长度等信息。
异常:
ValueError: 若 copies < 1 或未找到任何 G-code 成员。
"""
if job.copies < 1:
raise ValueError(f"Invalid copy count for {job.source_3mf}: {job.copies}")
with zipfile.ZipFile(job.source_3mf, "r") as archive:
members = list_gcode_members(archive)
if not members:
raise ValueError(f"No Metadata/plate_N.gcode member was found in {job.source_3mf}")
# 读取板层切片元数据(预测时间、重量等)
plate_metadata = read_slice_plate_metadata(archive)
# 读取耗材用量元数据
filament_metadata = read_filament_metadata(archive)
sources: list[PlateSource] = []
for member in members:
@@ -46,6 +177,7 @@ def load_plate_sources(job: PlateJob) -> list[PlateSource]:
text = raw.decode("utf-8-sig", errors="replace")
metadata = plate_metadata.get(plate_index, {})
filament = filament_metadata.get(plate_index, {})
# 重量优先取 metadata.weight,其次取 filament.used_g
weight = safe_float(metadata.get("weight"))
used_g = safe_float(filament.get("used_g"))
sources.append(
@@ -63,6 +195,20 @@ def load_plate_sources(job: PlateJob) -> list[PlateSource]:
def expand_jobs(jobs: list[PlateJob]) -> list[PlateSource]:
"""展开 Job 列表,按 copies 数量复制每个板层为多个 PlateSource。
例如,若某 PlateJob 有 2 个 copies 且包含 3 个板层,
则展开后产生 2 * 3 = 6 个 PlateSource。
输入:
jobs: PlateJob 列表,每个可指定 copies > 0。
输出:
按顺序展开后的 PlateSource 列表。
异常:
ValueError: 若展开后为空(没有任何输入板)。
"""
expanded: list[PlateSource] = []
for job in jobs:
sources = load_plate_sources(job)
@@ -73,6 +219,10 @@ def expand_jobs(jobs: list[PlateJob]) -> list[PlateSource]:
return expanded
# ---------------------------------------------------------------------------
# G-code 加工流水线
# ---------------------------------------------------------------------------
def process_plate_gcode(
source: PlateSource,
plate_number: int,
@@ -81,19 +231,145 @@ def process_plate_gcode(
swap_gcode_text: str,
patch_config: GcodePatchConfig,
) -> str:
"""完整的 G-code 加工流水线:预处理 + 换板注入。
输入:
source: 板层数据源(含 G-code 文本)。
plate_number: 当前板号(从 1 开始)。
total_plates: 总板数。
options: 构建选项(控制板号显示、换板行为、行尾格式等)。
swap_gcode_text: 换板 G-code 模板文本。
patch_config: G-code 补丁配置。
输出:
加工完成后的单板 G-code 文本字符串。
"""
prepared = _prepare_plate_gcode(source, options, patch_config)
return _process_prepared_plate_gcode(
prepared,
plate_number,
total_plates,
options,
swap_gcode_text,
patch_config,
)
def _prepare_plate_gcode(
source: PlateSource,
options: BuildOptions,
patch_config: GcodePatchConfig,
) -> _PreparedPlateGcode:
"""G-code 预处理阶段:规范化行尾、应用补丁、提取 M73 模板。
输入:
source: 板层数据源。
options: 构建选项。
patch_config: G-code 补丁配置。
输出:
预处理后的 _PreparedPlateGcode 对象。
"""
# 第一步:统一行尾为 \n(后续处理依赖一致的换行符)
text = normalize_newlines(source.gcode_text)
# 第二步:如启用,应用用户自定义 G-code 补丁规则
if options.apply_gcode_patches:
text = apply_gcode_patches(text, patch_config)
if options.show_plate_number:
text = apply_plate_number_offset(text, plate_number)
# 第三步:如启用板号显示,提取 M73 进度模板用于后续偏移计算
m73_template = prepare_m73_offset_template(text) if options.show_plate_number else None
return _PreparedPlateGcode(text, m73_template)
def _process_prepared_plate_gcode(
prepared: _PreparedPlateGcode,
plate_number: int,
total_plates: int,
options: BuildOptions,
swap_gcode_text: str,
patch_config: GcodePatchConfig,
) -> str:
"""G-code 后处理阶段:应用 M73 板号偏移、插入换板块。
输入:
prepared: 预处理后的板层 G-code。
plate_number: 当前板号(从 1 开始)。
total_plates: 总板数。
options: 构建选项。
swap_gcode_text: 换板 G-code 模板。
patch_config: G-code 补丁配置(含 insert_before_marker 标记位置)。
输出:
最终的单板 G-code 文本字符串。
"""
text = prepared.text
# 若启用板号显示且成功提取了 M73 模板,则将 M73 进度值偏移到正确范围
if options.show_plate_number and prepared.m73_template is not None:
text = prepared.m73_template.apply(plate_number)
# 判断是否需要在当前板后插入换板逻辑:
# - swap_after_final 为 True 时最后一板也换
# - 非最后一板始终需要换板
should_swap = options.swap_after_final or plate_number < total_plates
if should_swap:
# 构建换板 G-code 块(降温 + 等待取件)
swap_block = build_swap_block(swap_gcode_text, options.cool_bed_temp, options.wait_after_eject_seconds)
# 在指定标记位置插入换板块
text = insert_swap_block(text, swap_block, patch_config.insert_before_marker)
return text.rstrip("\n") + "\n"
# ---------------------------------------------------------------------------
# 缓存机制
# ---------------------------------------------------------------------------
def _plate_gcode_cache_key(source: PlateSource) -> tuple[Path, str]:
"""生成板层 G-code 的缓存键。
同一个 3MF 文件中同一成员名的板层,其预处理结果(规范化 + 补丁 + M73 模板)
是完全相同的,因此可以安全缓存复用,避免重复处理。
输入:
source: 板层数据源。
输出:
由 (源文件绝对路径, 成员名) 组成的元组。
"""
return (source.source_3mf.resolve(strict=False), source.member_name)
# ---------------------------------------------------------------------------
# 预览图处理
# ---------------------------------------------------------------------------
def save_png_bytes(image: object) -> bytes:
"""将 PIL Image 对象编码为 PNG 字节流。
输入:
image: PIL Image 对象。
输出:
PNG 格式的字节数据。
"""
import io
output = io.BytesIO()
image.save(output, format="PNG")
return output.getvalue()
def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> bytes:
"""在预览图左下角叠加文字标签(如 "5 P" 表示 5 个板)。
自动适配系统可用字体(Windows Arial Bold / Linux DejaVu Sans Bold / macOS Arial Bold),
若全部缺失则回退到 PIL 默认字体。PIL 不可用时原样返回 PNG 字节。
输入:
png_bytes: 原始预览图 PNG 字节数据。
label: 要叠加的文字标签。
small: 是否为缩略图(True 时使用更小的字体)。
输出:
叠加文字后的 PNG 字节数据。
"""
if Image is None or ImageDraw is None:
return png_bytes
import io
@@ -103,8 +379,10 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b
except Exception:
return png_bytes
draw = ImageDraw.Draw(image)
# 根据图片尺寸和大小标志计算字体大小
size = max(16, image.width // (12 if small else 8))
font = None
# 按平台优先级尝试加载粗体系统字
for font_path in (
Path(os.environ.get("WINDIR", "")) / "Fonts" / "arialbd.ttf",
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
@@ -116,56 +394,429 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b
break
except Exception:
font = None
# 所有系统字体都失败时,使用 PIL 内置默认字体
if font is None and ImageFont is not None:
font = ImageFont.load_default()
# 文字位置:左下角,留出边距
margin = max(8, image.width // 24)
text_bbox = draw.textbbox((0, 0), label, font=font)
x = margin
y = image.height - margin - (text_bbox[3] - text_bbox[1])
# 半透明绿色文字,保证在各种背景上都可见
draw.text((x, y), label, font=font, fill=(0, 255, 0, 255))
output = io.BytesIO()
image.save(output, format="PNG")
return output.getvalue()
return save_png_bytes(image)
def preview_member_name_for_gcode_member(gcode_member: str, small: bool = False) -> str | None:
"""根据 G-code 成员名推导对应的预览图成员名。
例如 "Metadata/plate_3.gcode" -> "Metadata/plate_3.png""Metadata/plate_3_small.png"
输入:
gcode_member: G-code 成员路径,如 "Metadata/plate_1.gcode"
small: 是否请求缩略图版本。
输出:
对应的 PNG 成员路径,若不匹配正则则返回 None。
"""
match = GCODE_MEMBER_RE.match(gcode_member)
if not match:
return None
suffix = "_small" if small else ""
return f"Metadata/plate_{match.group(1)}{suffix}.png"
def preview_member_sort_key(member_name: str) -> tuple[int, int, str]:
"""生成预览图成员的排序键:先按是否为缩略图分组,再按板号排序。
输入:
member_name: ZIP 成员名称。
输出:
(is_small, plate_number, member_name.lower()) 排序元组。
"""
match = PREVIEW_MEMBER_RE.match(member_name)
plate_number = int(match.group(1)) if match else 9999
is_small = 1 if member_name.lower().endswith("_small.png") else 0
return (is_small, plate_number, member_name.lower())
def select_preview_sources(sources: list[PlateSource], max_inputs: int = MAX_COMPOSITE_PREVIEW_INPUTS) -> list[PlateSource]:
"""从板源列表中选取用于合成预览图的唯一板源(去重,限数量)。
同一个 3MF 文件只取一个代表,避免重复展示同一模型的缩略图。
输入:
sources: 全部板源列表(可能含重复)。
max_inputs: 最多选取的数量,默认 9。
输出:
去重并按原顺序截取后的 PlateSource 列表。
"""
selected: list[PlateSource] = []
seen_paths: set[Path] = set()
for source in sources:
key = source.source_3mf.resolve(strict=False)
if key in seen_paths:
continue
seen_paths.add(key)
selected.append(source)
if len(selected) >= max_inputs:
break
return selected
def source_preview_member(archive: zipfile.ZipFile, source: PlateSource, small: bool = False) -> str | None:
"""在 3MF 归档中查找与给定板源最匹配的预览图成员。
查找策略(按优先级递减):
1. 精确匹配:与 G-code 成员同板号的同名 PNG。
2. 备选尺寸:同板号但不同大小的 PNG(如请求小图但只有大图)。
3. 同类型回退:任意板号的同大小 PNG。
4. 全尺寸回退:任意板号的任意大小 PNG。
5. 通配回退:Metadata/ 下任意 PNG。
输入:
archive: 已打开的 3MF ZIP 归档。
source: 板层数据源。
small: 是否优先缩略图。
输出:
匹配的成员名,若完全找不到则返回 None。
"""
names = set(archive.namelist())
# 解析实际输出的 G-code 成员名(可能与原始成员不同)
active_member = resolve_output_gcode_member(archive, source.member_name)
# 策略1:精确匹配
preferred = preview_member_name_for_gcode_member(active_member, small)
if preferred in names:
return preferred
# 策略2:相同板号但不同尺寸
alternate = preview_member_name_for_gcode_member(active_member, not small)
if alternate in names:
return alternate
# 策略3:同大小类型的任意 PNG
candidates = [
name
for name in names
if PREVIEW_MEMBER_RE.match(name) and name.lower().endswith("_small.png") == small
]
if not candidates:
# 策略4:所有 plate_N.png
candidates = [name for name in names if PREVIEW_MEMBER_RE.match(name)]
if not candidates:
# 策略5Metadata/ 下任意 PNG
candidates = [
name
for name in names
if name.lower().startswith("metadata/") and name.lower().endswith(".png")
]
if not candidates:
return None
# 按排序键取第一个(缩略图优先,板号升序)
return sorted(candidates, key=preview_member_sort_key)[0]
def read_source_preview_image(source: PlateSource, small: bool = False) -> object | None:
"""从源 3MF 文件中读取板层预览图(PIL Image 对象)。
输入:
source: 板层数据源。
small: True 时优先读取缩略图。
输出:
RGBA 模式的 PIL Image 对象,读取失败或 PIL 不可用时返回 None。
"""
if Image is None:
return None
import io
try:
with zipfile.ZipFile(source.source_3mf, "r") as archive:
member = source_preview_member(archive, source, small)
if member is None:
return None
data = archive.read(member)
return Image.open(io.BytesIO(data)).convert("RGBA")
except Exception:
return None
def preview_grid_dimensions(count: int) -> tuple[int, int]:
"""根据图片数量计算合成预览图的最佳网格布局 (列数, 行数)。
输入:
count: 要排列的图片数量。
输出:
(columns, rows) 元组。布局规则:
- 1 张 -> (1, 1)
- 2 张 -> (2, 1) 横向排列
- 3-4 张 -> (2, 2) 田字形
- 5+ 张 -> 最多 3 列,行数向上取整
"""
if count <= 1:
return (1, 1)
if count <= 2:
return (2, 1)
if count <= 4:
return (2, 2)
return (3, (count + 2) // 3)
def resize_image_to_fit(image: object, width: int, height: int) -> object:
"""将 PIL Image 等比缩放到指定宽高范围内(保持纵横比)。
输入:
image: PIL Image 对象。
width: 目标最大宽度。
height: 目标最大高度。
输出:
缩放后的 PIL Image 副本。
"""
resized = image.copy()
# 优先使用 LANCZOS 重采样(高质量),回退到 BICUBIC
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS", Image.BICUBIC)
resized.thumbnail((max(1, width), max(1, height)), resampling)
return resized
def compose_preview_image(png_bytes: bytes, sources: list[PlateSource], label: str, small: bool = False) -> bytes:
"""将多个板源的预览图合成为一张网格拼图并叠加文字标签。
工作流程:
1. 以原始预览图为画布基础(取尺寸)。
2. 从各源文件中读取缩略图(去重限数量)。
3. 按网格布局排列缩略图到画布上。
4. 叠加文字标签。
输入:
png_bytes: 原始预览图 PNG 字节数据(用于确定画布尺寸)。
sources: 所有板源列表(内部会自动去重限数量)。
label: 要叠加的文字标签。
small: 是否使用缩略图尺寸。
输出:
合成后的 PNG 字节数据。
"""
if Image is None:
return png_bytes
import io
try:
base_image = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
except Exception:
return update_preview_label(png_bytes, label, small)
# 读取所有去重后板源的预览图
source_images = [
image
for image in (read_source_preview_image(source, small) for source in select_preview_sources(sources))
if image is not None
]
if not source_images:
# 无可用预览图,仅叠加标签
return update_preview_label(png_bytes, label, small)
# 创建透明画布,尺寸与原始预览图一致
width, height = base_image.size
canvas = Image.new("RGBA", (width, height), (255, 255, 255, 0))
columns, rows = preview_grid_dimensions(len(source_images))
# 逐张放置缩略图到网格对应位置,居中摆放
for index, image in enumerate(source_images):
column = index % columns
row = index // columns
left = width * column // columns
right = width * (column + 1) // columns
top = height * row // rows
bottom = height * (row + 1) // rows
fitted = resize_image_to_fit(image, right - left, bottom - top)
x = left + max(0, (right - left - fitted.width) // 2)
y = top + max(0, (bottom - top - fitted.height) // 2)
# 使用自身 alpha 通道粘贴,保持透明
canvas.paste(fitted, (x, y), fitted)
return update_preview_label(save_png_bytes(canvas), label, small)
# ---------------------------------------------------------------------------
# 输出成员名解析
# ---------------------------------------------------------------------------
def resolve_output_gcode_member(archive: zipfile.ZipFile, fallback_member: str) -> str:
"""解析最终输出 3MF 中应使用的 G-code 成员名。
Bambu Studio 的 model_settings.config 可能指定了 G-code 成员名,
若该成员实际存在则优先使用;否则回退到 fallback_member。
输入:
archive: 源 3MF ZIP 归档。
fallback_member: 回退成员名。
输出:
最终应使用的 G-code 成员名称。
"""
# 从 model_settings.config 读取已配置的 G-code 成员列表
configured_members = read_model_settings_gcode_members(archive)
if fallback_member in configured_members:
return fallback_member
existing_members = set(list_gcode_members(archive))
# 在已配置成员中查找实际存在的第一个
for member in configured_members:
if member in existing_members:
return member
# 都不存在则使用配置中的第一个成员名
if configured_members:
return configured_members[0]
return fallback_member
def preview_members_for_gcode_member(gcode_member: str) -> set[str]:
"""获取与给定 G-code 成员对应的所有预览图成员名集合。
输入:
gcode_member: G-code 成员路径。
输出:
包含普通预览图和缩略图成员名的集合,无法推导时返回空集。
"""
full_member = preview_member_name_for_gcode_member(gcode_member, small=False)
small_member = preview_member_name_for_gcode_member(gcode_member, small=True)
if full_member is None or small_member is None:
return set()
return {full_member, small_member}
# ---------------------------------------------------------------------------
# ZIP 写入流程
# ---------------------------------------------------------------------------
def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sources: list[PlateSource], options: BuildOptions) -> str:
"""将合并后的 G-code 写入新的 3MF 文件。
处理步骤:
1. 计算最终 G-code 的 MD5 校验和。
2. 基于源 3MF 创建新的 ZIP 归档(使用 zlib-ng 加速)。
3. 复制所有非 G-code 成员到新归档,对特定成员做处理:
- slice_info.config: 更新为多板信息。
- 预览图 PNG: 如启用标签,合成为多板拼接预览图。
4. 写入合并后的 G-code 及对应的 MD5 文件。
输入:
base_3mf: 源 3MF 文件路径。
output_3mf: 输出 3MF 文件路径。
gcode_bytes: 合并打包后的完整 G-code 字节数据。
sources: 所有板源列表。
options: 构建选项。
输出:
最终 G-code 的 MD5 十六进制字符串。
"""
# 计算 G-code 的 MD5 校验和
md5 = hashlib.md5(gcode_bytes).hexdigest()
with zipfile.ZipFile(base_3mf, "r") as src, zipfile.ZipFile(output_3mf, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=1) as dst:
for item in src.infolist():
name = item.filename
if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name):
continue
data = src.read(name)
if name == "Metadata/slice_info.config":
data = update_first_slice_info(data, sources, options)
elif options.add_preview_label and name in {"Metadata/plate_1.png", "Metadata/plate_1_small.png"}:
data = update_preview_label(data, f"{len(sources)} plates", small=name.endswith("_small.png"))
dst.writestr(item, data)
dst.writestr("Metadata/plate_1.gcode", gcode_bytes)
dst.writestr("Metadata/plate_1.gcode.md5", md5)
compress_level = normalized_zip_compress_level(options.zip_compress_level)
# 使用 zlib-ng 替换 zipfile 的默认压缩器以加速写入
with use_zlib_ng_for_zipfile():
with zipfile.ZipFile(base_3mf, "r") as src, zipfile.ZipFile(
output_3mf,
"w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=compress_level,
) as dst:
# 解析输出 G-code 成员名(可能与源文件不同)
gcode_member = resolve_output_gcode_member(src, sources[0].member_name)
# 找出对应的预览图成员,以便后续做标签叠加处理
preview_members = preview_members_for_gcode_member(gcode_member)
# 逐项复制源归档中的成员
for item in src.infolist():
name = item.filename
# 跳过旧的 G-code 和 MD5 成员(将被新的替换)
if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name):
continue
data = src.read(name)
# 更新切片信息配置,写入多板汇总数据
if name == "Metadata/slice_info.config":
data = update_first_slice_info(data, sources, options)
# 对预览图叠加板数标签
elif options.add_preview_label and name in preview_members:
data = compose_preview_image(data, sources, f"{len(sources)} P", small=name.endswith("_small.png"))
dst.writestr(item, data)
# 写入合并后的 G-code 及其 MD5 校验文件
dst.writestr(gcode_member, gcode_bytes)
dst.writestr(f"{gcode_member}.md5", md5)
return md5
# ---------------------------------------------------------------------------
# 顶层构建入口
# ---------------------------------------------------------------------------
def build_packed_3mf(jobs: list[PlateJob], options: BuildOptions) -> BuildResult:
"""顶层入口:执行完整的换板 3MF 打包流程。
整体流程:
1. 展开 Job 列表为板源列表(处理 copies)。
2. 加载换板 G-code 模板。
3. 解析 G-code 补丁配置。
4. 对每个板源依次预处理 G-code(含缓存复用)并注入换板块。
5. 拼接所有板层 G-code。
6. 写入最终 3MF 文件(含预览图合成、元数据更新)。
输入:
jobs: PlateJob 列表,每个指定源 3MF 及 copies 数量。
options: 构建选项,控制所有行为开关。
输出:
BuildResult 对象,包含输出文件路径、总板数、预测时间和重量等汇总信息。
"""
# 第一步:展开并加载所有板源
sources = expand_jobs(jobs)
# 第二步:加载换板 G-code 模板
swap_gcode_path = resolve_swap_gcode_path(options.swap_gcode, options.swap_gcode_dir)
swap_gcode_text = read_swap_gcode_file(swap_gcode_path)
# 第三步:解析 G-code 补丁配置
patch_config = parse_patch_config() if options.apply_gcode_patches else GcodePatchConfig(rules=())
processed: list[str] = []
# 第四步:G-code 加工流水线(含预处理缓存)
# 同一源文件 + 同一成员名的板层只需预处理一次,后续复用缓存结果
prepared_gcode_cache: dict[tuple[Path, str], _PreparedPlateGcode] = {}
for plate_number, source in enumerate(sources, start=1):
processed.append(process_plate_gcode(source, plate_number, len(sources), options, swap_gcode_text, patch_config))
cache_key = _plate_gcode_cache_key(source)
prepared_text = prepared_gcode_cache.get(cache_key)
if prepared_text is None:
# 缓存未命中:执行预处理(行尾规范化 + 补丁应用 + M73 模板提取)
prepared_text = _prepare_plate_gcode(source, options, patch_config)
prepared_gcode_cache[cache_key] = prepared_text
# 后处理:M73 板号偏移 + 换板块注入
processed.append(
_process_prepared_plate_gcode(
prepared_text,
plate_number,
len(sources),
options,
swap_gcode_text,
patch_config,
)
)
# 第五步:拼接所有板层 G-code
final_gcode = "\n".join(item.rstrip("\n") for item in processed) + "\n"
gcode_bytes = apply_line_ending(final_gcode, options.line_ending)
# 第六步:写入输出 3MF 文件(通过临时文件保证原子性)
output_3mf = options.output_3mf
output_3mf.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf", dir=str(output_3mf.parent)) as temp_file:
temp_path = Path(temp_file.name)
try:
md5 = write_output_3mf(sources[0].source_3mf, temp_path, gcode_bytes, sources, options)
# 原子性替换:先在临时文件完成写入,再移动到目标路径
shutil.move(str(temp_path), str(output_3mf))
finally:
# 确保清理临时文件
if temp_path.exists():
temp_path.unlink(missing_ok=True)
# 汇总统计信息
total_prediction = sum(value for value in (source.prediction_seconds for source in sources) if value is not None)
total_weight = sum(value for value in (source.weight_grams for source in sources) if value is not None)
return BuildResult(
-111
View File
@@ -1,111 +0,0 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from . import APP_NAME
from .core import (
BuildOptions,
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:
if len(values) != 2:
raise argparse.ArgumentTypeError("Each --item needs a path and a copy count.")
path = Path(values[0])
try:
copies = int(values[1])
except ValueError as exc:
raise argparse.ArgumentTypeError(f"Invalid copy count: {values[1]}") from exc
return PlateJob(path, copies)
def build_command(args: argparse.Namespace) -> int:
jobs: list[PlateJob] = []
for item in args.item or []:
jobs.append(parse_item(item))
for input_path in args.inputs or []:
jobs.append(PlateJob(Path(input_path), args.copies))
if not jobs:
raise SystemExit("No input 3MF file was provided.")
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=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,
)
result = build_packed_3mf(jobs, options)
print(f"Output: {result.output_3mf}")
print(f"Plates: {result.plate_count}")
print(f"G-code MD5: {result.gcode_md5}")
if result.total_prediction_seconds is not None:
print(f"Source print time: {int(result.total_prediction_seconds)} seconds")
if result.total_weight_grams is not None:
print(f"Source filament weight: {result.total_weight_grams:.2f} g")
return 0
def list_swap_gcode_command(args: argparse.Namespace) -> int:
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"No swap G-code files found in: {directory}")
return 0
for path in files:
print(path.name)
return 0
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="a1-swap-mod-packer", description="Pack repeated Bambu A1 SwapMod plates into one 3MF job.")
subparsers = parser.add_subparsers(dest="command", required=True)
build = subparsers.add_parser("build", help="Build a packed 3MF file.")
build.add_argument("inputs", nargs="*", help="Input 3MF files. Use --copies for the same copy count on all of them.")
build.add_argument("--item", nargs=2, action="append", metavar=("PATH", "COPIES"), help="Add an input 3MF with its own copy count. Can be used multiple times.")
build.add_argument("-o", "--output", required=True, help="Output 3MF path.")
build.add_argument("--swap-gcode", required=True, help="Swap G-code file name in swap_gcode, or an explicit file path.")
build.add_argument("--swap-gcode-dir", default=None, help=f"Template directory. Default: {default_swap_gcode_dir()}")
build.add_argument("--copies", type=int, default=1, help="Copy count for positional input files.")
build.add_argument("--cool-bed", type=int, default=45, help="Bed temperature to wait for before running the swap G-code.")
build.add_argument("--no-bed-cooldown", action="store_true", help="Do not insert M190 before the swap code.")
build.add_argument("--wait", type=int, default=45, help="Seconds to wait after plate ejection.")
build.add_argument("--show-plate-number", action="store_true", help="Add 100 hours per plate number to M73 R values.")
build.add_argument("--no-swap-after-final", action="store_true", help="Do not run the swap G-code after the last plate.")
build.add_argument("--metadata-mode", choices=("source", "sum"), default="source", help="How to write slice_info prediction and weight.")
build.add_argument("--line-ending", choices=("lf", "crlf"), default="crlf", help="Line ending for the generated G-code.")
build.add_argument("--no-preview-label", action="store_true", help="Do not add a plate-count label to the first preview image.")
build.add_argument("--no-gcode-patches", action="store_true", help=f"Do not apply editable patches from {default_patch_config_path()}.")
build.set_defaults(func=build_command)
list_cmd = subparsers.add_parser("list-swap-gcode", help="List files from the swap_gcode directory.")
list_cmd.add_argument("--swap-gcode-dir", default=None, help=f"Template directory. Default: {default_swap_gcode_dir()}")
list_cmd.set_defaults(func=list_swap_gcode_command)
return parser
def main(argv: list[str] | None = None) -> int:
parser = create_parser()
args = parser.parse_args(argv)
try:
return int(args.func(args))
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+9
View File
@@ -1,3 +1,9 @@
"""核心公开 API 模块。
作为 a1_swap_mod_packer 包的统一入口,通过 __all__ 列表集中导出
所有对外公开的符号(类、函数、常量),方便外部调用者从一个模块导入所有所需接口。
"""
from __future__ import annotations
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
@@ -33,6 +39,7 @@ from .metadata import (
)
from .models import (
DEFAULT_INSERT_BEFORE_MARKER,
DEFAULT_ZIP_COMPRESS_LEVEL,
BuildOptions,
BuildResult,
BuildSummary,
@@ -56,9 +63,11 @@ from .planning import (
three_mf_summary_from_mapping,
)
# 公开 API:集中管理所有对外的符号导出
__all__ = [
"DEFAULT_INSERT_BEFORE_MARKER",
"DEFAULT_OUTPUT_PATTERN",
"DEFAULT_ZIP_COMPRESS_LEVEL",
"GCODE_MEMBER_RE",
"M73_RE",
"MD5_MEMBER_RE",
+176 -6
View File
@@ -1,19 +1,45 @@
"""G-code 文件处理工具集。
本模块提供了 G-code 文本的读取、换行符标准化、时间/耗材格式化、
M73 剩余时间偏移计算、换板代码块构建与插入等核心功能。
所有对换板 G-code 的文本操作均通过本模块完成。"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from .models import DEFAULT_INSERT_BEFORE_MARKER, LineEnding
from .paths import default_swap_gcode_dir
# 匹配 M73 剩余时间指令,捕获 P/R 参数中的分钟数值
# 示例: M73 P10 R120 ; 表示当前进度 10%,剩余 120 分钟
M73_RE = re.compile(r"^(M73\s+P\d+\s+R)(\d+)(.*)$", re.MULTILINE)
def normalize_newlines(text: str) -> str:
"""将文本中的 CRLF 和 CR 统一转换为 LF 换行符。
参数:
text: 待处理的原始文本。
返回:
使用 LF 换行的规范化文本。
"""
return text.replace("\r\n", "\n").replace("\r", "\n")
def apply_line_ending(text: str, line_ending: LineEnding) -> bytes:
"""将文本按指定的换行符风格编码为字节串。
参数:
text: 待编码的文本。
line_ending: 目标换行风格,"lf""crlf"
返回:
使用指定换行符编码的 UTF-8 字节串。
"""
normalized = normalize_newlines(text)
if line_ending == "crlf":
normalized = normalized.replace("\n", "\r\n")
@@ -21,10 +47,35 @@ def apply_line_ending(text: str, line_ending: LineEnding) -> bytes:
def read_swap_gcode_file(path: Path) -> str:
"""读取换板 G-code 文件内容,自动处理 BOM 头。
参数:
path: G-code 文件路径。
返回:
文件的文本内容。
"""
return path.read_text(encoding="utf-8-sig", errors="replace")
def resolve_swap_gcode_path(value: Path | str, swap_gcode_dir: Path | None = None) -> Path:
"""将用户输入的文件名或路径解析为实际存在的换板 G-code 文件路径。
解析策略(按优先级):
1. 直接作为路径查找。
2. 在 swap_gcode_dir 目录下查找。
3. 无后缀名时依次尝试 .gcode, .nc, .ngc, .txt 后缀。
参数:
value: 文件名字符串或路径。
swap_gcode_dir: 默认搜索目录,为 None 时使用内置默认目录。
返回:
解析后的文件路径。
异常:
FileNotFoundError: 无法在任何位置找到该文件。
"""
raw = Path(value)
if raw.exists():
return raw
@@ -32,6 +83,7 @@ def resolve_swap_gcode_path(value: Path | str, swap_gcode_dir: Path | None = Non
candidate = search_dir / str(value)
if candidate.exists():
return candidate
# 无后缀名时自动尝试常见 G-code 后缀
if raw.suffix:
raise FileNotFoundError(f"Swap G-code file not found: {value}")
for suffix in (".gcode", ".nc", ".ngc", ".txt"):
@@ -42,16 +94,33 @@ def resolve_swap_gcode_path(value: Path | str, swap_gcode_dir: Path | None = Non
def list_swap_gcode_files(directory: Path | None = None) -> list[Path]:
"""列出指定目录下所有可用的换板 G-code 文件。
参数:
directory: 要扫描的目录,为 None 时使用内置默认目录。
返回:
按名称排序的 G-code 文件路径列表。
"""
root = directory or default_swap_gcode_dir()
if not root.exists():
return []
allowed = {".gcode", ".nc", ".ngc", ".txt"}
# 过滤掉隐藏文件(以 . 开头),只保留允许的后缀名
return sorted(path for path in root.iterdir() if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in allowed)
def format_duration(seconds: float | None) -> str:
"""将秒数格式化为易读的时间字符串。
参数:
seconds: 秒数,可为 None。
返回:
格式化后的时间字符串,如 "2h 30m""5m 20s""45s",未知则返回 "未知"
"""
if seconds is None:
return "Unknown"
return "0s"
total = max(0, int(round(seconds)))
hours, rem = divmod(total, 3600)
minutes, secs = divmod(rem, 60)
@@ -63,8 +132,17 @@ def format_duration(seconds: float | None) -> str:
def format_filament(weight_grams: float | None, used_m: float | None = None) -> str:
"""将耗材重量和长度格式化为易读字符串。
参数:
weight_grams: 耗材重量(克),可为 None。
used_m: 耗材使用长度(米),可为 None。
返回:
格式化后的耗材信息字符串,未知则返回 "未知"
"""
if weight_grams is None and used_m is None:
return "Unknown"
return "0.00 g"
parts: list[str] = []
if weight_grams is not None:
parts.append(f"{weight_grams:.2f} g")
@@ -73,34 +151,126 @@ def format_filament(weight_grams: float | None, used_m: float | None = None) ->
return " / ".join(parts)
@dataclass(frozen=True)
class M73OffsetTemplate:
"""M73 剩余时间偏移模板。
用于在合并多板 G-code 时,将各板的 M73 剩余时间递增偏移,
使合并后的 G-code 中每块板的剩余时间估算保持连续。
属性:
segments: M73 指令之间的文本片段(含首尾)。
base_remaining_minutes: 每个 M73 指令的原始剩余分钟数。
"""
segments: tuple[str, ...]
base_remaining_minutes: tuple[int, ...]
def apply(self, plate_number: int) -> str:
"""根据板号生成偏移后的 M73 指令文本。
偏移量 = plate_number * 100 * 60(单位:分钟),
即每块板增加 100 小时的剩余时间偏移。
参数:
plate_number: 板号(从 0 开始)。
返回:
应用偏移后的完整 G-code 文本片段。
"""
if not self.base_remaining_minutes:
return self.segments[0]
minute_offset = plate_number * 100 * 60
parts: list[str] = []
for index, remaining_minutes in enumerate(self.base_remaining_minutes):
parts.append(self.segments[index])
parts.append(str(remaining_minutes + minute_offset))
parts.append(self.segments[-1])
return "".join(parts)
def prepare_m73_offset_template(gcode: str) -> M73OffsetTemplate:
"""从 G-code 文本中提取 M73 剩余时间偏移模板。
参数:
gcode: 原始换板 G-code 文本。
返回:
可用于多板偏移计算的 M73OffsetTemplate 实例。
"""
segments: list[str] = []
base_remaining_minutes: list[int] = []
last_index = 0
for match in M73_RE.finditer(gcode):
# 保存 M73 指令前的文本片段
segments.append(gcode[last_index : match.start(2)])
# 提取原始剩余分钟数
base_remaining_minutes.append(int(match.group(2)))
last_index = match.end(2)
# 保存最后一段尾部文本
segments.append(gcode[last_index:])
return M73OffsetTemplate(tuple(segments), tuple(base_remaining_minutes))
def apply_plate_number_offset(gcode: str, plate_number: int) -> str:
minute_offset = plate_number * 100 * 60
"""对 G-code 应用板号偏移,更新所有 M73 剩余时间。
def replace(match: re.Match[str]) -> str:
return f"{match.group(1)}{int(match.group(2)) + minute_offset}{match.group(3)}"
参数:
gcode: 原始换板 G-code 文本。
plate_number: 板号(从 0 开始)。
return M73_RE.sub(replace, gcode)
返回:
偏移后的 G-code 文本。
"""
return prepare_m73_offset_template(gcode).apply(plate_number)
def build_swap_block(swap_gcode: str, cool_bed_temp: int | None, wait_seconds: int) -> str:
"""构建换板代码块。
由可选的热床降温 M190 指令、换板 G-code 主体和可选的暂停等待 G4 指令组成。
参数:
swap_gcode: 换板 G-code 主体内容。
cool_bed_temp: 目标热床温度(°C),为 None 时不插入降温指令。
wait_seconds: 换板后等待秒数,0 时不插入等待指令。
返回:
完整的换板代码块字符串。
"""
parts: list[str] = []
if cool_bed_temp is not None:
parts.append(f"M190 S{int(cool_bed_temp)}")
parts.append(normalize_newlines(swap_gcode).rstrip("\n"))
if wait_seconds > 0:
parts.append(f"G4 P{int(wait_seconds) * 1000}")
# 过滤空字符串后拼接,末尾加换行
return "\n".join(part for part in parts if part) + "\n"
def insert_swap_block(gcode: str, swap_block: str, insert_before_marker: str = DEFAULT_INSERT_BEFORE_MARKER) -> str:
"""在原始 G-code 中的指定标记之前插入换板代码块。
如果找不到标记,则将换板代码块追加到 G-code 末尾。
参数:
gcode: 原始 G-code 文本。
swap_block: 要插入的换板代码块。
insert_before_marker: 插入位置标记,在该标记所在行之前插入。
返回:
插入换板代码块后的 G-code 文本。
"""
text = normalize_newlines(gcode)
marker = insert_before_marker.strip()
if marker:
# 查找标记行,支持换行后的标记或文件开头的标记
marker_index = text.find("\n" + marker)
if marker_index == -1 and text.startswith(marker):
marker_index = 0
if marker_index != -1:
# 在标记行之前插入换板代码块
prefix = text[: marker_index + (1 if marker_index > 0 else 0)]
suffix = text[marker_index + (1 if marker_index > 0 else 0) :]
return prefix + swap_block + suffix
# 未找到标记时追加到末尾
return text.rstrip("\n") + "\n" + swap_block
+970 -219
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -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")
+104
View File
@@ -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"
}
+104
View File
@@ -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": "未知"
}
+133
View File
@@ -1,3 +1,9 @@
"""3MF 文件元数据读取与处理模块。
提供从 3MF 归档文件中提取切片信息耗材数据模型设置等元数据的功能
以及基于元数据生成构建摘要时间预估耗材用量等的辅助函数
"""
from __future__ import annotations
import zipfile
@@ -10,6 +16,18 @@ from .models import BuildOptions, BuildSummary, PlateJob, PlateSource, ThreeMfSu
def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]]:
"""读取 3MF 归档中各打印板的切片元数据。
Metadata/slice_info.config 中解析每个 <plate> 元素内的 <metadata> 键值对
按盘索引号 index 组织为字典
参数:
archive: 已打开的 3MF 压缩文件对象
返回:
dict[int, dict[str, str]]: plate_index -> {metadata_key: metadata_value}
若配置文件不存在则返回空字典
"""
try:
data = archive.read("Metadata/slice_info.config")
except KeyError:
@@ -23,6 +41,7 @@ def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, s
value = metadata.attrib.get("value")
if key is not None and value is not None:
plate_data[key] = value
# 使用 index 字段确定盘编号,缺失或无效时自动递增
index_text = plate_data.get("index")
try:
index = int(index_text) if index_text else len(result) + 1
@@ -33,6 +52,18 @@ def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, s
def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]]:
"""读取 3MF 归档中各打印板的耗材(filament)元数据。
Metadata/slice_info.config 中提取每个 <plate> <filament> 元素的属性
按盘索引号组织
参数:
archive: 已打开的 3MF 压缩文件对象
返回:
dict[int, dict[str, str]]: plate_index -> {filament_attr: value}
若配置文件不存在则返回空字典
"""
try:
data = archive.read("Metadata/slice_info.config")
except KeyError:
@@ -41,6 +72,7 @@ def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]
result: dict[int, dict[str, str]] = {}
for plate in root.findall("plate"):
plate_index = 1
# 先获取当前盘的 index 编号
for metadata in plate.findall("metadata"):
if metadata.attrib.get("key") == "index":
try:
@@ -54,7 +86,48 @@ def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]
return result
def read_model_settings_gcode_members(archive: zipfile.ZipFile) -> list[str]:
"""读取模型设置中配置的 G-code 文件成员名称列表。
Metadata/model_settings.config 中提取所有 plate gcode_file 元数据值
用于确定需要处理的 G-code 归档成员
参数:
archive: 已打开的 3MF 压缩文件对象
返回:
list[str]: 符合 G-code 命名规范的成员路径列表若配置不存在则返回空列表
"""
try:
data = archive.read("Metadata/model_settings.config")
except KeyError:
return []
try:
root = ET.fromstring(data.decode("utf-8-sig", errors="replace"))
except Exception:
return []
members: list[str] = []
for plate in root.findall("plate"):
for metadata in plate.findall("metadata"):
if metadata.attrib.get("key") != "gcode_file":
continue
# 规范化路径分隔符并去掉前导斜杠
value = metadata.attrib.get("value", "").strip().replace("\\", "/").lstrip("/")
if value and GCODE_MEMBER_RE.match(value):
members.append(value)
break
return members
def safe_float(value: str | None) -> float | None:
"""安全地将字符串转换为浮点数,失败时返回 None。
参数:
value: 待转换的字符串或 None
返回:
float | None: 转换成功返回浮点数否则返回 None
"""
if value is None:
return None
try:
@@ -64,6 +137,14 @@ def safe_float(value: str | None) -> float | None:
def _sum_optional(values: Iterable[float | None]) -> float | None:
"""对一组可选浮点值求和,全为 None 时返回 None。
参数:
values: 包含 float None 的可迭代对象
返回:
float | None: 有效值的总和若无任何有效值则返回 None
"""
total = 0.0
found = False
for value in values:
@@ -74,6 +155,19 @@ def _sum_optional(values: Iterable[float | None]) -> float | None:
def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary:
"""读取单个 3MF 文件的完整摘要信息。
汇总文件内所有打印板的预估时间重量耗材用量等数据
参数:
source_3mf: 3MF 源文件路径
返回:
ThreeMfSummary: 包含盘数量预估秒数重量克数耗材米数和克数的摘要对象
抛出:
ValueError: 若文件中未找到任何 G-code 成员
"""
with zipfile.ZipFile(source_3mf, "r") as archive:
members = list_gcode_members(archive)
if not members:
@@ -90,13 +184,16 @@ def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary:
plate_index = int(match.group(1)) if match else 1
metadata = plate_metadata.get(plate_index, {})
filament = filament_metadata.get(plate_index, {})
# 提取各项元数据值
prediction = safe_float(metadata.get("prediction"))
weight = safe_float(metadata.get("weight"))
used_m = safe_float(filament.get("used_m"))
used_g = safe_float(filament.get("used_g"))
predictions.append(prediction)
# 重量优先取 weight,若无则回退到 used_g
weights.append(weight if weight is not None else used_g)
used_m_values.append(used_m)
# 耗材克数优先取 used_g,若无则回退到 weight
used_g_values.append(used_g if used_g is not None else weight)
return ThreeMfSummary(
@@ -110,6 +207,17 @@ def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary:
def multiply_summary(summary: ThreeMfSummary, copies: int) -> BuildSummary:
"""将单个 3MF 摘要按指定份数倍增,生成构建摘要。
用于计算同一文件多次打印时的累计耗时和耗材用量
参数:
summary: 单个 3MF 文件的摘要对象
copies: 打印份数至少为 1
返回:
BuildSummary: 倍增后的构建摘要
"""
multiplier = max(1, int(copies))
return BuildSummary(
plate_count=summary.plate_count * multiplier,
@@ -121,6 +229,16 @@ def multiply_summary(summary: ThreeMfSummary, copies: int) -> BuildSummary:
def summarize_jobs(jobs: Iterable[PlateJob]) -> BuildSummary:
"""汇总所有换板作业的构建摘要。
遍历所有 PlateJob依次读取各自的 3MF 摘要并按份数倍增后累加
参数:
jobs: PlateJob 对象的可迭代集合
返回:
BuildSummary: 所有作业合并后的构建摘要
"""
plate_count = 0
predictions: list[float | None] = []
weights: list[float | None] = []
@@ -143,12 +261,26 @@ def summarize_jobs(jobs: Iterable[PlateJob]) -> BuildSummary:
def update_first_slice_info(base_data: bytes, sources: list[PlateSource], options: BuildOptions) -> bytes:
"""更新打包后 3MF 中首个 slice_info.config 的元数据。
根据 metadata_mode 选项决定是保留源数据还是累加所有来源的预估时间和耗材用量
参数:
base_data: 原始 slice_info.config 的字节数据
sources: 所有 PlateSource 的列表
options: 构建选项 metadata_mode 字段
返回:
bytes: 更新后的 slice_info.config 字节数据
"""
if options.metadata_mode == "source":
# 源模式:保持原始数据不变
return base_data
try:
root = ET.fromstring(base_data.decode("utf-8-sig", errors="replace"))
except Exception:
return base_data
# 累加所有来源的预估时间和耗材用量
total_prediction = sum(value for value in (source.prediction_seconds for source in sources) if value is not None)
total_weight = sum(value for value in (source.weight_grams for source in sources) if value is not None)
total_used_m = sum(value for value in (source.filament_used_m for source in sources) if value is not None)
@@ -156,6 +288,7 @@ def update_first_slice_info(base_data: bytes, sources: list[PlateSource], option
first_plate = root.find("plate")
if first_plate is None:
return base_data
# 更新第一个 plate 的 metadata 和 filament 字段
for metadata in first_plate.findall("metadata"):
key = metadata.attrib.get("key")
if key == "prediction" and total_prediction > 0:
+117 -24
View File
@@ -1,67 +1,142 @@
"""数据模型模块。
定义换板打包器所用的全部数据类类型别名和常量包括
- 板件任务 (PlateJob)
- G-code 补丁规则与配置 (GcodePatchRule / GcodePatchConfig)
- 构建选项 (BuildOptions)
- 板件来源与摘要 (PlateSource / ThreeMfSummary / BuildSummary)
- 构建结果 (BuildResult)
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
# 默认插入标记:在此行之前插入换板 G-code
DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound========="
# 默认 ZIP 压缩级别 (0-9,7 为平衡选择)
DEFAULT_ZIP_COMPRESS_LEVEL = 7
# 元数据计算模式:取源文件数值还是累加求和
MetadataMode = Literal["source", "sum"]
# 换行符风格:LF (Unix) 或 CRLF (Windows)
LineEnding = Literal["lf", "crlf"]
@dataclass(frozen=True)
class PlateJob:
"""单个板件打包任务。
Attributes:
source_3mf: 3MF 文件路径
copies: 该板件的副本数量默认为 1
"""
source_3mf: Path
copies: int = 1
@dataclass(frozen=True)
class GcodePatchRule:
"""单条 G-code 查找替换规则。
Attributes:
name: 规则名称用于识别和日志
find: 要查找的目标字符串
replace: 替换后的字符串
flag: 正则标志可选 "i" 表示忽略大小写
enabled: 是否启用该规则默认启用
max_count: 最大替换次数默认 1仅替换首次出现
"""
name: str
find: str
replace: str
flag: str = ""
enabled: bool = True
max_count: int = 1
flag: str = "" # 正则标志,例如 'i' 表示忽略大小写
enabled: bool = True # 是否启用此规则
max_count: int = 1 # 最多替换次数
@dataclass(frozen=True)
class GcodePatchConfig:
"""G-code 补丁配置,包含一组规则和插入标记。
Attributes:
rules: 补丁规则元组
insert_before_marker: 在此标记行之前插入换板 G-code
"""
rules: tuple[GcodePatchRule, ...]
insert_before_marker: str = DEFAULT_INSERT_BEFORE_MARKER
@dataclass(frozen=True)
class BuildOptions:
"""构建打包选项。
Attributes:
swap_gcode: 换板 G-code 文件路径或内容字符串
output_3mf: 输出 3MF 文件的保存路径
cool_bed_temp: 冷却热床目标温度摄氏度None 表示不等待冷却
wait_after_eject_seconds: 推板后等待秒数
show_plate_number: 是否在屏幕上显示当前板件编号
swap_after_final: 最终板件完成后是否也执行换板
metadata_mode: 元数据模式"source" 取源文件值"sum" 累加求和
line_ending: 输出 G-code 的换行符风格
add_preview_label: 是否为板件添加预览标签注释
apply_gcode_patches: 是否应用 G-code 补丁规则
swap_gcode_dir: 换板 G-code 文件所在目录默认自动检测
zip_compress_level: 3MF (ZIP) 压缩级别默认 7
"""
swap_gcode: Path | str
output_3mf: Path
cool_bed_temp: int | None = 45
wait_after_eject_seconds: int = 45
show_plate_number: bool = True
swap_after_final: bool = True
metadata_mode: MetadataMode = "source"
line_ending: LineEnding = "crlf"
add_preview_label: bool = True
apply_gcode_patches: bool = True
swap_gcode_dir: Path | None = None
cool_bed_temp: int | None = 45 # 热床冷却目标温度,None 跳过冷却
wait_after_eject_seconds: int = 45 # 推板完成后等待时间
show_plate_number: bool = True # 是否显示板件编号到屏幕
swap_after_final: bool = True # 最后一块是否也换板
metadata_mode: MetadataMode = "sum" # 元数据计算模式
line_ending: LineEnding = "crlf" # 输出换行符
add_preview_label: bool = True # 是否添加预览注释
apply_gcode_patches: bool = True # 是否启用 G-code 补丁
swap_gcode_dir: Path | None = None # 换板 G-code 所在目录
zip_compress_level: int = DEFAULT_ZIP_COMPRESS_LEVEL # ZIP 压缩级别
@dataclass
class PlateSource:
"""单板 G-code 来源数据。
Attributes:
source_3mf: 来源 3MF 文件
member_name: 3MF 内部成员名称用于正确提取 G-code
gcode_text: 提取出的 G-code 文本内容
prediction_seconds: 预估打印时间
weight_grams: 预估耗材重量
filament_used_m: 预估耗材用量
filament_used_g: 预估耗材用量部分切片软件以此字段为主
"""
source_3mf: Path
member_name: str
gcode_text: str
prediction_seconds: float | None = None
weight_grams: float | None = None
filament_used_m: float | None = None
filament_used_g: float | None = None
member_name: str # 3MF 内的成员名,用于定位 G-code
gcode_text: str # 完整的 G-code 文本
prediction_seconds: float | None = None # 预估打印时间(秒)
weight_grams: float | None = None # 预估耗材重量(克)
filament_used_m: float | None = None # 耗材用量(米)
filament_used_g: float | None = None # 耗材用量(克)
@dataclass(frozen=True)
class ThreeMfSummary:
"""单个 3MF 文件的元数据摘要。
Attributes:
source_3mf: 来源 3MF 文件
plate_count: 该文件中包含的板件数量
prediction_seconds: 预估打印时间
weight_grams: 预估耗材重量
filament_used_m: 预估耗材用量
filament_used_g: 预估耗材用量
"""
source_3mf: Path
plate_count: int
plate_count: int # 板件数量
prediction_seconds: float | None = None
weight_grams: float | None = None
filament_used_m: float | None = None
@@ -70,17 +145,35 @@ class ThreeMfSummary:
@dataclass(frozen=True)
class BuildSummary:
"""总体构建摘要。
Attributes:
plate_count: 总板件数
total_prediction_seconds: 总预估打印时间
total_weight_grams: 总耗材重量
total_filament_used_m: 总耗材用量
total_filament_used_g: 总耗材用量
"""
plate_count: int
total_prediction_seconds: float | None
total_weight_grams: float | None
total_filament_used_m: float | None
total_filament_used_g: float | None
total_prediction_seconds: float | None # 总预估时间
total_weight_grams: float | None # 总耗材重量
total_filament_used_m: float | None # 总耗材长度
total_filament_used_g: float | None # 总耗材质量
@dataclass
class BuildResult:
"""单次构建打包的最终结果。
Attributes:
output_3mf: 输出 3MF 文件路径
plate_count: 包含的板件总数
total_prediction_seconds: 总预估打印时间
total_weight_grams: 总耗材重量
gcode_md5: 最终 G-code 内容的 MD5 哈希值用于唯一性校验
"""
output_3mf: Path
plate_count: int
total_prediction_seconds: float | None
total_weight_grams: float | None
gcode_md5: str
gcode_md5: str # G-code MD5 哈希值
+69
View File
@@ -1,3 +1,9 @@
"""G-code 补丁配置解析与应用。
本模块提供了从 INI 配置文件读取 G-code 修改规则的功能
支持两种配置节格式新版 [patch.<名称>] 节和旧版 [Gcode]
补丁规则可用于在打包前对换板 G-code 进行查找替换等文本修改"""
from __future__ import annotations
import configparser
@@ -9,24 +15,53 @@ from .paths import default_patch_config_path
def parse_bool(value: str | None, default: bool = True) -> bool:
"""将 INI 配置中的字符串值解析为布尔值。
支持的值: "1", "true", "yes", "on", "enabled"不区分大小写
参数:
value: 配置字符串值可为 None
default: value None 时的默认返回值
返回:
解析后的布尔值
"""
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on", "enabled"}
def parse_patch_config(path: Path | None = None) -> GcodePatchConfig:
"""从 INI 配置文件解析 G-code 补丁配置。
支持两种配置格式:
1. 新版 [patch.<规则名>] 每个节定义一组查找/替换规则
2. 旧版 [Gcode] 通过 Postion<> / Postion<>_edit / Postion<>_flag
三联键定义规则FinishFlag 定义插入位置标记
参数:
path: 配置文件路径 None 时使用默认路径
返回:
包含所有补丁规则和插入标记的 GcodePatchConfig 实例
"""
config_path = path or default_patch_config_path()
if not config_path.exists():
return GcodePatchConfig(rules=())
# configparser 配置: 禁用插值,保持键名大小写
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.optionxform = str
parser.read(config_path, encoding="utf-8-sig")
# 读取 [swap] 节中的插入标记配置
insert_before_marker = DEFAULT_INSERT_BEFORE_MARKER
if parser.has_section("swap"):
insert_before_marker = parser.get("swap", "insert_before_marker", fallback=insert_before_marker).strip().strip('"')
rules: list[GcodePatchRule] = []
# 解析新版 [patch.<名称>] 格式的补丁规则
for section in parser.sections():
if section.lower().startswith("patch."):
enabled = parse_bool(parser.get(section, "enabled", fallback="true"), True)
@@ -38,11 +73,14 @@ def parse_patch_config(path: Path | None = None) -> GcodePatchConfig:
max_count = max(0, int(max_count_text))
except ValueError:
max_count = 1
# 仅当 find 非空且规则启用时才添加
if find and enabled:
rules.append(GcodePatchRule(section.split(".", 1)[1], find, replace, flag, enabled, max_count))
# 解析旧版 [Gcode] 格式的补丁规则(兼容旧配置文件)
if parser.has_section("Gcode"):
gcode = parser["Gcode"]
# 找出所有 Postion<键>_flag 键,提取基准键名
base_names = sorted(
key[:-5]
for key in gcode.keys()
@@ -54,33 +92,64 @@ def parse_patch_config(path: Path | None = None) -> GcodePatchConfig:
flag = gcode.get(f"{base}_flag", "").strip().strip('"')
if find and replace:
rules.append(GcodePatchRule(base, find, replace, flag, True, 1))
# FinishFlag 作为插入位置标记
marker = gcode.get("FinishFlag", "").strip().strip('"')
if marker:
insert_before_marker = marker
return GcodePatchConfig(tuple(rules), insert_before_marker)
def apply_patch_rule(text: str, rule: GcodePatchRule) -> str:
"""对 G-code 文本应用单条补丁规则。
查找替换逻辑:
- 如果规则未启用查找文本为空或 max_count 0则原样返回
- 如果指定了 flag 标记行则只在该标记行之后开始查找替换
- 每次替换后计数器递增达到 max_count 后停止
参数:
text: 待处理的 G-code 文本
rule: 要应用的补丁规则
返回:
应用规则后的 G-code 文本
"""
if not rule.enabled or not rule.find or rule.max_count == 0:
return text
lines = normalize_newlines(text).split("\n")
start_index = 0
# 如果指定了 flag 标记,找到标记行后从下一行开始查找替换
if rule.flag:
for index, line in enumerate(lines):
if line.strip() == rule.flag or rule.flag in line:
start_index = index + 1
break
replacements = 0
# 在指定范围内查找并替换匹配行
for index in range(start_index, len(lines)):
if lines[index].strip() == rule.find or lines[index] == rule.find:
lines[index] = rule.replace
replacements += 1
if replacements >= rule.max_count:
break
return "\n".join(lines)
def apply_gcode_patches(text: str, config: GcodePatchConfig) -> str:
"""对 G-code 文本依次应用配置中的所有补丁规则。
参数:
text: 待处理的 G-code 文本
config: 包含补丁规则列表的配置对象
返回:
应用所有规则后的 G-code 文本
"""
patched = normalize_newlines(text)
for rule in config.rules:
patched = apply_patch_rule(patched, rule)
+52
View File
@@ -1,26 +1,78 @@
"""路径管理模块。
提供程序根目录和关键资源路径的计算逻辑处理以下场景
- 正常 Python 运行通过 __file__ 定位
- PyInstaller/Nuitka 单文件打包通过 __compiled__ 信息定位
- PyInstaller 冻结目录模式通过 sys.frozen / sys.executable 定位
"""
from __future__ import annotations
import sys
from pathlib import Path
# 应用程序包目录名
APP_DIR_NAME = "a1_swap_mod_packer"
# 换板 G-code 文件的默认子目录名
SWAP_GCODE_DIR_NAME = "swap_gcode"
# G-code 补丁配置文件名
PATCH_CONFIG_FILE_NAME = "gcode_patches.ini"
def program_root() -> Path:
"""返回程序根目录的绝对路径。
根据运行环境自动选择定位策略
- Nuitka 编译的单文件通过 __compiled__.containing_dir 获取
- Nuitka 但无 containing_dir通过 __compiled__.original_argv0 推算
- PyInstaller 冻结模式通过 sys.executable 所在目录获取
- 正常 Python 运行基于本模块文件路径向上两级推算
Returns:
Path: 程序根目录的绝对路径
"""
# Nuitka 编译标记,单文件模式下需特殊处理
compiled = globals().get("__compiled__")
if compiled is not None and getattr(compiled, "onefile", False):
# Nuitka 提供的包含目录
containing_dir = getattr(compiled, "containing_dir", None)
if containing_dir:
return Path(containing_dir).resolve()
# 回退:通过原始可执行文件路径推算
original_argv0 = getattr(compiled, "original_argv0", None)
if original_argv0:
return Path(original_argv0).resolve().parent
# PyInstaller 冻结模式
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
# 正常 Python 解释器运行:向上两级回到项目根目录
return Path(__file__).resolve().parents[1]
def default_swap_gcode_dir() -> Path:
"""返回换板 G-code 文件的默认存放目录。
Returns:
Path: 程序根目录下的 swap_gcode 子目录
"""
return program_root() / SWAP_GCODE_DIR_NAME
def default_patch_config_path() -> Path:
"""返回 G-code 补丁配置文件的默认路径。
Returns:
Path: 程序根目录下的 gcode_patches.ini 文件路径
"""
return program_root() / PATCH_CONFIG_FILE_NAME
def user_settings_path() -> Path:
"""返回用户设置文件的路径。
Returns:
Path: 程序根目录下的 settings.json 文件路径
"""
return program_root() / "settings.json"
+107 -2
View File
@@ -1,3 +1,9 @@
"""输出规划与文件命名模块。
提供换板打包任务的输出路径解析文件命名规则摘要汇总等功能
支持基于模板的输出文件名生成以及同一批次内文件路径去重
"""
from __future__ import annotations
import re
@@ -9,13 +15,24 @@ from typing import Callable, Mapping, Sequence
from .metadata import read_3mf_summary
from .models import PlateJob, ThreeMfSummary
DEFAULT_OUTPUT_PATTERN = "{plates} Plates - {sources}.3mf"
# 默认输出文件名模板,{plates} 为总盘数,{sources} 为来源标识
DEFAULT_OUTPUT_PATTERN = "{plates}-Plates-{sources}.3mf"
# 摘要解析器类型别名,接受 Path 返回 ThreeMfSummary
SummaryResolver = Callable[[Path], ThreeMfSummary]
@dataclass(frozen=True)
class OutputSummary:
"""输出摘要数据类,汇总一次换板打包的整体统计信息。
属性:
plate_count: 总盘数含份数倍增
copy_count: 总份数
prediction_seconds: 预估总耗时不可用时为 None
weight_grams: 总重量不可用时为 None
filament_used_m: 耗材总长度不可用时为 None
"""
plate_count: int
copy_count: int
prediction_seconds: float | None = None
@@ -25,16 +42,37 @@ class OutputSummary:
@dataclass(frozen=True)
class OutputNamingOptions:
"""输出命名选项,控制生成文件的目录和命名规则。
属性:
output_directory: 输出目录路径None 表示使用第一个输入文件所在目录
filename_rule: 输出文件名模板字符串
"""
output_directory: Path | str | None = None
filename_rule: str = DEFAULT_OUTPUT_PATTERN
class SafeFormatDict(dict):
"""安全的格式化字典。
str.format_map 请求不存在的键时返回原始占位符而非抛出 KeyError
保证模板中未提供的占位符被原样保留在输出中
"""
def __missing__(self, key: str) -> str:
return "{" + key + "}"
def three_mf_summary_from_mapping(data: Mapping[str, object]) -> ThreeMfSummary:
"""从类字典的映射对象构建 ThreeMfSummary。
用于将外部传入的配置数据 GUI 表格行转换为标准摘要对象
参数:
data: 包含摘要字段的映射对象键名与 ThreeMfSummary 字段名对应
返回:
ThreeMfSummary: 解析后的摘要对象缺失字段使用默认值
"""
source_value = data.get("source_3mf")
return ThreeMfSummary(
source_3mf=Path(str(source_value)) if source_value is not None else Path(),
@@ -50,6 +88,18 @@ def summarize_jobs_for_output(
jobs: Sequence[PlateJob],
summary_resolver: SummaryResolver = read_3mf_summary,
) -> OutputSummary:
"""将一组换板作业汇总为 OutputSummary。
分别计算总盘数总份数以及各项可选的累计统计值
支持自定义的摘要解析器默认为 read_3mf_summary
参数:
jobs: PlateJob 序列
summary_resolver: 用于获取单个 3MF 摘要的可调用对象
返回:
OutputSummary: 汇总后的输出摘要
"""
plate_count = 0
copy_count = 0
prediction_total = 0.0
@@ -72,6 +122,7 @@ def summarize_jobs_for_output(
if summary.filament_used_m is not None:
used_m_total += summary.filament_used_m * copies
used_m_found = True
# 仅当至少有一个有效值时才会设置对应字段
return OutputSummary(
plate_count=plate_count,
copy_count=copy_count,
@@ -82,7 +133,20 @@ def summarize_jobs_for_output(
def sanitize_filename(file_name: str) -> str:
"""清理文件名中的非法字符,确保生成合法的文件路径。
Windows/Unix 中不允许出现在文件名中的字符替换为下划线
并去掉末尾的点和空格
参数:
file_name: 原始文件名
返回:
str: 清理后的安全文件名至少返回 "packed.3mf"
"""
# 将非法字符替换为下划线
sanitized = re.sub(r"[\\/:*?\"<>|]+", "_", file_name).strip()
# 去除末尾的点号和空格(Windows 禁止)
sanitized = sanitized.rstrip(". ")
return sanitized or "packed.3mf"
@@ -93,15 +157,35 @@ def resolve_output_path(
summary_resolver: SummaryResolver = read_3mf_summary,
now: datetime | None = None,
) -> Path:
"""根据作业列表和命名选项解析最终输出文件路径。
使用模板变量source, sources, plates, copies, date, time替换
filename_rule 中的占位符生成输出文件名
参数:
jobs: PlateJob 序列
naming: 输出命名选项
summary_resolver: 摘要解析器默认 read_3mf_summary
now: 当前时间None 时使用系统当前时间
返回:
Path: 解析后的输出文件完整路径
抛出:
ValueError: jobs 为空
"""
if not jobs:
raise ValueError("No input 3MF file was provided.")
summary = summarize_jobs_for_output(jobs, summary_resolver)
first_input = jobs[0].source_3mf
# 收集所有输入文件的主文件名(不含扩展名)
stems = [job.source_3mf.stem for job in jobs]
unique_stems = list(dict.fromkeys(stems))
unique_stems = list(dict.fromkeys(stems)) # 保持顺序的去重
source_token = first_input.stem
# 当有多个不同来源时,生成 "xxx_and_N_more" 格式的标记
sources_token = source_token if len(unique_stems) == 1 else f"{source_token}_and_{len(unique_stems) - 1}_more"
timestamp = now or datetime.now()
# 构建模板变量字典
tokens = SafeFormatDict(
source=source_token,
sources=sources_token,
@@ -112,6 +196,7 @@ def resolve_output_path(
)
pattern = naming.filename_rule.strip() or DEFAULT_OUTPUT_PATTERN
file_name = sanitize_filename(pattern.format_map(tokens))
# 确保文件扩展名为 .3mf
if not file_name.lower().endswith(".3mf"):
file_name += ".3mf"
output_dir_text = "" if naming.output_directory is None else str(naming.output_directory).strip()
@@ -120,6 +205,17 @@ def resolve_output_path(
def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path:
"""为单次运行确保输出路径唯一,通过追加递增序号避免冲突。
若目标路径已被使用则在文件名后添加 _2_3 等后缀直至唯一
参数:
path: 期望的输出路径
used_paths: 已使用路径的集合会原地修改
返回:
Path: 去重后的唯一路径
"""
resolved_key = path.resolve(strict=False)
if resolved_key not in used_paths:
used_paths.add(resolved_key)
@@ -128,6 +224,7 @@ def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path:
suffix = path.suffix
parent = path.parent
index = 2
# 递增序号直到找到未使用的路径
while True:
candidate = parent / f"{stem}_{index}{suffix}"
candidate_key = candidate.resolve(strict=False)
@@ -138,6 +235,14 @@ def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path:
def _optional_float(value: object) -> float | None:
"""安全地将任意对象转换为浮点数,失败时返回 None。
参数:
value: 待转换的任意对象
返回:
float | None: 转换成功返回浮点数否则返回 None
"""
if value is None:
return None
try:
+39
View File
@@ -0,0 +1,39 @@
@echo off
chcp 65001 >nul
setlocal
set "PY=.venv\Scripts\python.exe"
set "DIST=build\a1packer"
if not exist "%DIST%" mkdir "%DIST%"
"%PY%" -m nuitka ^
--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 ^
--remove-output ^
run_gui.py
if errorlevel 1 exit /b %ERRORLEVEL%
echo.
echo GUI build done.
set "OUTPUT=%DIST%\a1packer.dist"
robocopy "swap_gcode" "%OUTPUT%\swap_gcode" /E
if %ERRORLEVEL% GEQ 8 exit /b %ERRORLEVEL%
copy /Y "gcode_patches.ini" "%OUTPUT%\gcode_patches.ini" >nul
if errorlevel 1 exit /b %ERRORLEVEL%
copy /Y "x.png" "%OUTPUT%\x.png" >nul
if errorlevel 1 exit /b %ERRORLEVEL%
echo.
echo Build done: %OUTPUT%
echo The entire "%OUTPUT%" folder is the portable distribution.
exit /b 0
+241
View File
@@ -0,0 +1,241 @@
# A1 Swap Mod Packer 工作原理
**版本**: v0.6.0 | **日期**: 2026-07-27
## 总览
给一个源 `.3mf` 文件,设好份数和换盘 G-code,输出一个打包好的新 `.3mf`
```mermaid
flowchart LR
A["源 3MF × N 份"] --> B["① 解压读取"]
B --> C["② 按份数展开"]
C --> D["③ 预处理<br/>(换行/补丁/M73模板)"]
D --> E["④ 逐份加工<br/>(M73偏移/插入换盘块)"]
E --> F["⑤ 拼接总G-code"]
F --> G["⑥ 写入新3MF<br/>(删除旧gcode/更新元数据/合成预览/压缩)"]
G --> H["输出 3MF"]
```
---
## ① 解压读取
`.3mf` 本质是 ZIP 包。`load_plate_sources()` 打开它,提取三样东西:
| 文件名 | 内容 |
|--------|------|
| `Metadata/plate_N.gcode` | 面板 G-code 文本 |
| `Metadata/slice_info.config` | 打印时间(prediction)、重量(weight |
| `Metadata/filament` 节点 | 耗材长度(used_m)、耗材重量(used_g |
同时记下 G-code 成员的名称(比如 `Metadata/plate_1.gcode`),后面写回时要用。
---
## ② 按份数展开
`expand_jobs()` 遍历所有输入文件,每个文件按设定的份数重复展开。
例:`A.3mf` 里有 1 块板,设 5 份 → 展开成 5 个 `PlateSource`
- 都指向 `A.3mf`
- 都引用 `Metadata/plate_1.gcode`
- 各自携带同样的预测时间、耗材信息
实际上 G-code 文本是**按需读取**的——相同 `(源文件, 面板名)` 组合只读取一次,后面的副本引用已有的 `PlateSource`
---
## ③ 预处理
对每个唯一的 G-code 文本,只做一遍以下处理,结果按 `(源文件路径, 面板成员名)` 键缓存:
### 换行统一
```python
text.replace("\r\n", "\n").replace("\r", "\n")
```
### G-code 补丁(可选)
如果启用了"应用可编辑的 G-code 补丁",按 `gcode_patches.ini` 的规则做查找替换:
```
[patch.a1_start_y]
flag = G0 X128 F30000 ← 从这行之后开始找
find = G0 Y254 F3000 ← 找到这行
replace = G0 Y250 F3000 ← 替换为这行
max_count = 1 ← 最多替换 1 次
```
### M73 模板拆解(可选)
如果启用了"剩余时间板号",用正则把 G-code 中所有 `M73 P0 R10` 拆成三段:
```
前缀 "M73 P0 R" + 数字 10 + 后缀 "\n"
```
存为 `M73OffsetTemplate`,后面逐份加工时直接拼接不用再扫一遍正则。
---
## ④ 逐份加工
对展开后的每一份,依次做两件事:
### M73 偏移
第 i 份的 `M73 R` 值加上 `i × 6000` 分钟(= 100 小时):
```
第1份: M73 P0 R6010 (+6000)
第2份: M73 P0 R12010 (+12000)
第3份: M73 P0 R18010 (+18000)
```
打印机显示剩余时间时,**百位数字正好是板号**。
### 插入换盘块
`;=====printer finish sound=========` 这一行**之前**插入换盘代码块:
```gcode
M190 S45 ; 等热床降温到 45°C(可选)
[你选的换盘G-code] ; 弹射/换板动作
G4 P45000 ; 等 45 秒(可选)
```
最后一份:
- 如果关了"最后换盘" → 不插换盘块
- 如果关了"最后换盘"但前面都插了 → 最后一份正常结束
---
## ⑤ 拼接总 G-code
把所有份已经加工好的 G-code 尾行 `\n` 拼成一个大字符串。
按你选的换行符编码:
- `crlf`(默认)→ `\n` 全部替换为 `\r\n`
- `lf` → 保持 `\n`
---
## ⑥ 写入新 3MF
`write_output_3mf()` 是最后一步。它不从头创建 ZIP——而是**以第一个源 3MF 为模板**,部分覆盖:
### 删除旧的
- 所有 `Metadata/plate_N.gcode`
- 所有 `Metadata/plate_N.gcode.md5`
### 写入新的
- 把拼接好的总 G-code 写到原来的 `plate_1.gcode` 成员名(或其 MD5 对应的成员)
- 同时写入 `plate_1.gcode.md5`(总 G-code 的 MD5 哈希)
### 更新元数据(默认:累加模式)
默认累加所有重复份的预测和耗材用量,修改 `Metadata/slice_info.config`
| 字段 | 修改方式 |
|------|----------|
| prediction | 所有重复份的总和 |
| weight | 所有重复份的总和 |
| filament.used_m | 所有重复份的总长度 |
| filament.used_g | 所有重复份的总重量 |
如果选了"保留原始预测和重量",此文件原样保留。
### 合成预览图(可选)
如果启用了预览:
1. 从最多 9 个**不同输入文件**中各取一张 `plate_N.png`(优先取活动面板的预览图)
2. 按网格拼接(1/2/4/6/9 宫格)
3. 在左下角用绿色粗体字盖一个标签,如 `5 P`
合成后的 PNG 覆盖 ZIP 中原来的 `plate_1.png``plate_1_small.png`
如果源文件中没有预览图 → 保持原样,仍然尝试盖标签。
### 压缩
**zlib-ng** 替换标准库的 `zipfile.zlib`,按你选的级别(1-9,默认 7)做 ZIP Deflate 压缩。
### 原子写入
整个写入过程分两步:
1. 先写到**临时文件**`temp.N.3mf`
2. 写入成功后 `shutil.move` 到目标路径
防止构建中断导致输出文件损坏。
---
## ⑦ 返回
```python
BuildResult(
output_3mf=Path(...), # 输出文件路径
plate_count=42, # 总板数
total_prediction_seconds=36000.0, # 总预估打印时间
total_weight_grams=150.0, # 总耗材重量
gcode_md5="a1b2c3..." # 总 G-code MD5
)
```
---
## 使用方式
核心构建函数只有一个:`build_packed_3mf(jobs, options)`,GUI 在调用它之前做不同的准备工作。
```mermaid
flowchart TB
subgraph GUI["GUI 入口: run_gui.py → gui.py"]
G1["拖放/添加文件 → 表格"] --> G2["每行: 3MF路径 + 份数"]
G2 --> G3["控件值 → BuildOptions"]
G3 --> G4{"批处理模式?"}
G4 -- 合并 --> BUILD["build_packed_3mf()"]
G4 -- 独立 --> G5["逐行调 build_packed_3mf()<br/>ProcessPoolExecutor 并行"]
end
```
### GUI
窗口操作,参数靠控件选、文件靠拖放。最终也是调 `build_packed_3mf()`,准备工作包括:
#### GUI 独有的准备阶段
1. **文件管理** — 表格里每行一个源文件 + 份数,可拖放、排序、移除
2. **元数据预读** — 添加文件时就解析 `slice_info.config`,表格直接显示时间和耗材
3. **缩略图预览** — 选中行 3MF → 右侧显示 `plate_N.png` 缩略图
4. **输出路径自动算** — 默认留空,自动写到第一个输入文件旁边。文件名按规则 `{plates} Plates - {sources}.3mf` 生成
5. **设置持久化** — 控件值自动存 `settings.json`,重启恢复
#### 合并模式(默认)
所有行合并调一次 `build_packed_3mf()`
```
A.3mf 份数 3
B.3mf 份数 2 → build_packed_3mf([A,A,A,B,B]) → 5 Plates - A_and_1_more.3mf
```
#### 独立批处理模式
每行独立构建,多输出并行:
```
A.3mf 份数 3 → build_packed_3mf([A]) → 3 Plates - A.3mf
B.3mf 份数 2 → build_packed_3mf([B]) → 2 Plates - B.3mf 并行(ProcessPoolExecutor,最多 8 个进程)
C.3mf 份数 5 → build_packed_3mf([C]) → 5 Plates - C.3mf
```
构建前:
- 每行自动算各自的输出路径(文件名规则 + 去重防覆盖)
- 弹出进度日志,记录每个文件的构建结果
- 有关闭全局预览标签的选项
+31
View File
@@ -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` |
---
+138
View File
@@ -0,0 +1,138 @@
# A1 SwapMod 换板 G-code 分析
**版本**: v0.6.0 | **日期**: 2026-07-27
## 文件清单
共 5 个模板,按内容复杂度分为三类:
| 类型 | 包含文件 | 主要特征 |
|------|----------|----------|
| 类型 A — 基础弹射 | `LX_0.01.01.gcode` | G380 S3/S2 交替下压,单轮弹射,无 M400/M17 |
| 类型 B — 双轮抖板 | `LX_0.07.22.gcode` | G380 S3/S2 交替下压,双轮 Shake,无 M400/M17 |
| 类型 C — 安全换板 | `LX_1.02.26.gcode`<br>`LX_1.02.26_slow.gcode`<br>`LX_1.02.26_slow_safeZ.gcode` | G1 Z- + G380 S2 下压,M400/M17 保护,双轮 Shake |
## 类型 A — 基础弹板(LX_0.01.01
**行数**: 35 行 | **复杂度**: 最低
```
Z 轴下压(G380 S3/S2 交替)→ Y 归零 → 推到 Y266
→ Z 轴轻压(G380 S2 Z20)→ 关闭软限位(M211 X0 Y0 Z0
→ Y266→Y0 高速前推 → Z 轴回压 → 单次弹射 Y-2 F5000
→ 归位 Y150
```
**特点**
- `M211 X0 Y0 Z0` 关闭软限位,允许超行程运动(其他版本均无此行)
- 仅单轮弹射,无 Shake 抖板机制
- 使用相对坐标 `G91` 大量操作
- 无 `M400`/`M17` 保护
- 换板结束后 Z 轴停留在低位
## 类型 B — 双轮抖板(LX_0.07.22
**行数**: 100 行 | **复杂度**: 中等
```
G28 Y → Y262 → Z 轴下压(G380 S3/S2 ×4轮)
→ G4 P2000 → Y15→Y150→Y40 → G380 S2 Y264
→ Z 轴上升(G1 Z-55)→ Y120 → G28 Y → Y-1
→ 抖板第1轮(6段: 1→1.5→2→2.5→3→3.5mm 往复)
→ Y235→Y110→Y200 → G380 S2 Y264 → G4 P1000
→ Y40 → Y-2 F5000
→ 抖板第2轮(6段: 1→1.5→2→2.5→3→3.5mm 往复)
→ Y120 归位
```
**特点**
- 首次引入双轮抖板,且抖板偏移量最大(最大 3.5mm)
- G380 S3/S2 交替多轮下压确保板贴合
- 无 `M400`/`M17`
## 类型 C — 安全换板(LX_1.02.26 / slow / slow_safeZ
**行数**: 115~128 行 | **复杂度**: 最高
### C1 — 标准版(LX_1.02.26
```
G28 Y → Y262 F2000 → M400 → M17 Z0.4
→ Z 轴下压(G1 Z- + G380 S2 纯力控,4轮)
→ G4 P2000 → Y15→Y150→Y40 → G380 S2 Y264
→ Z 轴上升(G1 Z-70)→ Y200 → Y25 → Y-1 F20000 → G28 Y
→ 抖板第1轮(5段: 1→1.5→2→2.5→2.5mm 往复)
→ Y235→Y110→Y200 → G380 S2 Y264 → G4 P1000
→ Y25 → Y-2 F20000
→ 抖板第2轮(5段: 1→1.5→2→2.5→2.5mm 往复)
→ Y262→Y120 归位
```
### C2 — 慢速版(LX_1.02.26_slow
与 C1 完全相同,仅 **一处差异**
- 第2轮弹射 `Y-2 F20000``Y-2 F1000`
### C3 — 慢速安全版(LX_1.02.26_slow_safeZ
与 C2 完全相同,尾部追加 **Z 轴安全回位**
```gcode
G4 P3000 ; 驻留 3 秒
M400
G90
G1 Z30 F600 ; Z 轴升至 30mm
M400
```
> 由 QQ 群用户 **风信子号** 提供
**特点**
- 引入 `M400`(同步等待)+ `M17 Z0.4`(降低 Z 电机电流),防止碰撞损伤
- Z 轴下压从 G380 S3/S2 交替改为 G1 Z- + G380 S2 纯力控
- 抖板偏移收窄到 2.5mm(原 3.5mm
- Y262 归位提速 F1200→F2000
## 三类对比总表
| 特性 | 类型 A | 类型 B | 类型 C1 | 类型 C2 | 类型 C3 |
|------|--------|--------|---------|---------|---------|
| **M400/M17** | ❌ | ❌ | ✅ | ✅ | ✅ |
| **M211 关限位** | ✅ | ❌ | ❌ | ❌ | ❌ |
| **Z 下压方式** | G380 S3/S2 | G380 S3/S2 | G1 Z-+G380 S2 | G1 Z-+G380 S2 | G1 Z-+G380 S2 |
| **抖板轮数** | 0 | 2 | 2 | 2 | 2 |
| **抖板最大偏移** | — | 3.5mm | 2.5mm | 2.5mm | 2.5mm |
| **Z 轴上升量** | — | -55mm | -70mm | -70mm | -70mm |
| **第2轮弹射速度** | F5000 | F5000 | F20000 | F1000 | F1000 |
| **Safe Z 回位** | ❌ | ❌ | ❌ | ❌ | ✅ Z30 |
| **总行数** | 35 | 100 | 115 | 115 | 128 |
## 换板原理
A1 SwapMod 利用 **Y 轴运动 + 热床摩擦力** 来弹射和更换打印板:
- **弹射** — Y 轴推到后端 → Z 轴下压使板贴合热床 → Y 轴骤推到前端 → 惯性 + 力传感器校准 → Z 轴上升留出空间 → Y 轴高速反弹把旧板弹下
- **抖板** — 在板脱落位置做微小 Y 轴往复,通过振动确保板完全脱离
- **推板**`G380 S2 Y264` 力控推到最前端,为下一块板留出插入空间
## 关键 G-code 指令
| 指令 | 含义 |
|------|------|
| `G28 Y` | Y 轴归零(寻找限位开关) |
| `G380 S2 <目标>` | **A1 专有** — 力传感器目标位移动。以预设力矩正向推进 |
| `G380 S3 <目标>` | **A1 专有** — 力传感器碰撞退出。同向移动直到力反馈下降 |
| `M400` | 等待所有运动队列清空 |
| `M17 Z0.4` | Z 轴电机电流降至 0.4A,降低运动阻力 |
| `M211 X0 Y0 Z0` | 关闭软限位,允许超行程运动 |
| `G4 Pxxx` | 驻留 xxx 毫秒 |
| `G91` / `G90` | 相对坐标 / 绝对坐标切换 |
## 建议
推荐保留两个模板作为不同使用场景:
| 场景 | 推荐模板 | 原因 |
|------|----------|------|
| **日常推荐** | C3 — 慢速安全版 | 弹射温和、Z 轴安全回位,最不容易出问题 |
| **追求速度** | C1 — 标准版 | 弹射速度快、节拍最短 |
| **实验/备选** | A — 基础弹板 | 极简实现,适合调试 |
+1
View File
@@ -1,2 +1,3 @@
PySide6>=6.6
Pillow>=10.0
zlib-ng>=0.5
-4
View File
@@ -1,4 +0,0 @@
from a1_swap_mod_packer.cli import main
if __name__ == "__main__":
raise SystemExit(main())
+55
View File
@@ -0,0 +1,55 @@
;==========================================================
; A1 SwapMod 换板 G-code — 类型 A:基础弹板
; 特点:G380 S3/S2 交替下压、M211 关限位、单轮弹射、无抖板
;==========================================================
;======== 开始换盘 =================
; --- 阶段1:Z 轴交替下压,将打印板压紧在热床上 ---
G91 ; 切换相对坐标模式
G380 S3 Z-20 F1200 ; 力传感器碰撞退出:Z 轴下降直到力反馈减弱(碰到板)
G380 S2 Z75 F1200 ; 力传感器目标位移动:Z 轴上升 75mm 直到达到预设力值
G380 S3 Z-20 F1200 ; 再次下压碰撞检测
G380 S2 Z75 F1200 ; 再次力控上升
G380 S3 Z-20 F1200 ; 第三轮下压
G380 S2 Z75 F1200 ; 第三轮上升
G380 S3 Z-20 F1200 ; 第四轮下压(确保板完全贴合)
G1 Z5 F1200 ; Z 轴上抬 5mm 释放压力
G90 ; 切回绝对坐标模式
; --- 阶段2:Y 轴归零并推到后端 ---
G28 Y ; Y 轴归零(寻找限位开关)
G90 ; 确保绝对坐标
G1 Y266 F2000 ; 推到 Y266(后端,靠近 Z 轴立柱)
G4 P500 ; 驻留 500ms 让板稳定
; --- 阶段3Z 轴轻压校准 ---
G91 ; 切相对坐标
G380 S2 Z20 F1200 ; Z 轴力控上升 20mm,确保与板有良好接触
G90 ; 切回绝对坐标
; --- 阶段4:关闭软限位,高速前推弹板 ---
M211 X0 Y0 Z0 ; 关闭 X/Y/Z 轴软限位,允许超行程运动
G90 ; 确保绝对坐标
G1 Y266 F2000 ; 再次确认在后端位置
G1 Y0 F1000 ; 高速推到 Y0(最前端),利用惯性将板甩出
; --- 阶段5Z 轴回压 + Y 轴复位 ---
G91 ; 切相对坐标
G380 S3 Z-20 F1200 ; Z 轴碰撞退出下压,重新校准板的位置
G90 ; 切绝对坐标
G1 Y200 F2000 ; Y 轴移动到中间位置
G1 Y50 F2000 ; 减速靠前
; --- 阶段6:单次弹射 ---
G1 Y266 F2000 ; 快速推到后端 Y266
G1 Y43 F2000 ; 推到前端附近
G1 Y262 F2000 ; 再次推到后端
G1 Y-2 F5000 ; ⚡ 高速反向弹射 2mm,把旧板弹下
; --- 阶段7:归位 ---
G1 Y150 F2000 ; 回到 Y150 待机位置
G91 ; 切相对坐标(保持最终状态)
;======= 换板结束 ====================
+98 -66
View File
@@ -1,75 +1,105 @@
;start change plate
;==========================================================
; A1 SwapMod 换板 G-code — 类型 B:双轮抖板
; 特点:G380 S3/S2 交替下压、双轮 Shake 抖板(最大3.5mm偏移)、无 M400/M17
;==========================================================
G28 Y
G1 Y262 F1200
G91
G380 S3 Z-25 F1200
G380 S2 Z85 F1200
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
G90;
; 开始换板
G4 P2000
; --- 阶段1:Y 轴归零并退到后端 ---
G28 Y ; Y 轴归零(寻找限位开关)
G1 Y262 F1200 ; 推到 Y262(后端位置,靠近 Z 轴立柱)
G1 Y15 F2000
G1 Y150 F1200
G1 Y40 F1200
;G1 Y260 F3000
;The platform ensures that new plate can be added properly even when no plate are available.
G380 S2 Y264 F2000
; --- 阶段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 ; 切回绝对坐标模式
G91;
;G380 S3 Z-55 F1200
G1 Z-55 F1200
G90;
; --- 阶段3:驻留稳定 ---
G4 P2000 ; 驻留 2 秒,让板稳定贴合
G1 Y120 F4000
; --- 阶段4:前推弹板 — 利用惯性将板从热床上推离 ---
G1 Y15 F2000 ; 先推到前端附近 Y15
G1 Y150 F1200 ; 后退一段
G1 Y40 F1200 ; 再推向前端
G28 Y
;Avoiding the use of the G380 command is to prevent failure in reaching the correct position.
G1 Y-1 F1200
;G380 S3 Y-1 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
;start Shake-triggered plate replacement
G1 Y1 F100
G1 Y0 F20000
G1 Y2 F100 ; 前进 2mm
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
;end Shake-triggered plate replacement
G1 Y235 F1200
G1 Y110 F5000
G1 Y200 F1200
;G1 Y266 F1500
G380 S2 Y264 F2000
G1 Y2.5 F100 ; 前进 2.5mm
G1 Y0 F20000 ; 快速退回
G4 P500
;After retracting the build plate to the frontmost position, dwell for 1 second.
G4 P1000
G1 Y3 F100 ; 前进 3mm
G1 Y0 F10000 ; 退回(降速)
G4 P500
G1 Y40 F4000
G1 Y-2 F5000
G1 Y3.5 F100 ; 前进 3.5mm(最大振幅)
G1 Y0 F5000 ; 退回(进一步降速)
; 结束 振动触发换板
;start Shake-triggered plate replacement
; --- 阶段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
@@ -88,13 +118,15 @@ 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
;end Shake-triggered plate replacement
; 以下为注释掉的备选抖板参数
; G1 Y2.5 F100
; G1 Y0 F8000
; G4 P1000
; G1 Y3.5 F100
; G1 Y0 F5000
; 结束 振动触发换板
G1 Y120 F2000
; --- 阶段9:归位到待机位置 ---
G1 Y120 F2000 ; 回到 Y120 中间待机位置
;end change plate
; 结束换板
+101 -76
View File
@@ -1,93 +1,119 @@
;==========================================================
; A1 SwapMod 换板 G-code — 类型 C1:安全换板(标准版)
; 特点:M400/M17 保护、G1 Z- + G380 S2 纯力控下压、双轮抖板(2.5mm偏移)
;==========================================================
;start change plate
; 开始换板
G28 Y
G1 Y262 F2000
; --- 阶段1:Y 轴归零并退到后端 ---
G28 Y ; Y 轴归零(寻找限位开关)
G1 Y262 F2000 ; 推到 Y262(后端位置,速度 F2000)
M400 ; wait all motion done
M17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom
; --- 阶段2:运动保护 — 等所有运动停止 + 降低 Z 电机电流 ---
M400 ; 等待所有运动队列清空
M17 Z0.4 ; 将 Z 轴电机电流降至 0.4A,降低阻力,防止后续 Y 轴移动时 Z 轴硬怼损伤打印件
G91
G1 Z-35 F1200
G380 S2 Z95
; --- 阶段3Z 轴下压(G1 Z- + G380 S2 纯力控 ×4轮),压紧打印板 ---
G91 ; 切换相对坐标模式
G1 Z-35 F1200 ; Z 轴下降 35mm
G380 S2 Z95 ; 力控上升 95mm 到预设力值
G1 Z-25
G1 Z-25 ; Z 轴再降 25mm
G380 S2 Z90 ; 力控上升 90mm
G1 Z-25 ; 第三轮
G380 S2 Z90
G1 Z-25
G380 S2 Z90
G1 Z-30
G1 Z-30 ; 第四轮
G380 S2 Z95
G1 Z-5
G90
G4 P2000
G1 Z-5 ; Z 轴微降 5mm 释放张力
G90 ; 切回绝对坐标
G1 Y15 F800
G1 Y150 F1200
; --- 阶段4:驻留稳定 ---
G4 P2000 ; 驻留 2 秒
;This line controls how much distance the tail of the reel hangs. The smaller the value, the less distance it hangs.
G1 Y40 F1000
; --- 阶段5:前推弹板 — 利用惯性将板推离 ---
G1 Y15 F800 ; 缓慢推到前端 Y15
G1 Y150 F1200 ; 后退到 Y150
;The platform ensures that new plate can be added properly even when no plate are available.
;G1 Y260 F3000
G380 S2 Y264 F2000
; 此行控制卷料尾端的悬垂距离。值越小,悬垂越少。
G1 Y40 F1000 ; 推到 Y40
G91;
G1 Z-70 F1200
G90;
; 平台通过力控确保即使没有板也能正常装入新板
; G1 Y260 F3000 ; 注释掉:备选方案
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
;
G1 Y200 F10000
; --- 阶段6:Z 轴上撤退让 ---
G91 ; 切相对坐标
G1 Z-70 F1200 ; Z 轴下降 70mm(使喷嘴远离板面,为弹射留空间)
G90 ; 切回绝对坐标
G1 Y25 F4000
; --- 阶段7:Y 轴高速移动 + 微调到弹射起始位 ---
G1 Y200 F10000 ; 高速推到 Y200
G1 Y25 F4000 ; 减速靠前
;Avoiding the use of the G380 command is to prevent failure in reaching the correct position.
G1 Y-1 F20000
;G380 S3 Y-1 F1200
; 使用 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
G28 Y
;start Shake-triggered plate replacement
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
;end Shake-triggered plate replacement
G1 Y235 F1200
G1 Y110 F5000
G1 Y200 F1200
;G1 Y266 F1500
G380 S2 Y264 F2000
;After retracting the build plate to the frontmost position, dwell for 1 second.
G4 P1000
G1 Y25 F4000
G1 Y-2 F20000
;G1 Y-2 F1000
G1 Y1.5 F100 ; 前进 1.5mm
G1 Y0 F20000 ; 快速退回
G4 P500
;start Shake-triggered plate replacement
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
@@ -104,12 +130,11 @@ G1 Y2.5 F100
G1 Y0 F10000
G4 P500
; 结束 振动触发换板
;end Shake-triggered plate replacement
; --- 阶段10:归位 ---
; 推到前端
G1 Y262 F1500 ; 推到 Y262
G1 Y120 F2000 ; 回到 Y120 待机位置
;move to front
G1 Y262 F1500
G1 Y120 F2000
;end change plate
; 结束换板
+69 -51
View File
@@ -1,58 +1,71 @@
;==========================================================
; A1 SwapMod 换板 G-code — 类型 C2:安全换板(慢速版)
; 特点:与 C1 标准版完全相同,仅第2轮弹射速度从 F20000 降为 F1000
; 更温和的弹射,减少对板和打印机的冲击
;==========================================================
;start change plate
; 开始换板
G28 Y
G1 Y262 F2000
; --- 阶段1:Y 轴归零并退到后端 ---
G28 Y ; Y 轴归零(寻找限位开关)
G1 Y262 F2000 ; 推到 Y262(后端位置)
M400 ; wait all motion done
M17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom
; --- 阶段2:运动保护 ---
M400 ; 等待所有运动队列清空
M17 Z0.4 ; 将 Z 轴电机电流降至 0.4A,降低阻力,防止碰撞损伤打印件
G91
G1 Z-35 F1200
G380 S2 Z95
; --- 阶段3Z 轴下压(G1 Z- + G380 S2 纯力控 ×4轮)---
G91 ; 切换相对坐标
G1 Z-35 F1200 ; Z 轴下降 35mm
G380 S2 Z95 ; 力控上升 95mm
G1 Z-25
G1 Z-25 ; 第二轮
G380 S2 Z90
G1 Z-25
G1 Z-25 ; 第三轮
G380 S2 Z90
G1 Z-30
G1 Z-30 ; 第四轮
G380 S2 Z95
G1 Z-5
G90
G4 P2000
G1 Z-5 ; Z 轴微降释放张力
G90 ; 切回绝对坐标
G1 Y15 F800
G1 Y150 F1200
; --- 阶段4:驻留稳定 ---
G4 P2000 ; 驻留 2 秒
;This line controls how much distance the tail of the reel hangs. The smaller the value, the less distance it hangs.
G1 Y40 F1000
; --- 阶段5:前推弹板 ---
G1 Y15 F800 ; 缓慢推到前端 Y15
G1 Y150 F1200 ; 后退到 Y150
;The platform ensures that new plate can be added properly even when no plate are available.
;G1 Y260 F3000
G380 S2 Y264 F2000
; 此行控制卷料尾端的悬垂距离。值越小,悬垂越少。
G1 Y40 F1000 ; 推到 Y40
G91;
G1 Z-70 F1200
G90;
; 平台通过力控确保即使没有板也能正常装入新板
; G1 Y260 F3000
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
;
G1 Y200 F10000
; --- 阶段6:Z 轴上撤退让 ---
G91 ; 切相对坐标
G1 Z-70 F1200 ; Z 轴下降 70mm(为弹射留空间)
G90 ; 切回绝对坐标
G1 Y25 F4000
; --- 阶段7:Y 轴高速移动 + 微调定位 ---
G1 Y200 F10000 ; 高速推到 Y200
G1 Y25 F4000 ; 减速靠前
;Avoiding the use of the G380 command is to prevent failure in reaching the correct position.
G1 Y-1 F20000
;G380 S3 Y-1 F1200
; 使用 G1 而非 G380,防止触力传感器干扰定位
G1 Y-1 F20000 ; 高速后退 1mm
G4 P500
G4 P500 ; 驻留 500ms
G28 Y
; --- 抖板校准归零 ---
G28 Y ; Y 轴归零校准
;start Shake-triggered plate replacement
; ═══════════════════════════════════════════
; 抖板 第1轮:振动确保板完全脱离(1→2.5mm)
; ═══════════════════════════════════════════
; 开始 振动触发换板
G1 Y1 F100
G1 Y0 F20000
G4 P500
@@ -68,26 +81,32 @@ G4 P500
G1 Y2.5 F100
G1 Y0 F10000
G4 P500
;G1 Y3.5 F100
;G1 Y0 F5000
;end Shake-triggered plate replacement
; G1 Y3.5 F100 ; 注释掉:更大的振幅(备选)
; G1 Y0 F5000
; 结束 振动触发换板
; --- 阶段8:推板 + 力控校准 ---
G1 Y235 F1200
G1 Y110 F5000
G1 Y200 F1200
;G1 Y266 F1500
G380 S2 Y264 F2000
; G1 Y266 F1500
G380 S2 Y264 F2000 ; 力控推到最前端
;After retracting the build plate to the frontmost position, dwell for 1 second.
G4 P1000
; 驻留 1 秒
G4 P1000
G1 Y25 F4000
;G1 Y-2 F20000
G1 Y-2 F1000
; --- 阶段9:第2轮弹射(🐌 慢速版关键差异)---
G1 Y25 F4000 ; 推到 Y25
; G1 Y-2 F20000 ; 注释掉:原标准版高速弹射
G1 Y-2 F1000 ; 🐌 慢速弹射(F1000 vs 标准版 F20000
; 更温和,减少对板和结构的冲击
G4 P500
;start Shake-triggered plate replacement
; ═══════════════════════════════════════════
; 抖板 第2轮:再次振动(1→2.5mm)
; ═══════════════════════════════════════════
; 开始 振动触发换板
G1 Y1 F100
G1 Y0 F20000
G4 P500
@@ -104,12 +123,11 @@ G1 Y2.5 F100
G1 Y0 F10000
G4 P500
; 结束 振动触发换板
;end Shake-triggered plate replacement
;move to front
; --- 阶段10:归位 ---
; 推到前端
G1 Y262 F1500
G1 Y120 F2000 ; 回到 Y120 待机位置
G1 Y120 F2000
;end change plate
; 结束换板
+151
View File
@@ -0,0 +1,151 @@
;==========================================================
; 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 ; 再次确认到位
+244
View File
@@ -0,0 +1,244 @@
"""测试活动打印板成员(Active Plate Member)的 3MF 输出行为。
验证内容
- write_output_3mf 正确保留配置中指定的打印板 G-code 成员
- ZIP 压缩级别参数生效并正确裁剪
- 预览图拼合时仅使用前 9 个唯一输入文件
"""
from __future__ import annotations
import hashlib
import tempfile
import unittest
import zipfile
from pathlib import Path
from a1_swap_mod_packer.builder import (
normalized_zip_compress_level,
preview_members_for_gcode_member,
resolve_output_gcode_member,
select_preview_sources,
write_output_3mf,
)
from a1_swap_mod_packer.metadata import read_model_settings_gcode_members
from a1_swap_mod_packer.models import BuildOptions, PlateSource
try:
from PIL import Image
except Exception: # pragma: no cover
Image = None
class ActivePlateMemberTest(unittest.TestCase):
"""测试写入输出 3MF 时活动打印板成员的正确保留与压缩行为。"""
def test_write_output_preserves_configured_plate_member(self) -> None:
"""验证 write_output_3mf 保留配置中指定打印板对应的 G-code 成员。"""
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source_3mf = root / "plate2.3mf"
output_3mf = root / "out.3mf"
self.write_plate2_archive(source_3mf) # 创建含 plate_2 的测试 3MF
# 打开源归档,校验 plate_2 被正确识别
with zipfile.ZipFile(source_3mf, "r") as archive:
self.assertEqual(read_model_settings_gcode_members(archive), ["Metadata/plate_2.gcode"])
self.assertEqual(resolve_output_gcode_member(archive, "Metadata/plate_2.gcode"), "Metadata/plate_2.gcode")
self.assertEqual(
preview_members_for_gcode_member("Metadata/plate_2.gcode"),
{"Metadata/plate_2.png", "Metadata/plate_2_small.png"},
)
gcode_bytes = b"G1 X1\n" # 替换用的 G-code 内容
sources = [
PlateSource(
source_3mf=source_3mf,
member_name="Metadata/plate_2.gcode",
gcode_text="G1 X0\n",
)
]
options = BuildOptions(
swap_gcode="",
output_3mf=output_3mf,
add_preview_label=False,
apply_gcode_patches=False,
)
md5 = write_output_3mf(source_3mf, output_3mf, gcode_bytes, sources, options) # 执行打包输出
# 校验输出归档中仅包含 plate_2 的 G-code,且 MD5 正确
with zipfile.ZipFile(output_3mf, "r") as archive:
names = set(archive.namelist())
self.assertIn("Metadata/plate_2.gcode", names)
self.assertIn("Metadata/plate_2.gcode.md5", names)
self.assertNotIn("Metadata/plate_1.gcode", names)
self.assertEqual(archive.read("Metadata/plate_2.gcode"), gcode_bytes)
self.assertEqual(archive.read("Metadata/plate_2.gcode.md5").decode(), md5)
self.assertEqual(md5, hashlib.md5(gcode_bytes).hexdigest())
def test_write_output_honors_zip_compression_level(self) -> None:
"""验证 ZIP 压缩级别参数生效:压缩级别越高,压缩后文件越小。"""
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source_3mf = root / "plate2.3mf"
level_1_output = root / "level1.3mf"
level_7_output = root / "level7.3mf"
self.write_plate2_archive(source_3mf)
sources = [
PlateSource(
source_3mf=source_3mf,
member_name="Metadata/plate_2.gcode",
gcode_text="G1 X0\n",
)
]
# 大量重复 G-code 以便观察压缩效果
gcode_bytes = (b"G1 X123.456 Y789.012 E0.03456 ; repeated movement\n" * 20000)
# 分别以压缩级别 1 和 7 输出
for output_3mf, zip_level in ((level_1_output, 1), (level_7_output, 7)):
options = BuildOptions(
swap_gcode="",
output_3mf=output_3mf,
add_preview_label=False,
apply_gcode_patches=False,
zip_compress_level=zip_level,
)
write_output_3mf(source_3mf, output_3mf, gcode_bytes, sources, options)
# 比较两个输出文件的压缩后大小:级别 7 应更小
with zipfile.ZipFile(level_1_output, "r") as level_1_archive, zipfile.ZipFile(level_7_output, "r") as level_7_archive:
level_1_info = level_1_archive.getinfo("Metadata/plate_2.gcode")
level_7_info = level_7_archive.getinfo("Metadata/plate_2.gcode")
self.assertEqual(level_1_info.compress_type, zipfile.ZIP_DEFLATED)
self.assertEqual(level_7_info.compress_type, zipfile.ZIP_DEFLATED)
self.assertLess(level_7_info.compress_size, level_1_info.compress_size)
def test_zip_compression_level_is_clamped(self) -> None:
"""验证压缩级别参数被正确裁剪到合法范围 [1, 9]。"""
self.assertEqual(normalized_zip_compress_level(None), 7) # 默认值 → 7
self.assertEqual(normalized_zip_compress_level(0), 1) # 低于下限 → 1
self.assertEqual(normalized_zip_compress_level(10), 9) # 超出上限 → 9
def test_preview_composite_uses_first_nine_unique_input_files(self) -> None:
"""验证预览图拼合仅使用前 9 个不重复的输入文件,第 10 个被排除。"""
if Image is None:
self.skipTest("Pillow is not installed")
# 10 种不同颜色,用于验证拼合结果
colors = [
(255, 0, 0, 255),
(0, 0, 255, 255),
(255, 255, 0, 255),
(255, 0, 255, 255),
(0, 255, 255, 255),
(128, 0, 0, 255),
(0, 0, 128, 255),
(128, 128, 0, 255),
(128, 0, 128, 255),
(255, 128, 0, 255),
]
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sources: list[PlateSource] = []
# 为每种颜色创建一个独立的 3MF 源文件
for index, color in enumerate(colors):
source_3mf = root / f"source_{index}.3mf"
self.write_plate2_archive(source_3mf, image_color=color, image_size=300)
sources.append(
PlateSource(
source_3mf=source_3mf,
member_name="Metadata/plate_2.gcode",
gcode_text="G1 X0\n",
)
)
# 验证 select_preview_sources 返回恰好 9 个源
selected = select_preview_sources(sources)
self.assertEqual(len(selected), 9)
self.assertEqual(selected[-1].source_3mf.name, "source_8.3mf")
output_3mf = root / "out.3mf"
options = BuildOptions(
swap_gcode="",
output_3mf=output_3mf,
add_preview_label=True, # 启用预览标签生成
apply_gcode_patches=False,
)
write_output_3mf(sources[0].source_3mf, output_3mf, b"G1 X1\n", sources, options)
# 读取拼合后的预览图
with zipfile.ZipFile(output_3mf, "r") as archive:
image_bytes = archive.read("Metadata/plate_2.png")
import io
# 在拼合图像的 9 个采样点处检测颜色
image = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
sampled_colors = {
image.getpixel((50, 50)),
image.getpixel((150, 50)),
image.getpixel((250, 50)),
image.getpixel((50, 150)),
image.getpixel((150, 150)),
image.getpixel((250, 150)),
image.getpixel((50, 250)),
image.getpixel((150, 250)),
image.getpixel((250, 250)),
}
# 前 9 种颜色全部出现,第 10 种不出现
self.assertTrue(set(colors[:9]).issubset(sampled_colors))
self.assertNotIn(colors[9], sampled_colors)
@staticmethod
def write_plate2_archive(path: Path, image_color: tuple[int, int, int, int] | None = None, image_size: int = 128) -> None:
"""创建仅含 plate_2 有效 G-code 成员及其预览图的 3MF 测试归档。
Args:
path: 输出 3MF 文件路径
image_color: 预览图填充颜色RGBANone 则写入占位字节
image_size: 预览图尺寸正方形
"""
model_settings = """<?xml version="1.0" encoding="UTF-8"?>
<config>
<plate>
<metadata key="plater_id" value="1"/>
<metadata key="gcode_file" value=""/>
</plate>
<plate>
<metadata key="plater_id" value="2"/>
<metadata key="gcode_file" value="Metadata/plate_2.gcode"/>
</plate>
</config>
"""
slice_info = """<?xml version="1.0" encoding="UTF-8"?>
<config>
<plate>
<metadata key="index" value="2"/>
</plate>
</config>
"""
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("Metadata/model_settings.config", model_settings)
archive.writestr("Metadata/slice_info.config", slice_info)
archive.writestr("Metadata/plate_2.gcode", "G1 X0\n")
archive.writestr("Metadata/plate_2.gcode.md5", "old")
if image_color is None or Image is None:
# 无 Pillow 或不指定颜色 → 写入占位字节
archive.writestr("Metadata/plate_2.png", b"png")
archive.writestr("Metadata/plate_2_small.png", b"png")
else:
import io
# 生成纯色 PNG 预览图及其缩略图
image = Image.new("RGBA", (image_size, image_size), image_color)
output = io.BytesIO()
image.save(output, format="PNG")
archive.writestr("Metadata/plate_2.png", output.getvalue())
small = image.resize((max(1, image_size // 2), max(1, image_size // 2)))
output = io.BytesIO()
small.save(output, format="PNG")
archive.writestr("Metadata/plate_2_small.png", output.getvalue())
if __name__ == "__main__":
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
"""测试独立批量构建(Individual Batch Build)的任务调度与工作器计数。
验证内容
- 工作器数量根据任务数和 CPU 核心数合理计算
- 串行执行器保持成功任务顺序同时收集失败任务信息
"""
from __future__ import annotations
import unittest
from pathlib import Path
from unittest.mock import patch
import a1_swap_mod_packer.batch as batch
from a1_swap_mod_packer.models import BuildOptions, BuildResult, PlateJob
class IndividualBatchBuildTest(unittest.TestCase):
"""测试独立批量构建的工作器计数和运行器行为。"""
def test_worker_count_uses_task_count_and_caps_cpu_count(self) -> None:
"""验证工作器数量 = min(任务数, CPU 核心数),且受上下限约束。"""
self.assertEqual(batch.individual_batch_worker_count(0), 0) # 无任务 → 0 个工作器
self.assertEqual(batch.individual_batch_worker_count(3, max_workers=99), 3) # 任务数 < 上限 → 按任务数
self.assertEqual(batch.individual_batch_worker_count(3, max_workers=0), 1) # 上限为 0 → 退化为 1
with patch.object(batch.os, "cpu_count", return_value=32):
# 任务数 20CPU 32 核,上限 = INDIVIDUAL_BATCH_MAX_WORKERS
self.assertEqual(batch.individual_batch_worker_count(20), batch.INDIVIDUAL_BATCH_MAX_WORKERS)
with patch.object(batch.os, "cpu_count", return_value=None):
# cpu_count 返回 None → 回退为 1
self.assertEqual(batch.individual_batch_worker_count(20), 1)
def test_serial_runner_keeps_success_order_and_collects_failures(self) -> None:
"""验证串行执行器按提交顺序返回成功结果,并收集失败任务。"""
# 准备 3 个任务,其中第 2 个计划失败
tasks = [
self.task("one.3mf", "one.out.3mf", copies=2),
self.task("fail.3mf", "fail.out.3mf", copies=3),
self.task("two.3mf", "two.out.3mf", copies=4),
]
def fake_build(jobs: list[PlateJob], options: BuildOptions) -> BuildResult:
"""模拟构建:fail.3mf 触发 ValueError,其余正常返回。"""
job = jobs[0]
if job.source_3mf.name == "fail.3mf":
raise ValueError("planned failure")
return BuildResult(
output_3mf=options.output_3mf,
plate_count=job.copies,
total_prediction_seconds=None,
total_weight_grams=None,
gcode_md5=job.source_3mf.stem,
)
# 使用单工作器串行运行,验证顺序与失败收集
with patch.object(batch, "build_packed_3mf", side_effect=fake_build):
result = batch.run_individual_batch_builds(tasks, max_workers=1)
self.assertEqual(result.worker_count, 1)
# 成功结果按原始顺序排列,跳过失败项
self.assertEqual(
[item.output_3mf.name for item in result.results],
["one.out.3mf", "two.out.3mf"],
)
self.assertEqual([item.plate_count for item in result.results], [2, 4])
# 失败详情:索引、任务源文件名、错误信息
self.assertEqual(len(result.failures), 1)
self.assertEqual(result.failures[0].index, 1)
self.assertEqual(result.failures[0].job.source_3mf.name, "fail.3mf")
self.assertIn("planned failure", result.failures[0].error)
@staticmethod
def task(source_name: str, output_name: str, copies: int) -> batch.IndividualBuildTask:
"""构造独立构建任务辅助方法。
Args:
source_name: 3MF 文件名
output_name: 输出 3MF 文件名
copies: 打印板份数
Returns:
包装好的 IndividualBuildTask
"""
return batch.IndividualBuildTask(
PlateJob(Path(source_name), copies),
BuildOptions(swap_gcode=Path("swap.gcode"), output_3mf=Path(output_name)),
)
if __name__ == "__main__":
unittest.main()
+168
View File
@@ -0,0 +1,168 @@
"""测试 G-code 准备阶段缓存行为,确保重复副本不会重复执行预处理。
验证内容
- 同一 G-code 成员多次复制时补丁只应用一次
- 同一 G-code 成员多次复制时M73 偏移模板只计算一次
"""
from __future__ import annotations
import hashlib
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest.mock import patch
import a1_swap_mod_packer.builder as builder
from a1_swap_mod_packer.models import BuildOptions, GcodePatchConfig, GcodePatchRule, PlateJob
class GcodePrepareCacheTest(unittest.TestCase):
"""测试 G-code 准备阶段的缓存:补丁和 M73 模板应每成员仅执行一次。"""
def test_repeated_copies_prepare_gcode_once_per_member(self) -> None:
"""验证同一 G-code 成员多次复制时,补丁仅在首次准备时应用一次。"""
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source_3mf = root / "source.3mf"
output_3mf = root / "out.3mf"
swap_gcode = root / "swap.gcode"
self.write_archive(source_3mf) # 创建测试用 3MF 归档
swap_gcode.write_text("; swap\nG1 X5\n", encoding="utf-8")
# 配置一条简单的替换规则
patch_config = GcodePatchConfig(
rules=(
GcodePatchRule(
name="test",
find="G0 Y254 F3000",
replace="G0 Y250 F3000 ;Patched",
),
)
)
patch_calls = 0 # 记录补丁被调用次数
captured_gcode = "" # 捕获最终 G-code 内容
def count_patch_calls(text: str, config: GcodePatchConfig) -> str:
"""模拟补丁应用并累计调用次数。"""
nonlocal patch_calls
patch_calls += 1
return text.replace("G0 Y254 F3000", "G0 Y250 F3000 ;Patched", 1)
def capture_gcode(
base_3mf: Path,
output_3mf: Path,
gcode_bytes: bytes,
sources: list[builder.PlateSource],
options: BuildOptions,
) -> str:
"""截获最终写入的 G-code 字节以便断言。"""
nonlocal captured_gcode
captured_gcode = gcode_bytes.decode("utf-8")
return hashlib.md5(gcode_bytes).hexdigest()
options = BuildOptions(
swap_gcode=swap_gcode,
output_3mf=output_3mf,
apply_gcode_patches=True,
show_plate_number=False,
swap_after_final=False,
line_ending="lf",
add_preview_label=False,
)
# 构建 3 份副本,验证补丁仅调用 1 次但 3 份均被替换
with patch.object(builder, "parse_patch_config", return_value=patch_config), patch.object(
builder,
"apply_gcode_patches",
side_effect=count_patch_calls,
), patch.object(builder, "write_output_3mf", side_effect=capture_gcode):
result = builder.build_packed_3mf([PlateJob(source_3mf, 3)], options)
self.assertEqual(result.plate_count, 3) # 3 份副本
self.assertEqual(patch_calls, 1) # 补丁仅调用 1 次(缓存生效)
self.assertEqual(captured_gcode.count("G0 Y250 F3000 ;Patched"), 3) # 但 3 份都已替换
self.assertNotIn("G0 Y254 F3000", captured_gcode) # 原始行已不存在
def test_repeated_copies_prepare_m73_template_once_per_member(self) -> None:
"""验证同一 G-code 成员多次复制时,M73 偏移模板仅计算一次。"""
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source_3mf = root / "source.3mf"
output_3mf = root / "out.3mf"
swap_gcode = root / "swap.gcode"
self.write_archive(source_3mf)
swap_gcode.write_text("; swap\nG1 X5\n", encoding="utf-8")
m73_template_calls = 0 # 记录模板准备调用次数
captured_gcode = ""
original_prepare_m73 = builder.prepare_m73_offset_template # 保存原始函数引用
def count_m73_template_calls(text: str):
"""模拟 M73 模板准备并累计调用次数。"""
nonlocal m73_template_calls
m73_template_calls += 1
return original_prepare_m73(text)
def capture_gcode(
base_3mf: Path,
output_3mf: Path,
gcode_bytes: bytes,
sources: list[builder.PlateSource],
options: BuildOptions,
) -> str:
"""截获最终写入的 G-code 字节以便断言。"""
nonlocal captured_gcode
captured_gcode = gcode_bytes.decode("utf-8")
return hashlib.md5(gcode_bytes).hexdigest()
options = BuildOptions(
swap_gcode=swap_gcode,
output_3mf=output_3mf,
apply_gcode_patches=False,
show_plate_number=True, # 启用板号显示,触发 M73 偏移
swap_after_final=False,
line_ending="lf",
add_preview_label=False,
)
# 构建 3 份副本,验证模板仅准备 1 次,但每份各自偏移
with (
patch.object(builder, "prepare_m73_offset_template", side_effect=count_m73_template_calls),
patch.object(
builder,
"write_output_3mf",
side_effect=capture_gcode,
),
):
result = builder.build_packed_3mf([PlateJob(source_3mf, 3)], options)
self.assertEqual(result.plate_count, 3)
self.assertEqual(m73_template_calls, 1) # 模板仅准备 1 次
# 3 份副本的 M73 剩余时间已分别偏移
self.assertIn("M73 P0 R6010", captured_gcode)
self.assertIn("M73 P0 R12010", captured_gcode)
self.assertIn("M73 P0 R18010", captured_gcode)
@staticmethod
def write_archive(path: Path) -> None:
"""创建包含带 M73 指令和待替换行的 plate_1 G-code 的 3MF 测试归档。"""
slice_info = """<?xml version="1.0" encoding="UTF-8"?>
<config>
<plate>
<metadata key="index" value="1"/>
</plate>
</config>
"""
gcode = """G0 X128 F30000
G0 Y254 F3000
M73 P0 R10
;=====printer finish sound=========
"""
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("Metadata/slice_info.config", slice_info)
archive.writestr("Metadata/plate_1.gcode", gcode)
if __name__ == "__main__":
unittest.main()
+54
View File
@@ -0,0 +1,54 @@
"""测试 M73 偏移模板(M73 Offset Template)的生成与应用。
验证内容
- 模板应用结果与先前正则替换实现的语义一致
- 不含 M73 指令的 G-code 返回原始文本不做修改
"""
from __future__ import annotations
import unittest
from a1_swap_mod_packer.gcode import M73_RE, apply_plate_number_offset, prepare_m73_offset_template
class M73OffsetTemplateTest(unittest.TestCase):
"""测试 M73 剩余时间偏移模板的正确性。"""
def test_template_matches_previous_regex_replacement_semantics(self) -> None:
"""验证新模板方式生成的偏移结果与旧正则替换方式完全一致。"""
gcode = (
"M73 P0 R10\n"
"; M73 P0 R20\n" # 被注释的 M73 行不应匹配
" M73 P0 R30\n" # 带前导空格的 M73 行
"M73 P50 R5999 ; comment\n" # 带注释的 M73 行
"G1 X1\n" # 非 M73 行
"M73 P100 R0\n" # 剩余时间为 0 的 M73 行
)
def previous_apply(text: str, plate_number: int) -> str:
"""旧版正则替换实现:对每个匹配的 M73 行增加时间偏移。"""
minute_offset = plate_number * 100 * 60 # 每板偏移 100 分钟
def replace(match) -> str:
prefix = match.group(1)
remaining_minutes = int(match.group(2)) + minute_offset
suffix = match.group(3)
return f"{prefix}{remaining_minutes}{suffix}"
return M73_RE.sub(replace, text)
# 生成模板并与旧实现逐板对比
template = prepare_m73_offset_template(gcode)
for plate_number in (1, 2, 10):
self.assertEqual(template.apply(plate_number), previous_apply(gcode, plate_number))
self.assertEqual(apply_plate_number_offset(gcode, plate_number), previous_apply(gcode, plate_number))
def test_template_without_m73_matches_returns_original_text(self) -> None:
"""验证不含 M73 指令的 G-code 应用模板后保持原样。"""
gcode = "G1 X1\n; M73 P0 R10\n" # M73 在注释中,不应匹配
template = prepare_m73_offset_template(gcode)
self.assertEqual(template.apply(3), gcode)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -0,0 +1,22 @@
"""测试应用程序版本号的显示一致性。
验证内容
- APP_TITLE 使用统一的 __version__ 构建
"""
from __future__ import annotations
import unittest
from a1_swap_mod_packer import APP_NAME, APP_TITLE, __version__
class VersionDisplayTest(unittest.TestCase):
"""测试版本信息的一致性。"""
def test_app_title_uses_shared_version(self) -> None:
"""验证 APP_TITLE 由 APP_NAME 与 __version__ 拼接而成。"""
self.assertEqual(APP_TITLE, f"{APP_NAME} v{__version__}")
if __name__ == "__main__":
unittest.main()
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB