Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9d2a0cbf1 | |||
| 5e6be787c7 | |||
| 2438eca047 | |||
| f066ca24fa | |||
| 116dcfe45f | |||
| db517b69f7 | |||
| ed02b48d80 | |||
| 68aa056359 | |||
| 2fed301a6c | |||
| dd79918940 | |||
| 015f2636b3 | |||
| 935665e7e8 | |||
| ca436b577b | |||
| f6b118293c | |||
| 51c105b8bb | |||
| 6c2aa50193 | |||
| 28bdcddf44 | |||
| dea88feb7f | |||
| e204ce145e | |||
| 2d86a3fa0d | |||
| 8d7811aeb5 | |||
| 74f751a2d5 | |||
| 4c430f5984 | |||
| dc1ddb5f04 | |||
| d1598d2a5a | |||
| c1a3152b4c | |||
| edbbdd00a2 | |||
| 6b9fd8a93c | |||
| 8e2797dde6 | |||
| b749bdd807 | |||
| 002b8117d8 | |||
| e00e77a16a | |||
| 6a06245f19 | |||
| 4ae33cbe53 | |||
| b7a6f42189 | |||
| e9378ce7bd | |||
| 27fade244e | |||
| c5849af14e | |||
| 6ac5331932 | |||
| 4ba654ca3f | |||
| 8126a9c608 | |||
| 7dabdc676a | |||
| 26b2591786 | |||
| 8bfaa1d1fe | |||
| 61b2b7ae44 | |||
| 87167d2616 | |||
| b334b16c42 | |||
| 5f3b5c716c | |||
| 614c65b30e | |||
| 287509411a |
+3
-1
@@ -15,4 +15,6 @@ Thumbs.db
|
|||||||
|
|
||||||
*.bak
|
*.bak
|
||||||
/settings.json
|
/settings.json
|
||||||
codec.py
|
/reference
|
||||||
|
codec.py/code/
|
||||||
|
code
|
||||||
|
|||||||
@@ -1,87 +1,183 @@
|
|||||||
# A1 Swap Mod Packer
|
# 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.
|
|
||||||
|
|
||||||
## Key features
|

|
||||||
|
|
||||||
- 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
|
```bash
|
||||||
pip install -r requirements.txt
|
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
|
```bash
|
||||||
python -m a1_swap_mod_packer.gui
|
python -m a1_swap_mod_packer.gui
|
||||||
```
|
```
|
||||||
|
|
||||||
or
|
或
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python run_gui.py
|
python run_gui.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the CLI:
|
## 构建 Windows 可执行文件
|
||||||
|
|
||||||
```bash
|
仓库包含 Windows 构建脚本:
|
||||||
python -m a1_swap_mod_packer.cli --help
|
|
||||||
|
```cmd
|
||||||
|
build_win.cmd
|
||||||
```
|
```
|
||||||
|
|
||||||
or
|
脚本使用 Nuitka onefile 模式,生成如下便携发布目录:
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
python run_cli.py
|
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
|
```text
|
||||||
swap_gcode/
|
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/`.
|
当前源码版本不解码加密的或厂商模板的 G-code 归档。
|
||||||
2. Click **Refresh** next to **Swap G-code**.
|
|
||||||
3. Select the template from the dropdown.
|
|
||||||
|
|
||||||
## 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
|
```text
|
||||||
gcode_patches.ini
|
gcode_patches.ini
|
||||||
```
|
```
|
||||||
|
|
||||||
Default rule:
|
默认规则:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[patch.a1_start_y]
|
[patch.a1_start_y]
|
||||||
@@ -92,181 +188,194 @@ replace = G0 Y250 F3000 ; Patched
|
|||||||
max_count = 1
|
max_count = 1
|
||||||
```
|
```
|
||||||
|
|
||||||
Meaning:
|
含义:
|
||||||
|
|
||||||
- Search after the optional `flag` line.
|
- 从可选的 `flag` 行之后开始查找。
|
||||||
- Replace the first matching `find` line with `replace`.
|
- 将第一个匹配 `find` 的行替换为 `replace`。
|
||||||
- Apply at most `max_count` replacements.
|
- 最多替换 `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
|
```ini
|
||||||
[swap]
|
[swap]
|
||||||
insert_before_marker = ;=====printer finish sound=========
|
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.
|
- **添加 3MF**:选择一个或多个 `.3mf` 文件。
|
||||||
- Drag and drop `.3mf` files into the table.
|
- 拖放 `.3mf` 文件到表格中。
|
||||||
- Drag and drop a folder into the table. The GUI adds top-level `.3mf` files from that folder.
|
- 拖放文件夹到表格中。GUI 会添加该文件夹内所有顶层 `.3mf` 文件。
|
||||||
- **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.
|
- **应用默认份数至选中行**:将当前默认份数覆盖到选中行。
|
||||||
|
|
||||||
Columns:
|
列说明:
|
||||||
|
|
||||||
- **3MF path**: source file path.
|
- **3MF 文件**:源文件路径。
|
||||||
- **Copies**: how many times this source should be repeated.
|
- **份数**:该源文件重复的次数。
|
||||||
- **Time**: estimated print time multiplied by copies.
|
- **时间**:预估打印时间 × 份数。
|
||||||
- **Filament**: estimated filament multiplied by copies.
|
- **耗材**:预估耗材用量 × 份数。
|
||||||
|
|
||||||
The time and filament values are read from:
|
时间和耗材数据读取自:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Metadata/slice_info.config
|
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
|
```text
|
||||||
swap_gcode/
|
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
|
```gcode
|
||||||
M190 S45
|
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
|
```gcode
|
||||||
G4 P45000
|
G4 P30000
|
||||||
```
|
```
|
||||||
|
|
||||||
### Remaining-time plate number
|
### 剩余时间板号
|
||||||
|
|
||||||
When enabled, the packer offsets `M73 ... R...` remaining-time values by:
|
启用后,打包器按以下公式偏移 `M73 ... R...` 剩余时间值:
|
||||||
|
|
||||||
```text
|
```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
|
- 第 1 板:+6000 分钟
|
||||||
- Plate 2: +12000 minutes
|
- 第 2 板:+12000 分钟
|
||||||
- Plate 3: +18000 minutes
|
- 第 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
|
```text
|
||||||
gcode_patches.ini
|
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
|
```text
|
||||||
A.3mf copies 2
|
A.3mf 份数 2
|
||||||
B.3mf copies 3
|
B.3mf 份数 3
|
||||||
```
|
```
|
||||||
|
|
||||||
Output:
|
输出:
|
||||||
|
|
||||||
```text
|
```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
|
```text
|
||||||
A.3mf copies 5
|
A.3mf 份数 5
|
||||||
B.3mf copies 5
|
B.3mf 份数 5
|
||||||
C.3mf copies 5
|
C.3mf 份数 5
|
||||||
```
|
```
|
||||||
|
|
||||||
Output:
|
输出:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
5 Plates - A.3mf
|
5 Plates - A.3mf
|
||||||
@@ -274,53 +383,55 @@ Output:
|
|||||||
5 Plates - C.3mf
|
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
|
```text
|
||||||
{plates} Plates - {sources}.3mf
|
{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` |
|
| `{source}` | 第一个输入文件的文件名(不含 `.3mf`) |
|
||||||
| `{sources}` | Source summary. One source uses its stem; multiple sources become `first_source_and_N_more` |
|
| `{sources}` | 源文件摘要。单个源使用其文件名;多个源则显示为 `first_source_and_N_more` |
|
||||||
| `{plates}` | Total plate count in this output |
|
| `{plates}` | 本输出中的总板数 |
|
||||||
| `{copies}` | Total copy count in this output |
|
| `{copies}` | 本输出中的总份数 |
|
||||||
| `{date}` | Current date as `YYYYMMDD` |
|
| `{date}` | 当前日期,格式 `YYYYMMDD` |
|
||||||
| `{time}` | Current time as `HHMMSS` |
|
| `{time}` | 当前时间,格式 `HHMMSS` |
|
||||||
|
|
||||||
In individual batch mode, tokens are calculated per input row.
|
在独立批处理模式下,标记按每行输入单独计算。
|
||||||
|
|
||||||
Examples:
|
示例:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
{plates} Plates - {sources}.3mf
|
{plates} Plates - {sources}.3mf
|
||||||
@@ -328,72 +439,29 @@ SwapMod - {source} - x{copies}.3mf
|
|||||||
{date}_{time}_{source}.3mf
|
{date}_{time}_{source}.3mf
|
||||||
```
|
```
|
||||||
|
|
||||||
## GUI settings
|
## GUI 设置
|
||||||
|
|
||||||
The GUI writes settings to the program folder:
|
GUI 将设置写入程序目录:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
settings.json
|
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 压缩级别。
|
||||||
|
- 独立批处理模式。
|
||||||
|
- 输入处理选项。
|
||||||
|
- 构建成功后清空输入列表。
|
||||||
|
- 输出目录。
|
||||||
|
- 输出文件名规则。
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
__version__ = "0.4.0"
|
"""A1 Swap Mod Packer 主包入口模块。
|
||||||
|
|
||||||
|
定义应用程序的名称和版本信息,供其他模块引用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 应用程序显示名称
|
||||||
APP_NAME = "A1 Swap Mod Packer"
|
APP_NAME = "A1 Swap Mod Packer"
|
||||||
|
# 语义化版本号
|
||||||
|
__version__ = "0.6.1"
|
||||||
|
# 窗口标题:应用名称 + 版本号
|
||||||
|
APP_TITLE = f"{APP_NAME} v{__version__}"
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
|
"""ZIP 归档文件中 G-code 成员的读取与解析。
|
||||||
|
|
||||||
|
本模块提供了从 .3mf 或 .zip 归档文件中列出 G-code 文件路径的功能,
|
||||||
|
主要用于识别 Metadata 目录下按 plate_<编号>.gcode 命名的换板 G-code 文件。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
# 匹配 Metadata 目录下 plate_<数字>.gcode 格式的文件名
|
||||||
GCODE_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)\.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$")
|
MD5_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode\.md5$")
|
||||||
|
|
||||||
|
|
||||||
def list_gcode_members(archive: zipfile.ZipFile) -> list[str]:
|
def list_gcode_members(archive: zipfile.ZipFile) -> list[str]:
|
||||||
|
"""列出归档文件中所有 plate G-code 成员,按板号升序排列。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
archive: 已打开的 ZIP 归档文件对象。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
按 plate 编号从小到大排序的 G-code 文件名列表。
|
||||||
|
"""
|
||||||
members: list[tuple[int, str]] = []
|
members: list[tuple[int, str]] = []
|
||||||
for name in archive.namelist():
|
for name in archive.namelist():
|
||||||
match = GCODE_MEMBER_RE.match(name)
|
match = GCODE_MEMBER_RE.match(name)
|
||||||
if match:
|
if match:
|
||||||
|
# 提取板号并存储 (板号, 文件名) 元组
|
||||||
members.append((int(match.group(1)), name))
|
members.append((int(match.group(1)), name))
|
||||||
|
# 按板号升序排列后只返回文件名
|
||||||
return [name for _, name in sorted(members)]
|
return [name for _, name in sorted(members)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
+665
-14
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
|
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
|
||||||
from .gcode import (
|
from .gcode import (
|
||||||
|
M73OffsetTemplate,
|
||||||
apply_line_ending,
|
apply_line_ending,
|
||||||
apply_plate_number_offset,
|
|
||||||
build_swap_block,
|
build_swap_block,
|
||||||
insert_swap_block,
|
insert_swap_block,
|
||||||
normalize_newlines,
|
normalize_newlines,
|
||||||
|
prepare_m73_offset_template,
|
||||||
read_swap_gcode_file,
|
read_swap_gcode_file,
|
||||||
resolve_swap_gcode_path,
|
resolve_swap_gcode_path,
|
||||||
)
|
)
|
||||||
from .metadata import read_filament_metadata, read_slice_plate_metadata, safe_float, update_first_slice_info
|
from .metadata import (
|
||||||
from .models import BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource
|
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
|
from .patches import apply_gcode_patches, parse_patch_config
|
||||||
|
|
||||||
|
# PIL(Python Imaging Library)为可选依赖,用于生成预览图标签和缩略图拼接
|
||||||
|
# 若不可用则降级跳过图片处理
|
||||||
try:
|
try:
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
except Exception: # pragma: no cover
|
except Exception: # pragma: no cover
|
||||||
@@ -28,15 +52,122 @@ except Exception: # pragma: no cover
|
|||||||
ImageDraw = None
|
ImageDraw = None
|
||||||
ImageFont = 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]:
|
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:
|
if job.copies < 1:
|
||||||
raise ValueError(f"Invalid copy count for {job.source_3mf}: {job.copies}")
|
raise ValueError(f"Invalid copy count for {job.source_3mf}: {job.copies}")
|
||||||
with zipfile.ZipFile(job.source_3mf, "r") as archive:
|
with zipfile.ZipFile(job.source_3mf, "r") as archive:
|
||||||
members = list_gcode_members(archive)
|
members = list_gcode_members(archive)
|
||||||
if not members:
|
if not members:
|
||||||
raise ValueError(f"No Metadata/plate_N.gcode member was found in {job.source_3mf}")
|
raise ValueError(f"No Metadata/plate_N.gcode member was found in {job.source_3mf}")
|
||||||
|
# 读取板层切片元数据(预测时间、重量等)
|
||||||
plate_metadata = read_slice_plate_metadata(archive)
|
plate_metadata = read_slice_plate_metadata(archive)
|
||||||
|
# 读取耗材用量元数据
|
||||||
filament_metadata = read_filament_metadata(archive)
|
filament_metadata = read_filament_metadata(archive)
|
||||||
sources: list[PlateSource] = []
|
sources: list[PlateSource] = []
|
||||||
for member in members:
|
for member in members:
|
||||||
@@ -46,6 +177,7 @@ def load_plate_sources(job: PlateJob) -> list[PlateSource]:
|
|||||||
text = raw.decode("utf-8-sig", errors="replace")
|
text = raw.decode("utf-8-sig", errors="replace")
|
||||||
metadata = plate_metadata.get(plate_index, {})
|
metadata = plate_metadata.get(plate_index, {})
|
||||||
filament = filament_metadata.get(plate_index, {})
|
filament = filament_metadata.get(plate_index, {})
|
||||||
|
# 重量优先取 metadata.weight,其次取 filament.used_g
|
||||||
weight = safe_float(metadata.get("weight"))
|
weight = safe_float(metadata.get("weight"))
|
||||||
used_g = safe_float(filament.get("used_g"))
|
used_g = safe_float(filament.get("used_g"))
|
||||||
sources.append(
|
sources.append(
|
||||||
@@ -63,6 +195,20 @@ def load_plate_sources(job: PlateJob) -> list[PlateSource]:
|
|||||||
|
|
||||||
|
|
||||||
def expand_jobs(jobs: list[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] = []
|
expanded: list[PlateSource] = []
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
sources = load_plate_sources(job)
|
sources = load_plate_sources(job)
|
||||||
@@ -73,6 +219,10 @@ def expand_jobs(jobs: list[PlateJob]) -> list[PlateSource]:
|
|||||||
return expanded
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# G-code 加工流水线
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def process_plate_gcode(
|
def process_plate_gcode(
|
||||||
source: PlateSource,
|
source: PlateSource,
|
||||||
plate_number: int,
|
plate_number: int,
|
||||||
@@ -81,19 +231,145 @@ def process_plate_gcode(
|
|||||||
swap_gcode_text: str,
|
swap_gcode_text: str,
|
||||||
patch_config: GcodePatchConfig,
|
patch_config: GcodePatchConfig,
|
||||||
) -> str:
|
) -> 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)
|
text = normalize_newlines(source.gcode_text)
|
||||||
|
# 第二步:如启用,应用用户自定义 G-code 补丁规则
|
||||||
if options.apply_gcode_patches:
|
if options.apply_gcode_patches:
|
||||||
text = apply_gcode_patches(text, patch_config)
|
text = apply_gcode_patches(text, patch_config)
|
||||||
if options.show_plate_number:
|
# 第三步:如启用板号显示,提取 M73 进度模板用于后续偏移计算
|
||||||
text = apply_plate_number_offset(text, plate_number)
|
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
|
should_swap = options.swap_after_final or plate_number < total_plates
|
||||||
if should_swap:
|
if should_swap:
|
||||||
|
# 构建换板 G-code 块(降温 + 等待取件)
|
||||||
swap_block = build_swap_block(swap_gcode_text, options.cool_bed_temp, options.wait_after_eject_seconds)
|
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)
|
text = insert_swap_block(text, swap_block, patch_config.insert_before_marker)
|
||||||
return text.rstrip("\n") + "\n"
|
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:
|
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:
|
if Image is None or ImageDraw is None:
|
||||||
return png_bytes
|
return png_bytes
|
||||||
import io
|
import io
|
||||||
@@ -103,8 +379,10 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b
|
|||||||
except Exception:
|
except Exception:
|
||||||
return png_bytes
|
return png_bytes
|
||||||
draw = ImageDraw.Draw(image)
|
draw = ImageDraw.Draw(image)
|
||||||
|
# 根据图片尺寸和大小标志计算字体大小
|
||||||
size = max(16, image.width // (12 if small else 8))
|
size = max(16, image.width // (12 if small else 8))
|
||||||
font = None
|
font = None
|
||||||
|
# 按平台优先级尝试加载粗体系统字
|
||||||
for font_path in (
|
for font_path in (
|
||||||
Path(os.environ.get("WINDIR", "")) / "Fonts" / "arialbd.ttf",
|
Path(os.environ.get("WINDIR", "")) / "Fonts" / "arialbd.ttf",
|
||||||
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.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
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
font = None
|
font = None
|
||||||
|
# 所有系统字体都失败时,使用 PIL 内置默认字体
|
||||||
if font is None and ImageFont is not None:
|
if font is None and ImageFont is not None:
|
||||||
font = ImageFont.load_default()
|
font = ImageFont.load_default()
|
||||||
|
# 文字位置:左下角,留出边距
|
||||||
margin = max(8, image.width // 24)
|
margin = max(8, image.width // 24)
|
||||||
text_bbox = draw.textbbox((0, 0), label, font=font)
|
text_bbox = draw.textbbox((0, 0), label, font=font)
|
||||||
x = margin
|
x = margin
|
||||||
y = image.height - margin - (text_bbox[3] - text_bbox[1])
|
y = image.height - margin - (text_bbox[3] - text_bbox[1])
|
||||||
|
# 半透明绿色文字,保证在各种背景上都可见
|
||||||
draw.text((x, y), label, font=font, fill=(0, 255, 0, 255))
|
draw.text((x, y), label, font=font, fill=(0, 255, 0, 255))
|
||||||
output = io.BytesIO()
|
return save_png_bytes(image)
|
||||||
image.save(output, format="PNG")
|
|
||||||
return output.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
# 策略5:Metadata/ 下任意 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:
|
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()
|
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:
|
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():
|
for item in src.infolist():
|
||||||
name = item.filename
|
name = item.filename
|
||||||
|
# 跳过旧的 G-code 和 MD5 成员(将被新的替换)
|
||||||
if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name):
|
if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name):
|
||||||
continue
|
continue
|
||||||
data = src.read(name)
|
data = src.read(name)
|
||||||
|
# 更新切片信息配置,写入多板汇总数据
|
||||||
if name == "Metadata/slice_info.config":
|
if name == "Metadata/slice_info.config":
|
||||||
data = update_first_slice_info(data, sources, options)
|
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"))
|
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)
|
dst.writestr(item, data)
|
||||||
dst.writestr("Metadata/plate_1.gcode", gcode_bytes)
|
# 写入合并后的 G-code 及其 MD5 校验文件
|
||||||
dst.writestr("Metadata/plate_1.gcode.md5", md5)
|
dst.writestr(gcode_member, gcode_bytes)
|
||||||
|
dst.writestr(f"{gcode_member}.md5", md5)
|
||||||
return md5
|
return md5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 顶层构建入口
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def build_packed_3mf(jobs: list[PlateJob], options: BuildOptions) -> BuildResult:
|
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)
|
sources = expand_jobs(jobs)
|
||||||
|
# 第二步:加载换板 G-code 模板
|
||||||
swap_gcode_path = resolve_swap_gcode_path(options.swap_gcode, options.swap_gcode_dir)
|
swap_gcode_path = resolve_swap_gcode_path(options.swap_gcode, options.swap_gcode_dir)
|
||||||
swap_gcode_text = read_swap_gcode_file(swap_gcode_path)
|
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=())
|
patch_config = parse_patch_config() if options.apply_gcode_patches else GcodePatchConfig(rules=())
|
||||||
processed: list[str] = []
|
processed: list[str] = []
|
||||||
|
# 第四步:G-code 加工流水线(含预处理缓存)
|
||||||
|
# 同一源文件 + 同一成员名的板层只需预处理一次,后续复用缓存结果
|
||||||
|
prepared_gcode_cache: dict[tuple[Path, str], _PreparedPlateGcode] = {}
|
||||||
for plate_number, source in enumerate(sources, start=1):
|
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"
|
final_gcode = "\n".join(item.rstrip("\n") for item in processed) + "\n"
|
||||||
gcode_bytes = apply_line_ending(final_gcode, options.line_ending)
|
gcode_bytes = apply_line_ending(final_gcode, options.line_ending)
|
||||||
|
# 第六步:写入输出 3MF 文件(通过临时文件保证原子性)
|
||||||
output_3mf = options.output_3mf
|
output_3mf = options.output_3mf
|
||||||
output_3mf.parent.mkdir(parents=True, exist_ok=True)
|
output_3mf.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf", dir=str(output_3mf.parent)) as temp_file:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf", dir=str(output_3mf.parent)) as temp_file:
|
||||||
temp_path = Path(temp_file.name)
|
temp_path = Path(temp_file.name)
|
||||||
try:
|
try:
|
||||||
md5 = write_output_3mf(sources[0].source_3mf, temp_path, gcode_bytes, sources, options)
|
md5 = write_output_3mf(sources[0].source_3mf, temp_path, gcode_bytes, sources, options)
|
||||||
|
# 原子性替换:先在临时文件完成写入,再移动到目标路径
|
||||||
shutil.move(str(temp_path), str(output_3mf))
|
shutil.move(str(temp_path), str(output_3mf))
|
||||||
finally:
|
finally:
|
||||||
|
# 确保清理临时文件
|
||||||
if temp_path.exists():
|
if temp_path.exists():
|
||||||
temp_path.unlink(missing_ok=True)
|
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_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_weight = sum(value for value in (source.weight_grams for source in sources) if value is not None)
|
||||||
return BuildResult(
|
return BuildResult(
|
||||||
|
|||||||
@@ -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())
|
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""核心公开 API 模块。
|
||||||
|
|
||||||
|
作为 a1_swap_mod_packer 包的统一入口,通过 __all__ 列表集中导出
|
||||||
|
所有对外公开的符号(类、函数、常量),方便外部调用者从一个模块导入所有所需接口。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
|
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
|
||||||
@@ -33,6 +39,7 @@ from .metadata import (
|
|||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
DEFAULT_INSERT_BEFORE_MARKER,
|
DEFAULT_INSERT_BEFORE_MARKER,
|
||||||
|
DEFAULT_ZIP_COMPRESS_LEVEL,
|
||||||
BuildOptions,
|
BuildOptions,
|
||||||
BuildResult,
|
BuildResult,
|
||||||
BuildSummary,
|
BuildSummary,
|
||||||
@@ -56,9 +63,11 @@ from .planning import (
|
|||||||
three_mf_summary_from_mapping,
|
three_mf_summary_from_mapping,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 公开 API:集中管理所有对外的符号导出
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_INSERT_BEFORE_MARKER",
|
"DEFAULT_INSERT_BEFORE_MARKER",
|
||||||
"DEFAULT_OUTPUT_PATTERN",
|
"DEFAULT_OUTPUT_PATTERN",
|
||||||
|
"DEFAULT_ZIP_COMPRESS_LEVEL",
|
||||||
"GCODE_MEMBER_RE",
|
"GCODE_MEMBER_RE",
|
||||||
"M73_RE",
|
"M73_RE",
|
||||||
"MD5_MEMBER_RE",
|
"MD5_MEMBER_RE",
|
||||||
|
|||||||
+176
-6
@@ -1,19 +1,45 @@
|
|||||||
|
"""G-code 文件处理工具集。
|
||||||
|
|
||||||
|
本模块提供了 G-code 文本的读取、换行符标准化、时间/耗材格式化、
|
||||||
|
M73 剩余时间偏移计算、换板代码块构建与插入等核心功能。
|
||||||
|
所有对换板 G-code 的文本操作均通过本模块完成。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .models import DEFAULT_INSERT_BEFORE_MARKER, LineEnding
|
from .models import DEFAULT_INSERT_BEFORE_MARKER, LineEnding
|
||||||
from .paths import default_swap_gcode_dir
|
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)
|
M73_RE = re.compile(r"^(M73\s+P\d+\s+R)(\d+)(.*)$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
def normalize_newlines(text: str) -> str:
|
def normalize_newlines(text: str) -> str:
|
||||||
|
"""将文本中的 CRLF 和 CR 统一转换为 LF 换行符。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
text: 待处理的原始文本。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
使用 LF 换行的规范化文本。
|
||||||
|
"""
|
||||||
return text.replace("\r\n", "\n").replace("\r", "\n")
|
return text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
|
||||||
|
|
||||||
def apply_line_ending(text: str, line_ending: LineEnding) -> bytes:
|
def apply_line_ending(text: str, line_ending: LineEnding) -> bytes:
|
||||||
|
"""将文本按指定的换行符风格编码为字节串。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
text: 待编码的文本。
|
||||||
|
line_ending: 目标换行风格,"lf" 或 "crlf"。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
使用指定换行符编码的 UTF-8 字节串。
|
||||||
|
"""
|
||||||
normalized = normalize_newlines(text)
|
normalized = normalize_newlines(text)
|
||||||
if line_ending == "crlf":
|
if line_ending == "crlf":
|
||||||
normalized = normalized.replace("\n", "\r\n")
|
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:
|
def read_swap_gcode_file(path: Path) -> str:
|
||||||
|
"""读取换板 G-code 文件内容,自动处理 BOM 头。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
path: G-code 文件路径。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
文件的文本内容。
|
||||||
|
"""
|
||||||
return path.read_text(encoding="utf-8-sig", errors="replace")
|
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:
|
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)
|
raw = Path(value)
|
||||||
if raw.exists():
|
if raw.exists():
|
||||||
return raw
|
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)
|
candidate = search_dir / str(value)
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
return candidate
|
return candidate
|
||||||
|
# 无后缀名时自动尝试常见 G-code 后缀
|
||||||
if raw.suffix:
|
if raw.suffix:
|
||||||
raise FileNotFoundError(f"Swap G-code file not found: {value}")
|
raise FileNotFoundError(f"Swap G-code file not found: {value}")
|
||||||
for suffix in (".gcode", ".nc", ".ngc", ".txt"):
|
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]:
|
def list_swap_gcode_files(directory: Path | None = None) -> list[Path]:
|
||||||
|
"""列出指定目录下所有可用的换板 G-code 文件。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
directory: 要扫描的目录,为 None 时使用内置默认目录。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
按名称排序的 G-code 文件路径列表。
|
||||||
|
"""
|
||||||
root = directory or default_swap_gcode_dir()
|
root = directory or default_swap_gcode_dir()
|
||||||
if not root.exists():
|
if not root.exists():
|
||||||
return []
|
return []
|
||||||
allowed = {".gcode", ".nc", ".ngc", ".txt"}
|
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)
|
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:
|
def format_duration(seconds: float | None) -> str:
|
||||||
|
"""将秒数格式化为易读的时间字符串。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
seconds: 秒数,可为 None。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
格式化后的时间字符串,如 "2h 30m"、"5m 20s"、"45s",未知则返回 "未知"。
|
||||||
|
"""
|
||||||
if seconds is None:
|
if seconds is None:
|
||||||
return "Unknown"
|
return "0s"
|
||||||
total = max(0, int(round(seconds)))
|
total = max(0, int(round(seconds)))
|
||||||
hours, rem = divmod(total, 3600)
|
hours, rem = divmod(total, 3600)
|
||||||
minutes, secs = divmod(rem, 60)
|
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:
|
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:
|
if weight_grams is None and used_m is None:
|
||||||
return "Unknown"
|
return "0.00 g"
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if weight_grams is not None:
|
if weight_grams is not None:
|
||||||
parts.append(f"{weight_grams:.2f} g")
|
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)
|
return " / ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def apply_plate_number_offset(gcode: str, plate_number: int) -> str:
|
@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
|
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 replace(match: re.Match[str]) -> str:
|
|
||||||
return f"{match.group(1)}{int(match.group(2)) + minute_offset}{match.group(3)}"
|
|
||||||
|
|
||||||
return M73_RE.sub(replace, gcode)
|
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:
|
||||||
|
"""对 G-code 应用板号偏移,更新所有 M73 剩余时间。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
gcode: 原始换板 G-code 文本。
|
||||||
|
plate_number: 板号(从 0 开始)。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
偏移后的 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:
|
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] = []
|
parts: list[str] = []
|
||||||
if cool_bed_temp is not None:
|
if cool_bed_temp is not None:
|
||||||
parts.append(f"M190 S{int(cool_bed_temp)}")
|
parts.append(f"M190 S{int(cool_bed_temp)}")
|
||||||
parts.append(normalize_newlines(swap_gcode).rstrip("\n"))
|
parts.append(normalize_newlines(swap_gcode).rstrip("\n"))
|
||||||
if wait_seconds > 0:
|
if wait_seconds > 0:
|
||||||
parts.append(f"G4 P{int(wait_seconds) * 1000}")
|
parts.append(f"G4 P{int(wait_seconds) * 1000}")
|
||||||
|
# 过滤空字符串后拼接,末尾加换行
|
||||||
return "\n".join(part for part in parts if part) + "\n"
|
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:
|
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)
|
text = normalize_newlines(gcode)
|
||||||
marker = insert_before_marker.strip()
|
marker = insert_before_marker.strip()
|
||||||
if marker:
|
if marker:
|
||||||
|
# 查找标记行,支持换行后的标记或文件开头的标记
|
||||||
marker_index = text.find("\n" + marker)
|
marker_index = text.find("\n" + marker)
|
||||||
if marker_index == -1 and text.startswith(marker):
|
if marker_index == -1 and text.startswith(marker):
|
||||||
marker_index = 0
|
marker_index = 0
|
||||||
if marker_index != -1:
|
if marker_index != -1:
|
||||||
|
# 在标记行之前插入换板代码块
|
||||||
prefix = text[: marker_index + (1 if marker_index > 0 else 0)]
|
prefix = text[: marker_index + (1 if marker_index > 0 else 0)]
|
||||||
suffix = 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 prefix + swap_block + suffix
|
||||||
|
# 未找到标记时追加到末尾
|
||||||
return text.rstrip("\n") + "\n" + swap_block
|
return text.rstrip("\n") + "\n" + swap_block
|
||||||
|
|||||||
+969
-218
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
"""国际化模块:提供 t(key, **kwargs) 翻译函数,支持语言切换。
|
||||||
|
|
||||||
|
模块导入时自动加载 zh_CN 作为默认语言。
|
||||||
|
通过 switch(locale) 切换语言,t() 返回当前语言的翻译文本。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_I18N_DIR = Path(__file__).parent
|
||||||
|
_translations: dict[str, str] = {}
|
||||||
|
_current_locale: str = "zh_CN"
|
||||||
|
|
||||||
|
|
||||||
|
def load(locale: str) -> None:
|
||||||
|
"""加载指定语言的翻译文件。"""
|
||||||
|
global _translations, _current_locale
|
||||||
|
_current_locale = locale
|
||||||
|
path = _I18N_DIR / f"{locale}.json"
|
||||||
|
if path.exists():
|
||||||
|
_translations = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
_translations = {}
|
||||||
|
|
||||||
|
|
||||||
|
def switch(locale: str) -> None:
|
||||||
|
"""切换当前语言并返回新语言代码。"""
|
||||||
|
load(locale)
|
||||||
|
|
||||||
|
|
||||||
|
def current_locale() -> str:
|
||||||
|
"""返回当前语言代码。"""
|
||||||
|
return _current_locale
|
||||||
|
|
||||||
|
|
||||||
|
def t(key: str, **kwargs: object) -> str:
|
||||||
|
"""获取翻译文本,支持 {name} 插值。
|
||||||
|
|
||||||
|
如果 key 不存在,返回 key 本身作为回退。
|
||||||
|
"""
|
||||||
|
text = str(_translations.get(key, key))
|
||||||
|
if kwargs:
|
||||||
|
text = text.format(**{k: v for k, v in kwargs.items()})
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# 模块导入时自动加载默认中文翻译
|
||||||
|
load("zh_CN")
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"_lang": "English",
|
||||||
|
|
||||||
|
"gui.file_group.title": "Input 3MF Files",
|
||||||
|
"gui.file_group.table.header.order": "Order",
|
||||||
|
"gui.file_group.table.header.file": "3MF File",
|
||||||
|
"gui.file_group.table.header.copies": "Copies",
|
||||||
|
"gui.file_group.table.header.time": "Time",
|
||||||
|
"gui.file_group.table.header.filament": "Filament",
|
||||||
|
"gui.file_group.table.tooltip": "Drag .3mf files or folders here. Folders add all top-level .3mf files.",
|
||||||
|
"gui.file_group.table.move_up": "Move up",
|
||||||
|
"gui.file_group.table.move_down": "Move down",
|
||||||
|
"gui.file_group.table.tooltip_base_plates": "Base plates: {count}",
|
||||||
|
"gui.file_group.table.tooltip_base_time": "Base time: {time}",
|
||||||
|
"gui.file_group.table.tooltip_base_filament": "Base filament: {filament}",
|
||||||
|
"gui.file_group.total_summary": "Total: {plates} plate(s) | Time: {time} | Filament: {filament}",
|
||||||
|
"gui.file_group.total_summary.empty": "Total: 0 plates | Time: 0s | Filament: 0.00 g",
|
||||||
|
|
||||||
|
"gui.button.add_3mf": "Add 3MF",
|
||||||
|
"gui.button.remove": "Remove",
|
||||||
|
"gui.button.remove_all": "Remove All",
|
||||||
|
"gui.button.apply_copies": "Apply Default Copies to Selected",
|
||||||
|
"gui.button.build": "Build 3MF",
|
||||||
|
|
||||||
|
"gui.preview.title": "Selected Thumbnail",
|
||||||
|
"gui.preview.select_hint": "Select an input file",
|
||||||
|
"gui.preview.no_image": "No preview image",
|
||||||
|
"gui.preview.unavailable": "Preview unavailable",
|
||||||
|
|
||||||
|
"gui.options.group.title": "Packing Options",
|
||||||
|
"gui.options.swap_gcode.label": "Swap G-code",
|
||||||
|
"gui.options.refresh": "Refresh",
|
||||||
|
"gui.options.open_folder": "Open Folder",
|
||||||
|
"gui.options.default_copies": "Default copies for new inputs",
|
||||||
|
"gui.options.bed_cooldown": "Wait for bed cooldown",
|
||||||
|
"gui.options.bed_cooldown.label": "Bed cooldown",
|
||||||
|
"gui.options.bed_cooldown.suffix": "°C",
|
||||||
|
"gui.options.eject_wait": "Wait after swap",
|
||||||
|
"gui.options.eject_wait.label": "Wait after swap",
|
||||||
|
"gui.options.wait_suffix": "sec",
|
||||||
|
"gui.options.plate_number": "Show current plate in the hundreds digit of remaining time",
|
||||||
|
"gui.options.plate_number.label": "Plate number",
|
||||||
|
"gui.options.final_swap": "Run swap G-code after the last plate",
|
||||||
|
"gui.options.final_swap.label": "Final swap",
|
||||||
|
"gui.options.gcode_patches": "Apply editable G-code patches",
|
||||||
|
"gui.options.gcode_patches.label": "G-code patches",
|
||||||
|
"gui.options.gcode_patches.tooltip": "Uses gcode_patches.ini",
|
||||||
|
"gui.options.open_config": "Open Config",
|
||||||
|
"gui.options.metadata.label": "3MF metadata",
|
||||||
|
"gui.options.metadata.source": "Keep source prediction and weight",
|
||||||
|
"gui.options.metadata.sum": "Sum prediction and filament",
|
||||||
|
"gui.options.zip_compression.label": "ZIP compression",
|
||||||
|
"gui.options.zip_compression.level_format": "Level {level}",
|
||||||
|
"gui.options.zip_compression.tooltip": "zlib-ng Deflate compression level for the output 3MF.",
|
||||||
|
"gui.options.batch_mode": "Individual batch mode",
|
||||||
|
"gui.options.batch_mode.label": "Batch mode",
|
||||||
|
"gui.options.batch_mode.tooltip": "Build each input row as a separate output file, using that row's copy count.",
|
||||||
|
"gui.options.clear_after_build": "Clear input list after successful build",
|
||||||
|
"gui.options.clear_after_build.label": "After build",
|
||||||
|
"gui.options.skip_duplicates": "Skip duplicate file paths when adding inputs",
|
||||||
|
"gui.options.input_handling": "Input handling",
|
||||||
|
"gui.options.language": "Language",
|
||||||
|
|
||||||
|
"gui.output.group.title": "Output",
|
||||||
|
"gui.output.dir.label": "Output directory",
|
||||||
|
"gui.output.dir.placeholder": "Leave empty to write next to the input file",
|
||||||
|
"gui.output.dir.browse": "Browse",
|
||||||
|
"gui.output.filename_rule": "Output filename rule",
|
||||||
|
"gui.output.filename_rule.placeholder": "Use tokens such as {source}, {sources}, {plates}, {copies}, {date}, {time}",
|
||||||
|
"gui.output.filename_rule.help": "?",
|
||||||
|
"gui.output.filename_rule.help.tooltip": "Show filename token help",
|
||||||
|
"gui.output.preview": "Preview",
|
||||||
|
"gui.output.batch_preview": "{first} | {count} output file(s)",
|
||||||
|
|
||||||
|
"gui.dialog.individual_batch.title": "Individual Batch Mode",
|
||||||
|
"gui.dialog.individual_batch.text": "Individual batch mode treats every input row as a separate build.\n\nExample: if you add 20 single-plate 3MF files and set copies to 5, Build 3MF will create 20 separate packed files. Each output contains only that source file repeated 5 times.\n\nThis is useful for quickly batch-converting many independent 3MF jobs into multi-copy SwapMod packs. It does not combine all input files into one 3MF.",
|
||||||
|
"gui.dialog.filename_help.title": "Output Filename Rule",
|
||||||
|
"gui.dialog.filename_help.text": "Available filename tokens:\n\n{source} - First input file stem, without .3mf\n{sources} - Source name summary. For multiple different inputs, it becomes first_source_and_N_more\n{plates} - Total plate count in the output\n{copies} - Total copy count used for the output\n{date} - Current date as YYYYMMDD\n{time} - Current time as HHMMSS\n\nDefault rule:\n{plates}-Plates-{sources}.3mf\n\nIn individual batch mode, these tokens are calculated separately for each input row.",
|
||||||
|
"gui.dialog.warning.title": "A1 Swap Mod Packer",
|
||||||
|
"gui.dialog.no_files": "Please add at least one 3MF file.",
|
||||||
|
"gui.dialog.no_swap_gcode": "Please put at least one swap G-code file in the swap_gcode folder and select it.",
|
||||||
|
"gui.dialog.patch_config_not_found": "Patch config does not exist yet:\n{path}",
|
||||||
|
"gui.dialog.batch_partial_success": "Built {success} file(s), but {failed} file(s) failed.\n\n",
|
||||||
|
"gui.dialog.choose_output_dir": "Choose output directory",
|
||||||
|
"gui.dialog.add_files_title": "Add 3MF files",
|
||||||
|
"gui.dialog.file_filter.3mf": "3MF files (*.3mf)",
|
||||||
|
"gui.dialog.file_filter.all": "All files (*)",
|
||||||
|
|
||||||
|
"gui.toast.combined_success": "The packed 3MF file was created.",
|
||||||
|
"gui.toast.batch_success": "Created {count} packed 3MF file(s).",
|
||||||
|
|
||||||
|
"gui.log.added_files": "Added {added} input file(s). Skipped {skipped}.",
|
||||||
|
"gui.log.skipped_file": "Skipped {path}: {reason}",
|
||||||
|
"gui.log.no_swap_files": "No swap G-code files found in {dir}",
|
||||||
|
"gui.log.building_individual": "Building {count} individual output(s) with {workers} worker(s).",
|
||||||
|
"gui.log.output": "Output: {path}",
|
||||||
|
"gui.log.plates": "Plates: {count}",
|
||||||
|
"gui.log.time": "Estimated source time: {time}",
|
||||||
|
"gui.log.filament": "Estimated source filament: {filament}",
|
||||||
|
"gui.log.md5": "G-code MD5: {md5}",
|
||||||
|
"gui.log.build_error": "Error building {path}: {error}",
|
||||||
|
|
||||||
|
"format.unknown": "Unknown"
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{
|
||||||
|
"_lang": "简体中文",
|
||||||
|
|
||||||
|
"gui.file_group.title": "输入 3MF 文件",
|
||||||
|
"gui.file_group.table.header.order": "顺序",
|
||||||
|
"gui.file_group.table.header.file": "3MF 文件",
|
||||||
|
"gui.file_group.table.header.copies": "份数",
|
||||||
|
"gui.file_group.table.header.time": "时间",
|
||||||
|
"gui.file_group.table.header.filament": "耗材",
|
||||||
|
"gui.file_group.table.tooltip": "将 .3mf 文件或文件夹拖放到此处。文件夹将添加所有顶层 .3mf 文件。",
|
||||||
|
"gui.file_group.table.move_up": "上移",
|
||||||
|
"gui.file_group.table.move_down": "下移",
|
||||||
|
"gui.file_group.table.tooltip_base_plates": "基础板数:{count}",
|
||||||
|
"gui.file_group.table.tooltip_base_time": "基础时间:{time}",
|
||||||
|
"gui.file_group.table.tooltip_base_filament": "基础耗材:{filament}",
|
||||||
|
"gui.file_group.total_summary": "合计:{plates} 板 | 时间:{time} | 耗材:{filament}",
|
||||||
|
"gui.file_group.total_summary.empty": "合计:0 板 | 时间:0s | 耗材:0.00 g",
|
||||||
|
|
||||||
|
"gui.button.add_3mf": "添加 3MF",
|
||||||
|
"gui.button.remove": "移除",
|
||||||
|
"gui.button.remove_all": "全部移除",
|
||||||
|
"gui.button.apply_copies": "应用默认份数至选中行",
|
||||||
|
"gui.button.build": "构建 3MF",
|
||||||
|
|
||||||
|
"gui.preview.title": "选中文件的缩略图",
|
||||||
|
"gui.preview.select_hint": "选择一个输入文件",
|
||||||
|
"gui.preview.no_image": "无预览图",
|
||||||
|
"gui.preview.unavailable": "预览不可用",
|
||||||
|
|
||||||
|
"gui.options.group.title": "打包选项",
|
||||||
|
"gui.options.swap_gcode.label": "换盘 G-code",
|
||||||
|
"gui.options.refresh": "刷新",
|
||||||
|
"gui.options.open_folder": "打开文件夹",
|
||||||
|
"gui.options.default_copies": "新输入的默认份数",
|
||||||
|
"gui.options.bed_cooldown": "等待热床降温",
|
||||||
|
"gui.options.bed_cooldown.label": "热床降温",
|
||||||
|
"gui.options.bed_cooldown.suffix": "°C",
|
||||||
|
"gui.options.eject_wait": "换盘后等待",
|
||||||
|
"gui.options.eject_wait.label": "换盘后等待时间",
|
||||||
|
"gui.options.wait_suffix": "秒",
|
||||||
|
"gui.options.plate_number": "在剩余时间的百位显示当前板号",
|
||||||
|
"gui.options.plate_number.label": "剩余时间板号",
|
||||||
|
"gui.options.final_swap": "最后一块盘后也执行换盘 G-code",
|
||||||
|
"gui.options.final_swap.label": "最后换盘",
|
||||||
|
"gui.options.gcode_patches": "应用可编辑的 G-code 补丁",
|
||||||
|
"gui.options.gcode_patches.label": "G-code 补丁",
|
||||||
|
"gui.options.gcode_patches.tooltip": "使用 gcode_patches.ini",
|
||||||
|
"gui.options.open_config": "打开配置文件",
|
||||||
|
"gui.options.metadata.label": "3MF 元数据",
|
||||||
|
"gui.options.metadata.source": "保留原始预测和重量",
|
||||||
|
"gui.options.metadata.sum": "累加预测和耗材用量",
|
||||||
|
"gui.options.zip_compression.label": "ZIP 压缩",
|
||||||
|
"gui.options.zip_compression.level_format": "级别 {level}",
|
||||||
|
"gui.options.zip_compression.tooltip": "输出 3MF 的 zlib-ng Deflate 压缩级别。",
|
||||||
|
"gui.options.batch_mode": "独立批处理模式",
|
||||||
|
"gui.options.batch_mode.label": "批处理模式",
|
||||||
|
"gui.options.batch_mode.tooltip": "将每行输入构建为独立的输出文件,使用该行的份数。",
|
||||||
|
"gui.options.clear_after_build": "构建成功后清空输入列表",
|
||||||
|
"gui.options.clear_after_build.label": "构建成功后",
|
||||||
|
"gui.options.skip_duplicates": "添加输入时跳过重复文件路径",
|
||||||
|
"gui.options.input_handling": "输入处理",
|
||||||
|
"gui.options.language": "语言",
|
||||||
|
|
||||||
|
"gui.output.group.title": "输出",
|
||||||
|
"gui.output.dir.label": "输出目录",
|
||||||
|
"gui.output.dir.placeholder": "留空则写入输入文件所在目录",
|
||||||
|
"gui.output.dir.browse": "浏览",
|
||||||
|
"gui.output.filename_rule": "输出文件名规则",
|
||||||
|
"gui.output.filename_rule.placeholder": "可使用 {source}、{sources}、{plates}、{copies}、{date}、{time} 等标记",
|
||||||
|
"gui.output.filename_rule.help": "?",
|
||||||
|
"gui.output.filename_rule.help.tooltip": "显示文件名标记帮助",
|
||||||
|
"gui.output.preview": "预览",
|
||||||
|
"gui.output.batch_preview": "{first} | {count} 个输出文件",
|
||||||
|
|
||||||
|
"gui.dialog.individual_batch.title": "独立批处理模式",
|
||||||
|
"gui.dialog.individual_batch.text": "独立批处理模式将每一行输入作为独立的构建任务。\n\n示例:如果你添加了 20 个单板 3MF 文件并设置份数为 5,点击\"构建 3MF\"将创建 20 个独立的打包文件。每个输出文件仅包含该源文件重复 5 次的内容。\n\n这个功能适用于快速将大量独立的 3MF 作业批量转换为多份数的 SwapMod 包。它不会将所有输入文件合并到一个 3MF 中。",
|
||||||
|
"gui.dialog.filename_help.title": "输出文件名规则",
|
||||||
|
"gui.dialog.filename_help.text": "可用的文件名标记:\n\n{source} — 第一个输入文件的文件名(不含 .3mf)\n{sources} — 源文件名称摘要。多个不同输入时,显示为 first_source_and_N_more\n{plates} — 输出中的总板数\n{copies} — 输出中使用的总份数\n{date} — 当前日期,格式 YYYYMMDD\n{time} — 当前时间,格式 HHMMSS\n\n默认规则:\n{plates}-Plates-{sources}.3mf\n\n在独立批处理模式下,这些标记会针对每行输入单独计算。",
|
||||||
|
"gui.dialog.warning.title": "A1 Swap Mod Packer",
|
||||||
|
"gui.dialog.no_files": "请至少添加一个 3MF 文件。",
|
||||||
|
"gui.dialog.no_swap_gcode": "请在 swap_gcode 文件夹中放入至少一个换盘 G-code 文件并选择它。",
|
||||||
|
"gui.dialog.patch_config_not_found": "补丁配置文件不存在:{path}",
|
||||||
|
"gui.dialog.batch_partial_success": "已构建 {success} 个文件,但有 {failed} 个文件失败。\n\n",
|
||||||
|
"gui.dialog.choose_output_dir": "选择输出目录",
|
||||||
|
"gui.dialog.add_files_title": "添加 3MF 文件",
|
||||||
|
"gui.dialog.file_filter.3mf": "3MF 文件 (*.3mf)",
|
||||||
|
"gui.dialog.file_filter.all": "所有文件 (*)",
|
||||||
|
|
||||||
|
"gui.toast.combined_success": "已创建打包的 3MF 文件。",
|
||||||
|
"gui.toast.batch_success": "已创建 {count} 个打包的 3MF 文件。",
|
||||||
|
|
||||||
|
"gui.log.added_files": "已添加 {added} 个输入文件。跳过 {skipped} 个。",
|
||||||
|
"gui.log.skipped_file": "已跳过 {path}:{reason}",
|
||||||
|
"gui.log.no_swap_files": "在 {dir} 中未找到换盘 G-code 文件",
|
||||||
|
"gui.log.building_individual": "正在使用 {workers} 个工作线程构建 {count} 个独立输出。",
|
||||||
|
"gui.log.output": "输出:{path}",
|
||||||
|
"gui.log.plates": "板数:{count}",
|
||||||
|
"gui.log.time": "预估源文件时间:{time}",
|
||||||
|
"gui.log.filament": "预估源文件耗材:{filament}",
|
||||||
|
"gui.log.md5": "G-code MD5:{md5}",
|
||||||
|
"gui.log.build_error": "构建 {path} 时出错:{error}",
|
||||||
|
|
||||||
|
"format.unknown": "未知"
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""3MF 文件元数据读取与处理模块。
|
||||||
|
|
||||||
|
提供从 3MF 归档文件中提取切片信息、耗材数据、模型设置等元数据的功能,
|
||||||
|
以及基于元数据生成构建摘要(时间预估、耗材用量等)的辅助函数。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import zipfile
|
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]]:
|
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:
|
try:
|
||||||
data = archive.read("Metadata/slice_info.config")
|
data = archive.read("Metadata/slice_info.config")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -23,6 +41,7 @@ def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, s
|
|||||||
value = metadata.attrib.get("value")
|
value = metadata.attrib.get("value")
|
||||||
if key is not None and value is not None:
|
if key is not None and value is not None:
|
||||||
plate_data[key] = value
|
plate_data[key] = value
|
||||||
|
# 使用 index 字段确定盘编号,缺失或无效时自动递增
|
||||||
index_text = plate_data.get("index")
|
index_text = plate_data.get("index")
|
||||||
try:
|
try:
|
||||||
index = int(index_text) if index_text else len(result) + 1
|
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]]:
|
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:
|
try:
|
||||||
data = archive.read("Metadata/slice_info.config")
|
data = archive.read("Metadata/slice_info.config")
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@@ -41,6 +72,7 @@ def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]
|
|||||||
result: dict[int, dict[str, str]] = {}
|
result: dict[int, dict[str, str]] = {}
|
||||||
for plate in root.findall("plate"):
|
for plate in root.findall("plate"):
|
||||||
plate_index = 1
|
plate_index = 1
|
||||||
|
# 先获取当前盘的 index 编号
|
||||||
for metadata in plate.findall("metadata"):
|
for metadata in plate.findall("metadata"):
|
||||||
if metadata.attrib.get("key") == "index":
|
if metadata.attrib.get("key") == "index":
|
||||||
try:
|
try:
|
||||||
@@ -54,7 +86,48 @@ def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]
|
|||||||
return result
|
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:
|
def safe_float(value: str | None) -> float | None:
|
||||||
|
"""安全地将字符串转换为浮点数,失败时返回 None。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
value: 待转换的字符串或 None。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
float | None: 转换成功返回浮点数,否则返回 None。
|
||||||
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -64,6 +137,14 @@ def safe_float(value: str | None) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def _sum_optional(values: Iterable[float | None]) -> float | None:
|
def _sum_optional(values: Iterable[float | None]) -> float | None:
|
||||||
|
"""对一组可选浮点值求和,全为 None 时返回 None。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
values: 包含 float 或 None 的可迭代对象。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
float | None: 有效值的总和,若无任何有效值则返回 None。
|
||||||
|
"""
|
||||||
total = 0.0
|
total = 0.0
|
||||||
found = False
|
found = False
|
||||||
for value in values:
|
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:
|
def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary:
|
||||||
|
"""读取单个 3MF 文件的完整摘要信息。
|
||||||
|
|
||||||
|
汇总文件内所有打印板的预估时间、重量、耗材用量等数据。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
source_3mf: 3MF 源文件路径。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
ThreeMfSummary: 包含盘数量、预估秒数、重量克数、耗材米数和克数的摘要对象。
|
||||||
|
|
||||||
|
抛出:
|
||||||
|
ValueError: 若文件中未找到任何 G-code 成员。
|
||||||
|
"""
|
||||||
with zipfile.ZipFile(source_3mf, "r") as archive:
|
with zipfile.ZipFile(source_3mf, "r") as archive:
|
||||||
members = list_gcode_members(archive)
|
members = list_gcode_members(archive)
|
||||||
if not members:
|
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
|
plate_index = int(match.group(1)) if match else 1
|
||||||
metadata = plate_metadata.get(plate_index, {})
|
metadata = plate_metadata.get(plate_index, {})
|
||||||
filament = filament_metadata.get(plate_index, {})
|
filament = filament_metadata.get(plate_index, {})
|
||||||
|
# 提取各项元数据值
|
||||||
prediction = safe_float(metadata.get("prediction"))
|
prediction = safe_float(metadata.get("prediction"))
|
||||||
weight = safe_float(metadata.get("weight"))
|
weight = safe_float(metadata.get("weight"))
|
||||||
used_m = safe_float(filament.get("used_m"))
|
used_m = safe_float(filament.get("used_m"))
|
||||||
used_g = safe_float(filament.get("used_g"))
|
used_g = safe_float(filament.get("used_g"))
|
||||||
predictions.append(prediction)
|
predictions.append(prediction)
|
||||||
|
# 重量优先取 weight,若无则回退到 used_g
|
||||||
weights.append(weight if weight is not None else used_g)
|
weights.append(weight if weight is not None else used_g)
|
||||||
used_m_values.append(used_m)
|
used_m_values.append(used_m)
|
||||||
|
# 耗材克数优先取 used_g,若无则回退到 weight
|
||||||
used_g_values.append(used_g if used_g is not None else weight)
|
used_g_values.append(used_g if used_g is not None else weight)
|
||||||
|
|
||||||
return ThreeMfSummary(
|
return ThreeMfSummary(
|
||||||
@@ -110,6 +207,17 @@ def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary:
|
|||||||
|
|
||||||
|
|
||||||
def multiply_summary(summary: ThreeMfSummary, copies: int) -> BuildSummary:
|
def multiply_summary(summary: ThreeMfSummary, copies: int) -> BuildSummary:
|
||||||
|
"""将单个 3MF 摘要按指定份数倍增,生成构建摘要。
|
||||||
|
|
||||||
|
用于计算同一文件多次打印时的累计耗时和耗材用量。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
summary: 单个 3MF 文件的摘要对象。
|
||||||
|
copies: 打印份数(至少为 1)。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
BuildSummary: 倍增后的构建摘要。
|
||||||
|
"""
|
||||||
multiplier = max(1, int(copies))
|
multiplier = max(1, int(copies))
|
||||||
return BuildSummary(
|
return BuildSummary(
|
||||||
plate_count=summary.plate_count * multiplier,
|
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:
|
def summarize_jobs(jobs: Iterable[PlateJob]) -> BuildSummary:
|
||||||
|
"""汇总所有换板作业的构建摘要。
|
||||||
|
|
||||||
|
遍历所有 PlateJob,依次读取各自的 3MF 摘要并按份数倍增后累加。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
jobs: PlateJob 对象的可迭代集合。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
BuildSummary: 所有作业合并后的构建摘要。
|
||||||
|
"""
|
||||||
plate_count = 0
|
plate_count = 0
|
||||||
predictions: list[float | None] = []
|
predictions: list[float | None] = []
|
||||||
weights: 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:
|
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":
|
if options.metadata_mode == "source":
|
||||||
|
# 源模式:保持原始数据不变
|
||||||
return base_data
|
return base_data
|
||||||
try:
|
try:
|
||||||
root = ET.fromstring(base_data.decode("utf-8-sig", errors="replace"))
|
root = ET.fromstring(base_data.decode("utf-8-sig", errors="replace"))
|
||||||
except Exception:
|
except Exception:
|
||||||
return base_data
|
return base_data
|
||||||
|
# 累加所有来源的预估时间和耗材用量
|
||||||
total_prediction = sum(value for value in (source.prediction_seconds for source in sources) if value is not None)
|
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_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)
|
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")
|
first_plate = root.find("plate")
|
||||||
if first_plate is None:
|
if first_plate is None:
|
||||||
return base_data
|
return base_data
|
||||||
|
# 更新第一个 plate 的 metadata 和 filament 字段
|
||||||
for metadata in first_plate.findall("metadata"):
|
for metadata in first_plate.findall("metadata"):
|
||||||
key = metadata.attrib.get("key")
|
key = metadata.attrib.get("key")
|
||||||
if key == "prediction" and total_prediction > 0:
|
if key == "prediction" and total_prediction > 0:
|
||||||
|
|||||||
+117
-24
@@ -1,67 +1,142 @@
|
|||||||
|
"""数据模型模块。
|
||||||
|
|
||||||
|
定义换板打包器所用的全部数据类、类型别名和常量,包括:
|
||||||
|
- 板件任务 (PlateJob)
|
||||||
|
- G-code 补丁规则与配置 (GcodePatchRule / GcodePatchConfig)
|
||||||
|
- 构建选项 (BuildOptions)
|
||||||
|
- 板件来源与摘要 (PlateSource / ThreeMfSummary / BuildSummary)
|
||||||
|
- 构建结果 (BuildResult)
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
# 默认插入标记:在此行之前插入换板 G-code
|
||||||
DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound========="
|
DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound========="
|
||||||
|
# 默认 ZIP 压缩级别 (0-9,7 为平衡选择)
|
||||||
|
DEFAULT_ZIP_COMPRESS_LEVEL = 7
|
||||||
|
|
||||||
|
# 元数据计算模式:取源文件数值还是累加求和
|
||||||
MetadataMode = Literal["source", "sum"]
|
MetadataMode = Literal["source", "sum"]
|
||||||
|
# 换行符风格:LF (Unix) 或 CRLF (Windows)
|
||||||
LineEnding = Literal["lf", "crlf"]
|
LineEnding = Literal["lf", "crlf"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PlateJob:
|
class PlateJob:
|
||||||
|
"""单个板件打包任务。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
source_3mf: 源 3MF 文件路径。
|
||||||
|
copies: 该板件的副本数量,默认为 1。
|
||||||
|
"""
|
||||||
source_3mf: Path
|
source_3mf: Path
|
||||||
copies: int = 1
|
copies: int = 1
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GcodePatchRule:
|
class GcodePatchRule:
|
||||||
|
"""单条 G-code 查找替换规则。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: 规则名称,用于识别和日志。
|
||||||
|
find: 要查找的目标字符串。
|
||||||
|
replace: 替换后的字符串。
|
||||||
|
flag: 正则标志(可选),如 "i" 表示忽略大小写。
|
||||||
|
enabled: 是否启用该规则,默认启用。
|
||||||
|
max_count: 最大替换次数,默认 1(仅替换首次出现)。
|
||||||
|
"""
|
||||||
name: str
|
name: str
|
||||||
find: str
|
find: str
|
||||||
replace: str
|
replace: str
|
||||||
flag: str = ""
|
flag: str = "" # 正则标志,例如 'i' 表示忽略大小写
|
||||||
enabled: bool = True
|
enabled: bool = True # 是否启用此规则
|
||||||
max_count: int = 1
|
max_count: int = 1 # 最多替换次数
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GcodePatchConfig:
|
class GcodePatchConfig:
|
||||||
|
"""G-code 补丁配置,包含一组规则和插入标记。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
rules: 补丁规则元组。
|
||||||
|
insert_before_marker: 在此标记行之前插入换板 G-code。
|
||||||
|
"""
|
||||||
rules: tuple[GcodePatchRule, ...]
|
rules: tuple[GcodePatchRule, ...]
|
||||||
insert_before_marker: str = DEFAULT_INSERT_BEFORE_MARKER
|
insert_before_marker: str = DEFAULT_INSERT_BEFORE_MARKER
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BuildOptions:
|
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
|
swap_gcode: Path | str
|
||||||
output_3mf: Path
|
output_3mf: Path
|
||||||
cool_bed_temp: int | None = 45
|
cool_bed_temp: int | None = 45 # 热床冷却目标温度,None 跳过冷却
|
||||||
wait_after_eject_seconds: int = 45
|
wait_after_eject_seconds: int = 45 # 推板完成后等待时间
|
||||||
show_plate_number: bool = True
|
show_plate_number: bool = True # 是否显示板件编号到屏幕
|
||||||
swap_after_final: bool = True
|
swap_after_final: bool = True # 最后一块是否也换板
|
||||||
metadata_mode: MetadataMode = "source"
|
metadata_mode: MetadataMode = "sum" # 元数据计算模式
|
||||||
line_ending: LineEnding = "crlf"
|
line_ending: LineEnding = "crlf" # 输出换行符
|
||||||
add_preview_label: bool = True
|
add_preview_label: bool = True # 是否添加预览注释
|
||||||
apply_gcode_patches: bool = True
|
apply_gcode_patches: bool = True # 是否启用 G-code 补丁
|
||||||
swap_gcode_dir: Path | None = None
|
swap_gcode_dir: Path | None = None # 换板 G-code 所在目录
|
||||||
|
zip_compress_level: int = DEFAULT_ZIP_COMPRESS_LEVEL # ZIP 压缩级别
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PlateSource:
|
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
|
source_3mf: Path
|
||||||
member_name: str
|
member_name: str # 3MF 内的成员名,用于定位 G-code
|
||||||
gcode_text: str
|
gcode_text: str # 完整的 G-code 文本
|
||||||
prediction_seconds: float | None = None
|
prediction_seconds: float | None = None # 预估打印时间(秒)
|
||||||
weight_grams: float | None = None
|
weight_grams: float | None = None # 预估耗材重量(克)
|
||||||
filament_used_m: float | None = None
|
filament_used_m: float | None = None # 耗材用量(米)
|
||||||
filament_used_g: float | None = None
|
filament_used_g: float | None = None # 耗材用量(克)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ThreeMfSummary:
|
class ThreeMfSummary:
|
||||||
|
"""单个 3MF 文件的元数据摘要。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
source_3mf: 来源 3MF 文件。
|
||||||
|
plate_count: 该文件中包含的板件数量。
|
||||||
|
prediction_seconds: 预估打印时间(秒)。
|
||||||
|
weight_grams: 预估耗材重量(克)。
|
||||||
|
filament_used_m: 预估耗材用量(米)。
|
||||||
|
filament_used_g: 预估耗材用量(克)。
|
||||||
|
"""
|
||||||
source_3mf: Path
|
source_3mf: Path
|
||||||
plate_count: int
|
plate_count: int # 板件数量
|
||||||
prediction_seconds: float | None = None
|
prediction_seconds: float | None = None
|
||||||
weight_grams: float | None = None
|
weight_grams: float | None = None
|
||||||
filament_used_m: float | None = None
|
filament_used_m: float | None = None
|
||||||
@@ -70,17 +145,35 @@ class ThreeMfSummary:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BuildSummary:
|
class BuildSummary:
|
||||||
|
"""总体构建摘要。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
plate_count: 总板件数。
|
||||||
|
total_prediction_seconds: 总预估打印时间(秒)。
|
||||||
|
total_weight_grams: 总耗材重量(克)。
|
||||||
|
total_filament_used_m: 总耗材用量(米)。
|
||||||
|
total_filament_used_g: 总耗材用量(克)。
|
||||||
|
"""
|
||||||
plate_count: int
|
plate_count: int
|
||||||
total_prediction_seconds: float | None
|
total_prediction_seconds: float | None # 总预估时间
|
||||||
total_weight_grams: float | None
|
total_weight_grams: float | None # 总耗材重量
|
||||||
total_filament_used_m: float | None
|
total_filament_used_m: float | None # 总耗材长度
|
||||||
total_filament_used_g: float | None
|
total_filament_used_g: float | None # 总耗材质量
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BuildResult:
|
class BuildResult:
|
||||||
|
"""单次构建打包的最终结果。
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
output_3mf: 输出 3MF 文件路径。
|
||||||
|
plate_count: 包含的板件总数。
|
||||||
|
total_prediction_seconds: 总预估打印时间(秒)。
|
||||||
|
total_weight_grams: 总耗材重量(克)。
|
||||||
|
gcode_md5: 最终 G-code 内容的 MD5 哈希值,用于唯一性校验。
|
||||||
|
"""
|
||||||
output_3mf: Path
|
output_3mf: Path
|
||||||
plate_count: int
|
plate_count: int
|
||||||
total_prediction_seconds: float | None
|
total_prediction_seconds: float | None
|
||||||
total_weight_grams: float | None
|
total_weight_grams: float | None
|
||||||
gcode_md5: str
|
gcode_md5: str # G-code MD5 哈希值
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""G-code 补丁配置解析与应用。
|
||||||
|
|
||||||
|
本模块提供了从 INI 配置文件读取 G-code 修改规则的功能,
|
||||||
|
支持两种配置节格式:新版 [patch.<名称>] 节和旧版 [Gcode] 节。
|
||||||
|
补丁规则可用于在打包前对换板 G-code 进行查找替换等文本修改。"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import configparser
|
import configparser
|
||||||
@@ -9,24 +15,53 @@ from .paths import default_patch_config_path
|
|||||||
|
|
||||||
|
|
||||||
def parse_bool(value: str | None, default: bool = True) -> bool:
|
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:
|
if value is None:
|
||||||
return default
|
return default
|
||||||
return value.strip().lower() in {"1", "true", "yes", "on", "enabled"}
|
return value.strip().lower() in {"1", "true", "yes", "on", "enabled"}
|
||||||
|
|
||||||
|
|
||||||
def parse_patch_config(path: Path | None = None) -> GcodePatchConfig:
|
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()
|
config_path = path or default_patch_config_path()
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
return GcodePatchConfig(rules=())
|
return GcodePatchConfig(rules=())
|
||||||
|
|
||||||
|
# configparser 配置: 禁用插值,保持键名大小写
|
||||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||||
parser.optionxform = str
|
parser.optionxform = str
|
||||||
parser.read(config_path, encoding="utf-8-sig")
|
parser.read(config_path, encoding="utf-8-sig")
|
||||||
|
|
||||||
|
# 读取 [swap] 节中的插入标记配置
|
||||||
insert_before_marker = DEFAULT_INSERT_BEFORE_MARKER
|
insert_before_marker = DEFAULT_INSERT_BEFORE_MARKER
|
||||||
if parser.has_section("swap"):
|
if parser.has_section("swap"):
|
||||||
insert_before_marker = parser.get("swap", "insert_before_marker", fallback=insert_before_marker).strip().strip('"')
|
insert_before_marker = parser.get("swap", "insert_before_marker", fallback=insert_before_marker).strip().strip('"')
|
||||||
|
|
||||||
rules: list[GcodePatchRule] = []
|
rules: list[GcodePatchRule] = []
|
||||||
|
|
||||||
|
# 解析新版 [patch.<名称>] 格式的补丁规则
|
||||||
for section in parser.sections():
|
for section in parser.sections():
|
||||||
if section.lower().startswith("patch."):
|
if section.lower().startswith("patch."):
|
||||||
enabled = parse_bool(parser.get(section, "enabled", fallback="true"), True)
|
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))
|
max_count = max(0, int(max_count_text))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
max_count = 1
|
max_count = 1
|
||||||
|
# 仅当 find 非空且规则启用时才添加
|
||||||
if find and enabled:
|
if find and enabled:
|
||||||
rules.append(GcodePatchRule(section.split(".", 1)[1], find, replace, flag, enabled, max_count))
|
rules.append(GcodePatchRule(section.split(".", 1)[1], find, replace, flag, enabled, max_count))
|
||||||
|
|
||||||
|
# 解析旧版 [Gcode] 格式的补丁规则(兼容旧配置文件)
|
||||||
if parser.has_section("Gcode"):
|
if parser.has_section("Gcode"):
|
||||||
gcode = parser["Gcode"]
|
gcode = parser["Gcode"]
|
||||||
|
# 找出所有 Postion<键>_flag 键,提取基准键名
|
||||||
base_names = sorted(
|
base_names = sorted(
|
||||||
key[:-5]
|
key[:-5]
|
||||||
for key in gcode.keys()
|
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('"')
|
flag = gcode.get(f"{base}_flag", "").strip().strip('"')
|
||||||
if find and replace:
|
if find and replace:
|
||||||
rules.append(GcodePatchRule(base, find, replace, flag, True, 1))
|
rules.append(GcodePatchRule(base, find, replace, flag, True, 1))
|
||||||
|
# FinishFlag 作为插入位置标记
|
||||||
marker = gcode.get("FinishFlag", "").strip().strip('"')
|
marker = gcode.get("FinishFlag", "").strip().strip('"')
|
||||||
if marker:
|
if marker:
|
||||||
insert_before_marker = marker
|
insert_before_marker = marker
|
||||||
|
|
||||||
return GcodePatchConfig(tuple(rules), insert_before_marker)
|
return GcodePatchConfig(tuple(rules), insert_before_marker)
|
||||||
|
|
||||||
|
|
||||||
def apply_patch_rule(text: str, rule: GcodePatchRule) -> str:
|
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:
|
if not rule.enabled or not rule.find or rule.max_count == 0:
|
||||||
return text
|
return text
|
||||||
|
|
||||||
lines = normalize_newlines(text).split("\n")
|
lines = normalize_newlines(text).split("\n")
|
||||||
start_index = 0
|
start_index = 0
|
||||||
|
|
||||||
|
# 如果指定了 flag 标记,找到标记行后从下一行开始查找替换
|
||||||
if rule.flag:
|
if rule.flag:
|
||||||
for index, line in enumerate(lines):
|
for index, line in enumerate(lines):
|
||||||
if line.strip() == rule.flag or rule.flag in line:
|
if line.strip() == rule.flag or rule.flag in line:
|
||||||
start_index = index + 1
|
start_index = index + 1
|
||||||
break
|
break
|
||||||
|
|
||||||
replacements = 0
|
replacements = 0
|
||||||
|
# 在指定范围内查找并替换匹配行
|
||||||
for index in range(start_index, len(lines)):
|
for index in range(start_index, len(lines)):
|
||||||
if lines[index].strip() == rule.find or lines[index] == rule.find:
|
if lines[index].strip() == rule.find or lines[index] == rule.find:
|
||||||
lines[index] = rule.replace
|
lines[index] = rule.replace
|
||||||
replacements += 1
|
replacements += 1
|
||||||
if replacements >= rule.max_count:
|
if replacements >= rule.max_count:
|
||||||
break
|
break
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def apply_gcode_patches(text: str, config: GcodePatchConfig) -> str:
|
def apply_gcode_patches(text: str, config: GcodePatchConfig) -> str:
|
||||||
|
"""对 G-code 文本依次应用配置中的所有补丁规则。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
text: 待处理的 G-code 文本。
|
||||||
|
config: 包含补丁规则列表的配置对象。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
应用所有规则后的 G-code 文本。
|
||||||
|
"""
|
||||||
patched = normalize_newlines(text)
|
patched = normalize_newlines(text)
|
||||||
for rule in config.rules:
|
for rule in config.rules:
|
||||||
patched = apply_patch_rule(patched, rule)
|
patched = apply_patch_rule(patched, rule)
|
||||||
|
|||||||
@@ -1,26 +1,78 @@
|
|||||||
|
"""路径管理模块。
|
||||||
|
|
||||||
|
提供程序根目录和关键资源路径的计算逻辑,处理以下场景:
|
||||||
|
- 正常 Python 运行(通过 __file__ 定位)
|
||||||
|
- PyInstaller/Nuitka 单文件打包(通过 __compiled__ 信息定位)
|
||||||
|
- PyInstaller 冻结目录模式(通过 sys.frozen / sys.executable 定位)
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 应用程序包目录名
|
||||||
APP_DIR_NAME = "a1_swap_mod_packer"
|
APP_DIR_NAME = "a1_swap_mod_packer"
|
||||||
|
# 换板 G-code 文件的默认子目录名
|
||||||
SWAP_GCODE_DIR_NAME = "swap_gcode"
|
SWAP_GCODE_DIR_NAME = "swap_gcode"
|
||||||
|
# G-code 补丁配置文件名
|
||||||
PATCH_CONFIG_FILE_NAME = "gcode_patches.ini"
|
PATCH_CONFIG_FILE_NAME = "gcode_patches.ini"
|
||||||
|
|
||||||
|
|
||||||
def program_root() -> Path:
|
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):
|
if getattr(sys, "frozen", False):
|
||||||
return Path(sys.executable).resolve().parent
|
return Path(sys.executable).resolve().parent
|
||||||
|
# 正常 Python 解释器运行:向上两级回到项目根目录
|
||||||
return Path(__file__).resolve().parents[1]
|
return Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def default_swap_gcode_dir() -> Path:
|
def default_swap_gcode_dir() -> Path:
|
||||||
|
"""返回换板 G-code 文件的默认存放目录。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path: 程序根目录下的 swap_gcode 子目录。
|
||||||
|
"""
|
||||||
return program_root() / SWAP_GCODE_DIR_NAME
|
return program_root() / SWAP_GCODE_DIR_NAME
|
||||||
|
|
||||||
|
|
||||||
def default_patch_config_path() -> Path:
|
def default_patch_config_path() -> Path:
|
||||||
|
"""返回 G-code 补丁配置文件的默认路径。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path: 程序根目录下的 gcode_patches.ini 文件路径。
|
||||||
|
"""
|
||||||
return program_root() / PATCH_CONFIG_FILE_NAME
|
return program_root() / PATCH_CONFIG_FILE_NAME
|
||||||
|
|
||||||
|
|
||||||
def user_settings_path() -> Path:
|
def user_settings_path() -> Path:
|
||||||
|
"""返回用户设置文件的路径。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path: 程序根目录下的 settings.json 文件路径。
|
||||||
|
"""
|
||||||
return program_root() / "settings.json"
|
return program_root() / "settings.json"
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""输出规划与文件命名模块。
|
||||||
|
|
||||||
|
提供换板打包任务的输出路径解析、文件命名规则、摘要汇总等功能。
|
||||||
|
支持基于模板的输出文件名生成以及同一批次内文件路径去重。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@@ -9,13 +15,24 @@ from typing import Callable, Mapping, Sequence
|
|||||||
from .metadata import read_3mf_summary
|
from .metadata import read_3mf_summary
|
||||||
from .models import PlateJob, ThreeMfSummary
|
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]
|
SummaryResolver = Callable[[Path], ThreeMfSummary]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OutputSummary:
|
class OutputSummary:
|
||||||
|
"""输出摘要数据类,汇总一次换板打包的整体统计信息。
|
||||||
|
|
||||||
|
属性:
|
||||||
|
plate_count: 总盘数(含份数倍增)。
|
||||||
|
copy_count: 总份数。
|
||||||
|
prediction_seconds: 预估总耗时(秒),不可用时为 None。
|
||||||
|
weight_grams: 总重量(克),不可用时为 None。
|
||||||
|
filament_used_m: 耗材总长度(米),不可用时为 None。
|
||||||
|
"""
|
||||||
plate_count: int
|
plate_count: int
|
||||||
copy_count: int
|
copy_count: int
|
||||||
prediction_seconds: float | None = None
|
prediction_seconds: float | None = None
|
||||||
@@ -25,16 +42,37 @@ class OutputSummary:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class OutputNamingOptions:
|
class OutputNamingOptions:
|
||||||
|
"""输出命名选项,控制生成文件的目录和命名规则。
|
||||||
|
|
||||||
|
属性:
|
||||||
|
output_directory: 输出目录路径,None 表示使用第一个输入文件所在目录。
|
||||||
|
filename_rule: 输出文件名模板字符串。
|
||||||
|
"""
|
||||||
output_directory: Path | str | None = None
|
output_directory: Path | str | None = None
|
||||||
filename_rule: str = DEFAULT_OUTPUT_PATTERN
|
filename_rule: str = DEFAULT_OUTPUT_PATTERN
|
||||||
|
|
||||||
|
|
||||||
class SafeFormatDict(dict):
|
class SafeFormatDict(dict):
|
||||||
|
"""安全的格式化字典。
|
||||||
|
|
||||||
|
当 str.format_map 请求不存在的键时,返回原始占位符而非抛出 KeyError,
|
||||||
|
保证模板中未提供的占位符被原样保留在输出中。
|
||||||
|
"""
|
||||||
def __missing__(self, key: str) -> str:
|
def __missing__(self, key: str) -> str:
|
||||||
return "{" + key + "}"
|
return "{" + key + "}"
|
||||||
|
|
||||||
|
|
||||||
def three_mf_summary_from_mapping(data: Mapping[str, object]) -> ThreeMfSummary:
|
def three_mf_summary_from_mapping(data: Mapping[str, object]) -> ThreeMfSummary:
|
||||||
|
"""从类字典的映射对象构建 ThreeMfSummary。
|
||||||
|
|
||||||
|
用于将外部传入的配置数据(如 GUI 表格行)转换为标准摘要对象。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
data: 包含摘要字段的映射对象(键名与 ThreeMfSummary 字段名对应)。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
ThreeMfSummary: 解析后的摘要对象,缺失字段使用默认值。
|
||||||
|
"""
|
||||||
source_value = data.get("source_3mf")
|
source_value = data.get("source_3mf")
|
||||||
return ThreeMfSummary(
|
return ThreeMfSummary(
|
||||||
source_3mf=Path(str(source_value)) if source_value is not None else Path(),
|
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],
|
jobs: Sequence[PlateJob],
|
||||||
summary_resolver: SummaryResolver = read_3mf_summary,
|
summary_resolver: SummaryResolver = read_3mf_summary,
|
||||||
) -> OutputSummary:
|
) -> OutputSummary:
|
||||||
|
"""将一组换板作业汇总为 OutputSummary。
|
||||||
|
|
||||||
|
分别计算总盘数、总份数,以及各项可选的累计统计值。
|
||||||
|
支持自定义的摘要解析器,默认为 read_3mf_summary。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
jobs: PlateJob 序列。
|
||||||
|
summary_resolver: 用于获取单个 3MF 摘要的可调用对象。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
OutputSummary: 汇总后的输出摘要。
|
||||||
|
"""
|
||||||
plate_count = 0
|
plate_count = 0
|
||||||
copy_count = 0
|
copy_count = 0
|
||||||
prediction_total = 0.0
|
prediction_total = 0.0
|
||||||
@@ -72,6 +122,7 @@ def summarize_jobs_for_output(
|
|||||||
if summary.filament_used_m is not None:
|
if summary.filament_used_m is not None:
|
||||||
used_m_total += summary.filament_used_m * copies
|
used_m_total += summary.filament_used_m * copies
|
||||||
used_m_found = True
|
used_m_found = True
|
||||||
|
# 仅当至少有一个有效值时才会设置对应字段
|
||||||
return OutputSummary(
|
return OutputSummary(
|
||||||
plate_count=plate_count,
|
plate_count=plate_count,
|
||||||
copy_count=copy_count,
|
copy_count=copy_count,
|
||||||
@@ -82,7 +133,20 @@ def summarize_jobs_for_output(
|
|||||||
|
|
||||||
|
|
||||||
def sanitize_filename(file_name: str) -> str:
|
def sanitize_filename(file_name: str) -> str:
|
||||||
|
"""清理文件名中的非法字符,确保生成合法的文件路径。
|
||||||
|
|
||||||
|
将 Windows/Unix 中不允许出现在文件名中的字符替换为下划线,
|
||||||
|
并去掉末尾的点和空格。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
file_name: 原始文件名。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
str: 清理后的安全文件名,至少返回 "packed.3mf"。
|
||||||
|
"""
|
||||||
|
# 将非法字符替换为下划线
|
||||||
sanitized = re.sub(r"[\\/:*?\"<>|]+", "_", file_name).strip()
|
sanitized = re.sub(r"[\\/:*?\"<>|]+", "_", file_name).strip()
|
||||||
|
# 去除末尾的点号和空格(Windows 禁止)
|
||||||
sanitized = sanitized.rstrip(". ")
|
sanitized = sanitized.rstrip(". ")
|
||||||
return sanitized or "packed.3mf"
|
return sanitized or "packed.3mf"
|
||||||
|
|
||||||
@@ -93,15 +157,35 @@ def resolve_output_path(
|
|||||||
summary_resolver: SummaryResolver = read_3mf_summary,
|
summary_resolver: SummaryResolver = read_3mf_summary,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
) -> Path:
|
) -> 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:
|
if not jobs:
|
||||||
raise ValueError("No input 3MF file was provided.")
|
raise ValueError("No input 3MF file was provided.")
|
||||||
summary = summarize_jobs_for_output(jobs, summary_resolver)
|
summary = summarize_jobs_for_output(jobs, summary_resolver)
|
||||||
first_input = jobs[0].source_3mf
|
first_input = jobs[0].source_3mf
|
||||||
|
# 收集所有输入文件的主文件名(不含扩展名)
|
||||||
stems = [job.source_3mf.stem for job in jobs]
|
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
|
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"
|
sources_token = source_token if len(unique_stems) == 1 else f"{source_token}_and_{len(unique_stems) - 1}_more"
|
||||||
timestamp = now or datetime.now()
|
timestamp = now or datetime.now()
|
||||||
|
# 构建模板变量字典
|
||||||
tokens = SafeFormatDict(
|
tokens = SafeFormatDict(
|
||||||
source=source_token,
|
source=source_token,
|
||||||
sources=sources_token,
|
sources=sources_token,
|
||||||
@@ -112,6 +196,7 @@ def resolve_output_path(
|
|||||||
)
|
)
|
||||||
pattern = naming.filename_rule.strip() or DEFAULT_OUTPUT_PATTERN
|
pattern = naming.filename_rule.strip() or DEFAULT_OUTPUT_PATTERN
|
||||||
file_name = sanitize_filename(pattern.format_map(tokens))
|
file_name = sanitize_filename(pattern.format_map(tokens))
|
||||||
|
# 确保文件扩展名为 .3mf
|
||||||
if not file_name.lower().endswith(".3mf"):
|
if not file_name.lower().endswith(".3mf"):
|
||||||
file_name += ".3mf"
|
file_name += ".3mf"
|
||||||
output_dir_text = "" if naming.output_directory is None else str(naming.output_directory).strip()
|
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:
|
def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path:
|
||||||
|
"""为单次运行确保输出路径唯一,通过追加递增序号避免冲突。
|
||||||
|
|
||||||
|
若目标路径已被使用,则在文件名后添加 _2、_3 等后缀直至唯一。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
path: 期望的输出路径。
|
||||||
|
used_paths: 已使用路径的集合(会原地修改)。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
Path: 去重后的唯一路径。
|
||||||
|
"""
|
||||||
resolved_key = path.resolve(strict=False)
|
resolved_key = path.resolve(strict=False)
|
||||||
if resolved_key not in used_paths:
|
if resolved_key not in used_paths:
|
||||||
used_paths.add(resolved_key)
|
used_paths.add(resolved_key)
|
||||||
@@ -128,6 +224,7 @@ def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path:
|
|||||||
suffix = path.suffix
|
suffix = path.suffix
|
||||||
parent = path.parent
|
parent = path.parent
|
||||||
index = 2
|
index = 2
|
||||||
|
# 递增序号直到找到未使用的路径
|
||||||
while True:
|
while True:
|
||||||
candidate = parent / f"{stem}_{index}{suffix}"
|
candidate = parent / f"{stem}_{index}{suffix}"
|
||||||
candidate_key = candidate.resolve(strict=False)
|
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:
|
def _optional_float(value: object) -> float | None:
|
||||||
|
"""安全地将任意对象转换为浮点数,失败时返回 None。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
value: 待转换的任意对象。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
float | None: 转换成功返回浮点数,否则返回 None。
|
||||||
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
构建前:
|
||||||
|
- 每行自动算各自的输出路径(文件名规则 + 去重防覆盖)
|
||||||
|
- 弹出进度日志,记录每个文件的构建结果
|
||||||
|
- 有关闭全局预览标签的选项
|
||||||
@@ -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` |
|
||||||
|
|
||||||
|
---
|
||||||
@@ -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,2 +1,3 @@
|
|||||||
PySide6>=6.6
|
PySide6>=6.6
|
||||||
Pillow>=10.0
|
Pillow>=10.0
|
||||||
|
zlib-ng>=0.5
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
from a1_swap_mod_packer.cli import main
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -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 让板稳定
|
||||||
|
|
||||||
|
; --- 阶段3:Z 轴轻压校准 ---
|
||||||
|
G91 ; 切相对坐标
|
||||||
|
G380 S2 Z20 F1200 ; Z 轴力控上升 20mm,确保与板有良好接触
|
||||||
|
G90 ; 切回绝对坐标
|
||||||
|
|
||||||
|
; --- 阶段4:关闭软限位,高速前推弹板 ---
|
||||||
|
M211 X0 Y0 Z0 ; 关闭 X/Y/Z 轴软限位,允许超行程运动
|
||||||
|
G90 ; 确保绝对坐标
|
||||||
|
G1 Y266 F2000 ; 再次确认在后端位置
|
||||||
|
G1 Y0 F1000 ; 高速推到 Y0(最前端),利用惯性将板甩出
|
||||||
|
|
||||||
|
; --- 阶段5:Z 轴回压 + 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
@@ -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
|
; --- 阶段2:Z 轴交替下压(G380 S3/S2 ×4轮),压紧打印板 ---
|
||||||
G1 Y150 F1200
|
G91 ; 切换相对坐标模式
|
||||||
G1 Y40 F1200
|
G380 S3 Z-25 F1200 ; 力传感器碰撞退出:Z 轴下降 25mm 直到碰到板
|
||||||
;G1 Y260 F3000
|
G380 S2 Z85 F1200 ; 力传感器目标位移动:Z 轴上升 85mm 到预设力值
|
||||||
;The platform ensures that new plate can be added properly even when no plate are available.
|
G380 S3 Z-20 F1200 ; 第二轮下压碰撞检测
|
||||||
G380 S2 Y264 F2000
|
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;
|
; --- 阶段3:驻留稳定 ---
|
||||||
;G380 S3 Z-55 F1200
|
G4 P2000 ; 驻留 2 秒,让板稳定贴合
|
||||||
G1 Z-55 F1200
|
|
||||||
G90;
|
|
||||||
|
|
||||||
G1 Y120 F4000
|
; --- 阶段4:前推弹板 — 利用惯性将板从热床上推离 ---
|
||||||
|
G1 Y15 F2000 ; 先推到前端附近 Y15
|
||||||
|
G1 Y150 F1200 ; 后退一段
|
||||||
|
G1 Y40 F1200 ; 再推向前端
|
||||||
|
|
||||||
G28 Y
|
; 以下注释掉的 Y260 是备选方案
|
||||||
;Avoiding the use of the G380 command is to prevent failure in reaching the correct position.
|
; G1 Y260 F3000
|
||||||
G1 Y-1 F1200
|
|
||||||
;G380 S3 Y-1 F1200
|
|
||||||
|
|
||||||
|
; 用触力传感器推到最前端 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
|
G4 P500
|
||||||
|
|
||||||
;start Shake-triggered plate replacement
|
G1 Y2 F100 ; 前进 2mm
|
||||||
G1 Y1 F100
|
G1 Y0 F20000 ; 快速退回
|
||||||
G1 Y0 F20000
|
|
||||||
G4 P500
|
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 Y2.5 F100 ; 前进 2.5mm
|
||||||
G1 Y110 F5000
|
G1 Y0 F20000 ; 快速退回
|
||||||
G1 Y200 F1200
|
G4 P500
|
||||||
;G1 Y266 F1500
|
|
||||||
G380 S2 Y264 F2000
|
|
||||||
|
|
||||||
;After retracting the build plate to the frontmost position, dwell for 1 second.
|
G1 Y3 F100 ; 前进 3mm
|
||||||
G4 P1000
|
G1 Y0 F10000 ; 退回(降速)
|
||||||
|
G4 P500
|
||||||
|
|
||||||
G1 Y40 F4000
|
G1 Y3.5 F100 ; 前进 3.5mm(最大振幅)
|
||||||
G1 Y-2 F5000
|
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 Y1 F100
|
||||||
G1 Y0 F20000
|
G1 Y0 F20000
|
||||||
G4 P500
|
G4 P500
|
||||||
@@ -88,13 +118,15 @@ G4 P500
|
|||||||
G1 Y3.5 F100
|
G1 Y3.5 F100
|
||||||
G1 Y0 F5000
|
G1 Y0 F5000
|
||||||
|
|
||||||
;G1 Y2.5 F100
|
; 以下为注释掉的备选抖板参数
|
||||||
;G1 Y0 F8000
|
; G1 Y2.5 F100
|
||||||
;G4 P1000
|
; G1 Y0 F8000
|
||||||
;G1 Y3.5 F100
|
; G4 P1000
|
||||||
;G1 Y0 F5000
|
; G1 Y3.5 F100
|
||||||
;end Shake-triggered plate replacement
|
; G1 Y0 F5000
|
||||||
|
; 结束 振动触发换板
|
||||||
|
|
||||||
G1 Y120 F2000
|
; --- 阶段9:归位到待机位置 ---
|
||||||
|
G1 Y120 F2000 ; 回到 Y120 中间待机位置
|
||||||
|
|
||||||
;end change plate
|
; 结束换板
|
||||||
|
|||||||
+94
-69
@@ -1,93 +1,119 @@
|
|||||||
|
;==========================================================
|
||||||
|
; A1 SwapMod 换板 G-code — 类型 C1:安全换板(标准版)
|
||||||
|
; 特点:M400/M17 保护、G1 Z- + G380 S2 纯力控下压、双轮抖板(2.5mm偏移)
|
||||||
|
;==========================================================
|
||||||
|
|
||||||
;start change plate
|
; 开始换板
|
||||||
|
|
||||||
G28 Y
|
; --- 阶段1:Y 轴归零并退到后端 ---
|
||||||
G1 Y262 F2000
|
G28 Y ; Y 轴归零(寻找限位开关)
|
||||||
|
G1 Y262 F2000 ; 推到 Y262(后端位置,速度 F2000)
|
||||||
|
|
||||||
M400 ; wait all motion done
|
; --- 阶段2:运动保护 — 等所有运动停止 + 降低 Z 电机电流 ---
|
||||||
M17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom
|
M400 ; 等待所有运动队列清空
|
||||||
|
M17 Z0.4 ; 将 Z 轴电机电流降至 0.4A,降低阻力,防止后续 Y 轴移动时 Z 轴硬怼损伤打印件
|
||||||
|
|
||||||
G91
|
; --- 阶段3:Z 轴下压(G1 Z- + G380 S2 纯力控 ×4轮),压紧打印板 ---
|
||||||
G1 Z-35 F1200
|
G91 ; 切换相对坐标模式
|
||||||
G380 S2 Z95
|
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
|
G380 S2 Z90
|
||||||
|
|
||||||
G1 Z-25
|
G1 Z-30 ; 第四轮
|
||||||
G380 S2 Z90
|
|
||||||
|
|
||||||
G1 Z-30
|
|
||||||
G380 S2 Z95
|
G380 S2 Z95
|
||||||
|
|
||||||
G1 Z-5
|
G1 Z-5 ; Z 轴微降 5mm 释放张力
|
||||||
G90
|
G90 ; 切回绝对坐标
|
||||||
|
|
||||||
G4 P2000
|
; --- 阶段4:驻留稳定 ---
|
||||||
|
G4 P2000 ; 驻留 2 秒
|
||||||
|
|
||||||
G1 Y15 F800
|
; --- 阶段5:前推弹板 — 利用惯性将板推离 ---
|
||||||
G1 Y150 F1200
|
G1 Y15 F800 ; 缓慢推到前端 Y15
|
||||||
|
G1 Y150 F1200 ; 后退到 Y150
|
||||||
|
|
||||||
;This line controls how much distance the tail of the reel hangs. The smaller the value, the less distance it hangs.
|
; 此行控制卷料尾端的悬垂距离。值越小,悬垂越少。
|
||||||
G1 Y40 F1000
|
G1 Y40 F1000 ; 推到 Y40
|
||||||
|
|
||||||
;The platform ensures that new plate can be added properly even when no plate are available.
|
; 平台通过力控确保即使没有板也能正常装入新板
|
||||||
;G1 Y260 F3000
|
; G1 Y260 F3000 ; 注释掉:备选方案
|
||||||
G380 S2 Y264 F2000
|
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
|
||||||
|
|
||||||
G91;
|
; --- 阶段6:Z 轴上撤退让 ---
|
||||||
G1 Z-70 F1200
|
G91 ; 切相对坐标
|
||||||
G90;
|
G1 Z-70 F1200 ; Z 轴下降 70mm(使喷嘴远离板面,为弹射留空间)
|
||||||
|
G90 ; 切回绝对坐标
|
||||||
|
|
||||||
;
|
; --- 阶段7:Y 轴高速移动 + 微调到弹射起始位 ---
|
||||||
G1 Y200 F10000
|
G1 Y200 F10000 ; 高速推到 Y200
|
||||||
|
G1 Y25 F4000 ; 减速靠前
|
||||||
|
|
||||||
G1 Y25 F4000
|
; 使用 G1 而非 G380,防止触力传感器干扰定位
|
||||||
|
G1 Y-1 F20000 ; 高速后退 1mm(为抖板准备起始位)
|
||||||
|
; G380 S3 Y-1 F1200 ; 注释掉:原触力退出方式
|
||||||
|
|
||||||
;Avoiding the use of the G380 command is to prevent failure in reaching the correct position.
|
G4 P500 ; 驻留 500ms
|
||||||
G1 Y-1 F20000
|
|
||||||
;G380 S3 Y-1 F1200
|
|
||||||
|
|
||||||
|
; ═══════════════════════════════════════════
|
||||||
|
; 抖板 第1轮:微小 Y 轴往复振动,确保板完全脱离
|
||||||
|
; 振幅逐步增大:1mm → 2.5mm(比类型B更保守)
|
||||||
|
; ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
G28 Y ; 先 Y 轴归零校准
|
||||||
|
|
||||||
|
; 开始 振动触发换板
|
||||||
|
G1 Y1 F100 ; 前进 1mm(慢速)
|
||||||
|
G1 Y0 F20000 ; 快速退回
|
||||||
G4 P500
|
G4 P500
|
||||||
|
|
||||||
G28 Y
|
G1 Y1.5 F100 ; 前进 1.5mm
|
||||||
|
G1 Y0 F20000 ; 快速退回
|
||||||
|
G4 P500
|
||||||
|
|
||||||
;start Shake-triggered plate replacement
|
G1 Y2 F100 ; 前进 2mm
|
||||||
G1 Y1 F100
|
G1 Y0 F20000 ; 快速退回
|
||||||
G1 Y0 F20000
|
|
||||||
G4 P500
|
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 Y2.5 F100 ; 前进 2.5mm
|
||||||
G1 Y110 F5000
|
G1 Y0 F20000 ; 快速退回
|
||||||
G1 Y200 F1200
|
G4 P500
|
||||||
;G1 Y266 F1500
|
|
||||||
G380 S2 Y264 F2000
|
|
||||||
|
|
||||||
;After retracting the build plate to the frontmost position, dwell for 1 second.
|
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
|
G4 P1000
|
||||||
|
|
||||||
G1 Y25 F4000
|
; --- 阶段9:第2轮弹射 ---
|
||||||
G1 Y-2 F20000
|
G1 Y25 F4000 ; 推到 Y25
|
||||||
;G1 Y-2 F1000
|
G1 Y-2 F20000 ; ⚡ 高速反向弹射 2mm(F20000,最快的弹射速度)
|
||||||
|
|
||||||
G4 P500
|
; G1 Y-2 F1000 ; 慢速备选
|
||||||
|
|
||||||
;start Shake-triggered plate replacement
|
G4 P500 ; 驻留 500ms
|
||||||
|
|
||||||
|
; ═══════════════════════════════════════════
|
||||||
|
; 抖板 第2轮:再次振动确保无残留
|
||||||
|
; 振幅:1mm → 2.5mm
|
||||||
|
; ═══════════════════════════════════════════
|
||||||
|
; 开始 振动触发换板
|
||||||
G1 Y1 F100
|
G1 Y1 F100
|
||||||
G1 Y0 F20000
|
G1 Y0 F20000
|
||||||
G4 P500
|
G4 P500
|
||||||
@@ -104,12 +130,11 @@ G1 Y2.5 F100
|
|||||||
G1 Y0 F10000
|
G1 Y0 F10000
|
||||||
G4 P500
|
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
|
|
||||||
|
|||||||
@@ -1,58 +1,71 @@
|
|||||||
|
;==========================================================
|
||||||
|
; A1 SwapMod 换板 G-code — 类型 C2:安全换板(慢速版)
|
||||||
|
; 特点:与 C1 标准版完全相同,仅第2轮弹射速度从 F20000 降为 F1000
|
||||||
|
; 更温和的弹射,减少对板和打印机的冲击
|
||||||
|
;==========================================================
|
||||||
|
|
||||||
;start change plate
|
; 开始换板
|
||||||
|
|
||||||
G28 Y
|
; --- 阶段1:Y 轴归零并退到后端 ---
|
||||||
G1 Y262 F2000
|
G28 Y ; Y 轴归零(寻找限位开关)
|
||||||
|
G1 Y262 F2000 ; 推到 Y262(后端位置)
|
||||||
|
|
||||||
M400 ; wait all motion done
|
; --- 阶段2:运动保护 ---
|
||||||
M17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom
|
M400 ; 等待所有运动队列清空
|
||||||
|
M17 Z0.4 ; 将 Z 轴电机电流降至 0.4A,降低阻力,防止碰撞损伤打印件
|
||||||
|
|
||||||
G91
|
; --- 阶段3:Z 轴下压(G1 Z- + G380 S2 纯力控 ×4轮)---
|
||||||
G1 Z-35 F1200
|
G91 ; 切换相对坐标
|
||||||
G380 S2 Z95
|
G1 Z-35 F1200 ; Z 轴下降 35mm
|
||||||
|
G380 S2 Z95 ; 力控上升 95mm
|
||||||
|
|
||||||
G1 Z-25
|
G1 Z-25 ; 第二轮
|
||||||
G380 S2 Z90
|
G380 S2 Z90
|
||||||
|
|
||||||
G1 Z-25
|
G1 Z-25 ; 第三轮
|
||||||
G380 S2 Z90
|
G380 S2 Z90
|
||||||
|
|
||||||
G1 Z-30
|
G1 Z-30 ; 第四轮
|
||||||
G380 S2 Z95
|
G380 S2 Z95
|
||||||
|
|
||||||
G1 Z-5
|
G1 Z-5 ; Z 轴微降释放张力
|
||||||
G90
|
G90 ; 切回绝对坐标
|
||||||
|
|
||||||
G4 P2000
|
; --- 阶段4:驻留稳定 ---
|
||||||
|
G4 P2000 ; 驻留 2 秒
|
||||||
|
|
||||||
G1 Y15 F800
|
; --- 阶段5:前推弹板 ---
|
||||||
G1 Y150 F1200
|
G1 Y15 F800 ; 缓慢推到前端 Y15
|
||||||
|
G1 Y150 F1200 ; 后退到 Y150
|
||||||
|
|
||||||
;This line controls how much distance the tail of the reel hangs. The smaller the value, the less distance it hangs.
|
; 此行控制卷料尾端的悬垂距离。值越小,悬垂越少。
|
||||||
G1 Y40 F1000
|
G1 Y40 F1000 ; 推到 Y40
|
||||||
|
|
||||||
;The platform ensures that new plate can be added properly even when no plate are available.
|
; 平台通过力控确保即使没有板也能正常装入新板
|
||||||
;G1 Y260 F3000
|
; G1 Y260 F3000
|
||||||
G380 S2 Y264 F2000
|
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
|
||||||
|
|
||||||
G91;
|
; --- 阶段6:Z 轴上撤退让 ---
|
||||||
G1 Z-70 F1200
|
G91 ; 切相对坐标
|
||||||
G90;
|
G1 Z-70 F1200 ; Z 轴下降 70mm(为弹射留空间)
|
||||||
|
G90 ; 切回绝对坐标
|
||||||
|
|
||||||
;
|
; --- 阶段7:Y 轴高速移动 + 微调定位 ---
|
||||||
G1 Y200 F10000
|
G1 Y200 F10000 ; 高速推到 Y200
|
||||||
|
G1 Y25 F4000 ; 减速靠前
|
||||||
|
|
||||||
G1 Y25 F4000
|
; 使用 G1 而非 G380,防止触力传感器干扰定位
|
||||||
|
G1 Y-1 F20000 ; 高速后退 1mm
|
||||||
|
|
||||||
;Avoiding the use of the G380 command is to prevent failure in reaching the correct position.
|
G4 P500 ; 驻留 500ms
|
||||||
G1 Y-1 F20000
|
|
||||||
;G380 S3 Y-1 F1200
|
|
||||||
|
|
||||||
G4 P500
|
; --- 抖板校准归零 ---
|
||||||
|
G28 Y ; Y 轴归零校准
|
||||||
|
|
||||||
G28 Y
|
; ═══════════════════════════════════════════
|
||||||
|
; 抖板 第1轮:振动确保板完全脱离(1→2.5mm)
|
||||||
;start Shake-triggered plate replacement
|
; ═══════════════════════════════════════════
|
||||||
|
; 开始 振动触发换板
|
||||||
G1 Y1 F100
|
G1 Y1 F100
|
||||||
G1 Y0 F20000
|
G1 Y0 F20000
|
||||||
G4 P500
|
G4 P500
|
||||||
@@ -68,26 +81,32 @@ G4 P500
|
|||||||
G1 Y2.5 F100
|
G1 Y2.5 F100
|
||||||
G1 Y0 F10000
|
G1 Y0 F10000
|
||||||
G4 P500
|
G4 P500
|
||||||
;G1 Y3.5 F100
|
; G1 Y3.5 F100 ; 注释掉:更大的振幅(备选)
|
||||||
;G1 Y0 F5000
|
; G1 Y0 F5000
|
||||||
;end Shake-triggered plate replacement
|
; 结束 振动触发换板
|
||||||
|
|
||||||
|
; --- 阶段8:推板 + 力控校准 ---
|
||||||
G1 Y235 F1200
|
G1 Y235 F1200
|
||||||
G1 Y110 F5000
|
G1 Y110 F5000
|
||||||
G1 Y200 F1200
|
G1 Y200 F1200
|
||||||
;G1 Y266 F1500
|
; G1 Y266 F1500
|
||||||
G380 S2 Y264 F2000
|
G380 S2 Y264 F2000 ; 力控推到最前端
|
||||||
|
|
||||||
;After retracting the build plate to the frontmost position, dwell for 1 second.
|
; 驻留 1 秒
|
||||||
G4 P1000
|
G4 P1000
|
||||||
|
|
||||||
G1 Y25 F4000
|
; --- 阶段9:第2轮弹射(🐌 慢速版关键差异)---
|
||||||
;G1 Y-2 F20000
|
G1 Y25 F4000 ; 推到 Y25
|
||||||
G1 Y-2 F1000
|
; G1 Y-2 F20000 ; 注释掉:原标准版高速弹射
|
||||||
|
G1 Y-2 F1000 ; 🐌 慢速弹射(F1000 vs 标准版 F20000)
|
||||||
|
; 更温和,减少对板和结构的冲击
|
||||||
|
|
||||||
G4 P500
|
G4 P500
|
||||||
|
|
||||||
;start Shake-triggered plate replacement
|
; ═══════════════════════════════════════════
|
||||||
|
; 抖板 第2轮:再次振动(1→2.5mm)
|
||||||
|
; ═══════════════════════════════════════════
|
||||||
|
; 开始 振动触发换板
|
||||||
G1 Y1 F100
|
G1 Y1 F100
|
||||||
G1 Y0 F20000
|
G1 Y0 F20000
|
||||||
G4 P500
|
G4 P500
|
||||||
@@ -104,12 +123,11 @@ G1 Y2.5 F100
|
|||||||
G1 Y0 F10000
|
G1 Y0 F10000
|
||||||
G4 P500
|
G4 P500
|
||||||
|
|
||||||
|
; 结束 振动触发换板
|
||||||
|
|
||||||
;end Shake-triggered plate replacement
|
; --- 阶段10:归位 ---
|
||||||
|
; 推到前端
|
||||||
;move to front
|
|
||||||
G1 Y262 F1500
|
G1 Y262 F1500
|
||||||
|
G1 Y120 F2000 ; 回到 Y120 待机位置
|
||||||
|
|
||||||
G1 Y120 F2000
|
; 结束换板
|
||||||
|
|
||||||
;end change plate
|
|
||||||
|
|||||||
@@ -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,降低阻力,防止碰撞损伤打印件
|
||||||
|
|
||||||
|
; --- 阶段3:Z 轴下压(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 轴下降,离开顶部
|
||||||
|
;=================================================
|
||||||
|
|
||||||
|
; --- 阶段6:Z 轴上撤退让 ---
|
||||||
|
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 ; 再次确认到位
|
||||||
@@ -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: 预览图填充颜色(RGBA),None 则写入占位字节
|
||||||
|
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()
|
||||||
@@ -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):
|
||||||
|
# 任务数 20,CPU 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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user