Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -15,4 +15,5 @@ Thumbs.db
|
||||
|
||||
*.bak
|
||||
/settings.json
|
||||
/reference
|
||||
codec.py
|
||||
@@ -1,87 +1,218 @@
|
||||
# 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。
|
||||
- 可重复执行的自动化 CLI。
|
||||
- 支持多个 `.3mf` 输入文件。
|
||||
- 每个文件独立份数。
|
||||
- 自动从 `Metadata/slice_info.config` 读取并汇总时间和耗材。
|
||||
- 外部 SwapMod G-code 模板目录。
|
||||
- 可编辑 G-code 补丁文件。
|
||||
- 可选通过 `M73 R...` 编码剩余时间板号。
|
||||
- GUI 中选中文件缩略图预览。
|
||||
- 最多 9 个不同输入文件的预览图合成,带 `{plates} P` 绿色标签。
|
||||
- 合并模式:所有输入行合并为一个打包 3MF。
|
||||
- 独立批处理模式:每行输入各生成一个独立打包 3MF。
|
||||
- zlib-ng Deflate ZIP 压缩,默认级别 7。
|
||||
- GUI 设置保存到程序目录的 `settings.json`。
|
||||
|
||||
## 源码安装
|
||||
|
||||
推荐 Python 3.13 及以上(Nuitka 4.x 对 3.14 仅实验性支持)。Python 3.10+ 亦可使用。
|
||||
|
||||
安装依赖:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
Run the GUI:
|
||||
|
||||
当前运行时依赖:
|
||||
|
||||
| 依赖 | 用途 |
|
||||
|---|---|
|
||||
| `PySide6` | GUI 界面、拖放表格、文件对话框、缩略图预览、设置界面 |
|
||||
| `Pillow` | 读取和合成输出 3MF 中的 PNG 预览图,包括 `{plates} P` 标签 |
|
||||
| `zlib-ng` | ZIP Deflate 压缩后端,用于写入压缩 3MF 并控制压缩级别 |
|
||||
|
||||
当前 Python 源码版本不需要 Java 运行时、Bambu Studio SDK、外部压缩工具或加密/模板解码器。
|
||||
|
||||
启动 GUI:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.gui
|
||||
```
|
||||
|
||||
or
|
||||
或
|
||||
|
||||
```bash
|
||||
python run_gui.py
|
||||
```
|
||||
|
||||
Run the CLI:
|
||||
启动 CLI:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli --help
|
||||
```
|
||||
|
||||
or
|
||||
或
|
||||
|
||||
```bash
|
||||
python run_cli.py
|
||||
```
|
||||
|
||||
## Swap G-code templates
|
||||
查看版本:
|
||||
|
||||
The GUI automatically scans this fixed folder:
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli --version
|
||||
```
|
||||
|
||||
## 构建 Windows 可执行文件
|
||||
|
||||
仓库包含 Windows 构建脚本:
|
||||
|
||||
```cmd
|
||||
build_win.cmd
|
||||
```
|
||||
|
||||
脚本使用 Nuitka onefile 模式,生成如下便携发布目录:
|
||||
|
||||
```text
|
||||
build/onefile/
|
||||
```
|
||||
|
||||
预期输出:
|
||||
|
||||
```text
|
||||
build/onefile/
|
||||
a1packer.exe
|
||||
a1packer-cli.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`
|
||||
- CLI:`run_cli.py` → `build/onefile/a1packer-cli.exe`
|
||||
|
||||
GUI 编译启用 PySide6 插件并包含当前界面所需的 Qt 插件组:
|
||||
|
||||
```text
|
||||
platforms,imageformats,styles,iconengines
|
||||
```
|
||||
|
||||
### 构建后验证
|
||||
|
||||
发布目录前,先确认 CLI 能识别外部资源:
|
||||
|
||||
```cmd
|
||||
build\onefile\a1packer-cli.exe --version
|
||||
build\onefile\a1packer-cli.exe list-swap-gcode
|
||||
```
|
||||
|
||||
第二条命令应列出:
|
||||
|
||||
```text
|
||||
build/onefile/swap_gcode/
|
||||
```
|
||||
|
||||
中的文件。
|
||||
|
||||
同时启动一次 `build/onefile/a1packer.exe`,确认:
|
||||
|
||||
- 换盘 G-code 下拉框列出已复制的模板。
|
||||
- 修改 GUI 选项会创建或更新 `build/onefile/settings.json`。
|
||||
- 首次启动前 `settings.json` 不存在时程序仍能正常运行。
|
||||
|
||||
## 换盘 G-code 模板
|
||||
|
||||
GUI 会自动扫描以下固定目录:
|
||||
|
||||
```text
|
||||
swap_gcode/
|
||||
```
|
||||
|
||||
Plain UTF-8 G-code files are read directly.
|
||||
纯 UTF-8 文本文件直接读取。当前扫描器接受以下后缀:
|
||||
|
||||
In the GUI:
|
||||
```text
|
||||
.gcode
|
||||
.nc
|
||||
.ngc
|
||||
.txt
|
||||
```
|
||||
|
||||
1. Put template files in `swap_gcode/`.
|
||||
2. Click **Refresh** next to **Swap G-code**.
|
||||
3. Select the template from the dropdown.
|
||||
当前源码版本不解码加密的或厂商模板的 G-code 归档。
|
||||
|
||||
## Editable G-code patches
|
||||
GUI 中使用方法:
|
||||
|
||||
The GUI and CLI read the fixed patch file:
|
||||
1. 将模板文件放入 `swap_gcode/`。
|
||||
2. 点击 **换盘 G-code** 旁的 **刷新**。
|
||||
3. 从下拉框中选择模板。
|
||||
|
||||
## 可编辑 G-code 补丁
|
||||
|
||||
GUI 和 CLI 都读取固定补丁文件:
|
||||
|
||||
```text
|
||||
gcode_patches.ini
|
||||
```
|
||||
|
||||
Default rule:
|
||||
默认规则:
|
||||
|
||||
```ini
|
||||
[patch.a1_start_y]
|
||||
@@ -92,181 +223,200 @@ replace = G0 Y250 F3000 ; Patched
|
||||
max_count = 1
|
||||
```
|
||||
|
||||
Meaning:
|
||||
含义:
|
||||
|
||||
- Search after the optional `flag` line.
|
||||
- Replace the first matching `find` line with `replace`.
|
||||
- Apply at most `max_count` replacements.
|
||||
- 从可选的 `flag` 行之后开始查找。
|
||||
- 将第一个匹配 `find` 的行替换为 `replace`。
|
||||
- 最多替换 `max_count` 次。
|
||||
|
||||
You can disable this in the GUI with **G-code patches**, or edit the INI file and keep the option enabled.
|
||||
可在 GUI 中通过 **G-code 补丁** 复选框禁用,或编辑 INI 文件并保持启用。
|
||||
|
||||
The swap insertion marker can also be edited:
|
||||
换板插入标记也可编辑:
|
||||
|
||||
```ini
|
||||
[swap]
|
||||
insert_before_marker = ;=====printer finish sound=========
|
||||
```
|
||||
|
||||
## GUI guide
|
||||
## GUI 使用指南
|
||||
|
||||
### Input 3MF files
|
||||
### 输入 3MF 文件
|
||||
|
||||
The input table supports:
|
||||
输入表格支持:
|
||||
|
||||
- **Add 3MF**: choose one or more `.3mf` files.
|
||||
- Drag and drop `.3mf` files into the table.
|
||||
- Drag and drop a folder into the table. The GUI adds top-level `.3mf` files from that folder.
|
||||
- **Remove**: remove selected rows.
|
||||
- **Remove All**: clear the full input list.
|
||||
- **Move Up / Move Down**: change input order.
|
||||
- **Apply Default Copies to Selected**: overwrite selected rows with the current default copy count.
|
||||
- **添加 3MF**:选择一个或多个 `.3mf` 文件。
|
||||
- 拖放 `.3mf` 文件到表格中。
|
||||
- 拖放文件夹到表格中。GUI 会添加该文件夹内所有顶层 `.3mf` 文件。
|
||||
- **移除**:移除选中行。
|
||||
- **全部移除**:清空全部输入列表。
|
||||
- **上移 / 下移**:调整输入顺序。
|
||||
- **应用默认份数至选中行**:将当前默认份数覆盖到选中行。
|
||||
|
||||
Columns:
|
||||
列说明:
|
||||
|
||||
- **3MF path**: source file path.
|
||||
- **Copies**: how many times this source should be repeated.
|
||||
- **Time**: estimated print time multiplied by copies.
|
||||
- **Filament**: estimated filament multiplied by copies.
|
||||
- **3MF 文件**:源文件路径。
|
||||
- **份数**:该源文件重复的次数。
|
||||
- **时间**:预估打印时间 × 份数。
|
||||
- **耗材**:预估耗材用量 × 份数。
|
||||
|
||||
The time and filament values are read from:
|
||||
时间和耗材数据读取自:
|
||||
|
||||
```text
|
||||
Metadata/slice_info.config
|
||||
```
|
||||
|
||||
If the source 3MF lacks this metadata, the GUI shows `Unknown`.
|
||||
如果源 3MF 缺少此元数据,GUI 显示"未知"。
|
||||
|
||||
The summary line under the input list shows the total plate count, estimated time, and filament for the current table.
|
||||
输入列表下方的汇总行显示当前表格的总板数、预估时间和耗材。
|
||||
|
||||
### Build 3MF button
|
||||
右侧缩略图面板显示选中输入文件的活动面板预览(若 3MF 中包含)。
|
||||
|
||||
The **Build 3MF** button is placed below the input list on the right side for fast batch operation.
|
||||
### 构建 3MF 按钮
|
||||
|
||||
In normal combined mode, one click creates one output 3MF containing all input rows.
|
||||
**构建 3MF** 按钮位于输入列表右下方,便于快速批量操作。
|
||||
|
||||
In individual batch mode, one click creates one output 3MF per input row.
|
||||
正常合并模式下,一次点击生成一个包含所有输入行的输出 3MF。
|
||||
|
||||
### Swap G-code
|
||||
独立批处理模式下,一次点击为每行输入各生成一个输出 3MF。
|
||||
|
||||
Selects the ejection/swap G-code block inserted into each repeated plate.
|
||||
### 换盘 G-code
|
||||
|
||||
The dropdown is generated from files in:
|
||||
选择插入到每个重复面板中的弹射/换板 G-code 块。
|
||||
|
||||
下拉框内容来自:
|
||||
|
||||
```text
|
||||
swap_gcode/
|
||||
```
|
||||
|
||||
Buttons:
|
||||
按钮:
|
||||
|
||||
- **Refresh**: rescan the folder.
|
||||
- **Open Folder**: open the template folder.
|
||||
- **刷新**:重新扫描目录。
|
||||
- **打开文件夹**:打开模板目录。
|
||||
|
||||
### Default copies for new inputs
|
||||
### 新输入的默认份数
|
||||
|
||||
Sets the default copy count applied to newly added or dragged-in files.
|
||||
设置添加到或拖入的新文件的默认份数。
|
||||
|
||||
This does not automatically change existing rows. Use **Apply Default Copies to Selected** for existing rows.
|
||||
这不会自动更改已有行。对已有行使用 **应用默认份数至选中行**。
|
||||
|
||||
### Bed cooldown
|
||||
> 提示:所有数值输入框均为纯数字输入(QLineEdit),无上下箭头,可直接键入任意数值。
|
||||
|
||||
Controls whether the tool inserts a bed temperature wait before the SwapMod G-code block.
|
||||
### 热床降温
|
||||
|
||||
Enabled example:
|
||||
控制是否在 SwapMod G-code 块之前插入热床等待温度。
|
||||
|
||||
启用示例:
|
||||
|
||||
```gcode
|
||||
M190 S45
|
||||
```
|
||||
|
||||
If disabled, no `M190` line is added by the packer.
|
||||
禁用后,打包器不添加 `M190` 行。
|
||||
|
||||
### Wait after ejection
|
||||
### 换盘后等待时间
|
||||
|
||||
Adds a dwell after the SwapMod G-code block.
|
||||
勾选框控制是否在换盘 G-code 块之后插入驻留时间。
|
||||
|
||||
Example for 45 seconds:
|
||||
默认 30 秒,可通过纯数字输入框(QLineEdit)调整:
|
||||
|
||||
```gcode
|
||||
G4 P45000
|
||||
G4 P30000
|
||||
```
|
||||
|
||||
### Remaining-time plate number
|
||||
### 剩余时间板号
|
||||
|
||||
When enabled, the packer offsets `M73 ... R...` remaining-time values by:
|
||||
启用后,打包器按以下公式偏移 `M73 ... R...` 剩余时间值:
|
||||
|
||||
```text
|
||||
plate_number × 100 hours × 60 minutes
|
||||
板号 × 100 小时 × 60 分钟
|
||||
```
|
||||
|
||||
This allows the A1 remaining-time hour hundreds digit to display the current plate number.
|
||||
这使得 A1 剩余时间的百位能显示当前板号。
|
||||
|
||||
Example concept:
|
||||
示例:
|
||||
|
||||
- Plate 1: +6000 minutes
|
||||
- Plate 2: +12000 minutes
|
||||
- Plate 3: +18000 minutes
|
||||
- 第 1 板:+6000 分钟
|
||||
- 第 2 板:+12000 分钟
|
||||
- 第 3 板:+18000 分钟
|
||||
|
||||
### Final swap
|
||||
### 最后换盘
|
||||
|
||||
When enabled, the swap/ejection block is also inserted after the final repeated plate.
|
||||
启用后,换板/弹射块也会插入到最后一个重复面板之后。
|
||||
|
||||
When disabled, the final repeated plate ends normally without running the swap G-code block.
|
||||
禁用后,最后一个重复面板正常结束,不执行换盘 G-code 块。
|
||||
|
||||
### G-code patches
|
||||
### G-code 补丁
|
||||
|
||||
Applies rules from:
|
||||
应用来自以下文件的规则:
|
||||
|
||||
```text
|
||||
gcode_patches.ini
|
||||
```
|
||||
|
||||
The **Open Config** button opens that fixed file for editing.
|
||||
**打开配置文件** 按钮打开该固定文件进行编辑。
|
||||
|
||||
### 3MF metadata
|
||||
### 3MF 元数据
|
||||
|
||||
Options:
|
||||
选项:
|
||||
|
||||
- **Keep source prediction and weight**
|
||||
Leaves `slice_info.config` prediction and weight values from the base 3MF unchanged. This best matches the observed vendor packer behavior.
|
||||
- **累加预测和耗材用量(默认)**
|
||||
使用所有重复面板的总和更新第一块面板的元数据。从统计角度看更合理,适合 SwapMod 场景。
|
||||
|
||||
- **Sum prediction and filament**
|
||||
Updates the first plate metadata using the sum of all repeated source plates. This is more logically correct for statistics, but may differ from the vendor software.
|
||||
- **保留原始预测和重量**
|
||||
保持基础 3MF 中 `slice_info.config` 的预测和重量值不变。
|
||||
|
||||
### Batch mode
|
||||
### 预览图处理
|
||||
|
||||
#### Combined mode
|
||||
默认情况下,输出 3MF 保留基础归档中对应当前输出面板的预览成员,并用以下内容重写这些 PNG 预览:
|
||||
|
||||
Default behavior.
|
||||
- 最多 9 个不同输入文件的预览图合成;
|
||||
- 一个简短的绿色标签(如 `5 P`),施加在最终合成图上。
|
||||
|
||||
All input rows are packed into one output file.
|
||||
如果预览图缺失或无法读取,打包器保留可用基础预览,仍尝试施加面板标签。
|
||||
|
||||
Example:
|
||||
CLI 可通过以下选项禁用预览重写:
|
||||
|
||||
```text
|
||||
A.3mf copies 2
|
||||
B.3mf copies 3
|
||||
```bash
|
||||
--no-preview-label
|
||||
```
|
||||
|
||||
Output:
|
||||
### 批处理模式
|
||||
|
||||
#### 合并模式
|
||||
|
||||
默认行为。
|
||||
|
||||
所有输入行打包为一个输出文件。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
one packed 3MF containing A, A, B, B, B
|
||||
A.3mf 份数 2
|
||||
B.3mf 份数 3
|
||||
```
|
||||
|
||||
#### Individual batch mode
|
||||
|
||||
When enabled, the GUI shows an explanation popup.
|
||||
|
||||
Every input row is treated as an independent build.
|
||||
|
||||
Example:
|
||||
输出:
|
||||
|
||||
```text
|
||||
A.3mf copies 5
|
||||
B.3mf copies 5
|
||||
C.3mf copies 5
|
||||
一个打包 3MF,包含 A, A, B, B, B
|
||||
```
|
||||
|
||||
Output:
|
||||
#### 独立批处理模式
|
||||
|
||||
启用时,GUI 显示说明弹窗。
|
||||
|
||||
每行输入被视为独立构建。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
A.3mf 份数 5
|
||||
B.3mf 份数 5
|
||||
C.3mf 份数 5
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```text
|
||||
5 Plates - A.3mf
|
||||
@@ -274,53 +424,55 @@ Output:
|
||||
5 Plates - C.3mf
|
||||
```
|
||||
|
||||
This is intended for quickly converting a large batch of independent single-plate 3MF files into multi-copy SwapMod packs.
|
||||
此模式用于快速将大量独立的单板 3MF 文件批量转换为多份数 SwapMod 包。
|
||||
|
||||
It does **not** combine all inputs into one file.
|
||||
GUI 并行构建这些独立输出,并发数受 CPU 核心数及安全上限限制。
|
||||
|
||||
### Input handling
|
||||
它**不会**将所有输入合并为一个文件。
|
||||
|
||||
Options:
|
||||
### 输入处理
|
||||
|
||||
- **Skip duplicate file paths when adding inputs**
|
||||
Prevents accidentally adding the same path multiple times.
|
||||
- **添加输入时跳过重复文件路径**
|
||||
防止意外多次添加同一路径。
|
||||
|
||||
- **Clear input list after successful build**
|
||||
Clears the table after a successful build. In individual batch mode, the list is cleared only if all outputs are built successfully.
|
||||
### 构建成功后
|
||||
|
||||
### Output directory
|
||||
- **清空输入列表**
|
||||
构建成功后清空表格。在独立批处理模式下,仅当所有输出构建成功后才会清空。
|
||||
|
||||
If this field is empty:
|
||||
### 输出目录
|
||||
|
||||
- Combined mode writes next to the first input file.
|
||||
- Individual batch mode writes each output next to its own input file.
|
||||
如果此字段为空:
|
||||
|
||||
If a directory is selected, all outputs go to that directory.
|
||||
- 合并模式写入第一个输入文件同目录。
|
||||
- 独立批处理模式将每个输出写入各自输入文件同目录。
|
||||
|
||||
### Output filename rule
|
||||
如果选择了目录,所有输出都写入该目录。
|
||||
|
||||
Default:
|
||||
### 输出文件名规则
|
||||
|
||||
默认规则:
|
||||
|
||||
```text
|
||||
{plates} Plates - {sources}.3mf
|
||||
```
|
||||
|
||||
Click the `?` button next to the rule field to show token help.
|
||||
点击规则字段旁的 `?` 按钮显示标记帮助。
|
||||
|
||||
Available tokens:
|
||||
可用标记:
|
||||
|
||||
| Token | Meaning |
|
||||
| 标记 | 含义 |
|
||||
|---|---|
|
||||
| `{source}` | First input file stem, without `.3mf` |
|
||||
| `{sources}` | Source summary. One source uses its stem; multiple sources become `first_source_and_N_more` |
|
||||
| `{plates}` | Total plate count in this output |
|
||||
| `{copies}` | Total copy count in this output |
|
||||
| `{date}` | Current date as `YYYYMMDD` |
|
||||
| `{time}` | Current time as `HHMMSS` |
|
||||
| `{source}` | 第一个输入文件的文件名(不含 `.3mf`) |
|
||||
| `{sources}` | 源文件摘要。单个源使用其文件名;多个源则显示为 `first_source_and_N_more` |
|
||||
| `{plates}` | 本输出中的总板数 |
|
||||
| `{copies}` | 本输出中的总份数 |
|
||||
| `{date}` | 当前日期,格式 `YYYYMMDD` |
|
||||
| `{time}` | 当前时间,格式 `HHMMSS` |
|
||||
|
||||
In individual batch mode, tokens are calculated per input row.
|
||||
在独立批处理模式下,标记按每行输入单独计算。
|
||||
|
||||
Examples:
|
||||
示例:
|
||||
|
||||
```text
|
||||
{plates} Plates - {sources}.3mf
|
||||
@@ -328,34 +480,42 @@ SwapMod - {source} - x{copies}.3mf
|
||||
{date}_{time}_{source}.3mf
|
||||
```
|
||||
|
||||
## GUI settings
|
||||
## GUI 设置
|
||||
|
||||
The GUI writes settings to the program folder:
|
||||
GUI 将设置写入程序目录:
|
||||
|
||||
```text
|
||||
settings.json
|
||||
```
|
||||
|
||||
This is intentional so a portable extracted folder or future packaged `.exe` build keeps its options next to the application.
|
||||
这是有意为之,以便便携式解压目录或打包的 `.exe` 版本能将选项保持在应用程序旁边。
|
||||
|
||||
Saved options include:
|
||||
保存的选项包括:
|
||||
|
||||
- Selected Swap G-code file.
|
||||
- Default copies.
|
||||
- Bed cooldown settings.
|
||||
- Wait after ejection.
|
||||
- Remaining-time plate-number switch.
|
||||
- Final swap switch.
|
||||
- G-code patch switch.
|
||||
- Metadata mode.
|
||||
- Individual batch mode.
|
||||
- Input handling options.
|
||||
- Output directory.
|
||||
- Output filename rule.
|
||||
- 当前选中的换盘 G-code 文件。
|
||||
- 默认份数。
|
||||
- 热床降温设置。
|
||||
- 换盘后等待时间。
|
||||
- 剩余时间板号开关。
|
||||
- 最后换盘开关。
|
||||
- G-code 补丁开关。
|
||||
- 元数据模式。
|
||||
- ZIP 压缩级别。
|
||||
- 独立批处理模式。
|
||||
- 输入处理选项。
|
||||
- 构建成功后清空输入列表。
|
||||
- 输出目录。
|
||||
- 输出文件名规则。
|
||||
|
||||
## CLI examples
|
||||
## CLI 使用示例
|
||||
|
||||
Build one source with five copies:
|
||||
列出可用的换盘 G-code 文件:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli list-swap-gcode
|
||||
```
|
||||
|
||||
单源文件 × 5 份:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
@@ -367,7 +527,7 @@ python -m a1_swap_mod_packer.cli build \
|
||||
-o "5 Plates - SC05720_01.gcode(1).3mf"
|
||||
```
|
||||
|
||||
Build multiple sources into one output:
|
||||
多源文件合并输出:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
@@ -377,7 +537,7 @@ python -m a1_swap_mod_packer.cli build \
|
||||
-o "5 Plates - A_and_B.3mf"
|
||||
```
|
||||
|
||||
Use summed metadata instead of source metadata:
|
||||
使用累加元数据模式:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
@@ -387,7 +547,7 @@ python -m a1_swap_mod_packer.cli build \
|
||||
-o "5 Plates - A.3mf"
|
||||
```
|
||||
|
||||
Disable editable G-code patches:
|
||||
禁用 G-code 补丁:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
@@ -397,3 +557,61 @@ python -m a1_swap_mod_packer.cli build \
|
||||
-o "5 Plates - A.3mf"
|
||||
```
|
||||
|
||||
位置参数输入 + 统一份数:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
"A.3mf" "B.3mf" \
|
||||
--copies 2 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
-o "4 Plates - A_and_B.3mf"
|
||||
```
|
||||
|
||||
指定换盘 G-code 目录和 ZIP 压缩级别:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 5 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
--swap-gcode-dir "D:\SwapMod\swap_gcode" \
|
||||
--zip-level 9 \
|
||||
-o "5 Plates - A.3mf"
|
||||
```
|
||||
|
||||
禁用最后换盘和预览标签重写:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 5 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
--no-swap-after-final \
|
||||
--no-preview-label \
|
||||
-o "5 Plates - A.3mf"
|
||||
```
|
||||
|
||||
禁用换盘后等待时间:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 5 \
|
||||
--swap-gcode "a1_swap.gcode" \
|
||||
--no-eject-wait \
|
||||
-o "5 Plates - A.3mf"
|
||||
```
|
||||
|
||||
CLI 常用选项速查:
|
||||
|
||||
| 选项 | 含义 |
|
||||
|---|---|
|
||||
| `--version` | 打印当前版本 |
|
||||
| `list-swap-gcode` | 列出换盘 G-code 目录中的文件 |
|
||||
| `--item PATH COPIES` | 添加一个带独立份数的输入;可多次使用 |
|
||||
| 位置参数 `inputs` + `--copies N` | 添加多个使用相同份数的输入 |
|
||||
| `--swap-gcode-dir DIR` | 使用自定义换盘 G-code 目录 |
|
||||
| `--no-bed-cooldown` | 不在换盘块前插入 `M190` |
|
||||
| `--no-swap-after-final` | 最后一块盘后不执行换盘块 |
|
||||
| `--no-eject-wait` | 不插入换盘后等待时间(`G4 P...` 行) |
|
||||
| `--line-ending lf\|crlf` | 选择生成 G-code 的换行符;默认 `crlf` |
|
||||
| `--zip-level 1-9` | zlib-ng Deflate 压缩级别;默认 `7` |
|
||||
| `--no-preview-label` | 不重写预览图标签/合成图 |
|
||||
| `--no-gcode-patches` | 不应用 `gcode_patches.ini` 中的规则 |
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
__version__ = "0.4.0"
|
||||
"""A1 Swap Mod Packer 主包入口模块。
|
||||
|
||||
定义应用程序的名称和版本信息,供其他模块引用。
|
||||
"""
|
||||
|
||||
# 应用程序显示名称
|
||||
APP_NAME = "A1 Swap Mod Packer"
|
||||
# 语义化版本号
|
||||
__version__ = "0.6.0"
|
||||
# 窗口标题:应用名称 + 版本号
|
||||
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
|
||||
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
# 匹配 Metadata 目录下 plate_<数字>.gcode 格式的文件名
|
||||
GCODE_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
|
||||
# 匹配对应的 MD5 校验文件 plate_<数字>.gcode.md5
|
||||
MD5_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode\.md5$")
|
||||
|
||||
|
||||
def list_gcode_members(archive: zipfile.ZipFile) -> list[str]:
|
||||
"""列出归档文件中所有 plate G-code 成员,按板号升序排列。
|
||||
|
||||
参数:
|
||||
archive: 已打开的 ZIP 归档文件对象。
|
||||
|
||||
返回:
|
||||
按 plate 编号从小到大排序的 G-code 文件名列表。
|
||||
"""
|
||||
members: list[tuple[int, str]] = []
|
||||
for name in archive.namelist():
|
||||
match = GCODE_MEMBER_RE.match(name)
|
||||
if match:
|
||||
# 提取板号并存储 (板号, 文件名) 元组
|
||||
members.append((int(match.group(1)), name))
|
||||
# 按板号升序排列后只返回文件名
|
||||
return [name for _, name in sorted(members)]
|
||||
|
||||
@@ -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
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
|
||||
from .gcode import (
|
||||
M73OffsetTemplate,
|
||||
apply_line_ending,
|
||||
apply_plate_number_offset,
|
||||
build_swap_block,
|
||||
insert_swap_block,
|
||||
normalize_newlines,
|
||||
prepare_m73_offset_template,
|
||||
read_swap_gcode_file,
|
||||
resolve_swap_gcode_path,
|
||||
)
|
||||
from .metadata import read_filament_metadata, read_slice_plate_metadata, safe_float, update_first_slice_info
|
||||
from .models import BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource
|
||||
from .metadata import (
|
||||
read_filament_metadata,
|
||||
read_model_settings_gcode_members,
|
||||
read_slice_plate_metadata,
|
||||
safe_float,
|
||||
update_first_slice_info,
|
||||
)
|
||||
from .models import DEFAULT_ZIP_COMPRESS_LEVEL, BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource
|
||||
from .patches import apply_gcode_patches, parse_patch_config
|
||||
|
||||
# PIL(Python Imaging Library)为可选依赖,用于生成预览图标签和缩略图拼接
|
||||
# 若不可用则降级跳过图片处理
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except Exception: # pragma: no cover
|
||||
@@ -28,15 +52,122 @@ except Exception: # pragma: no cover
|
||||
ImageDraw = None
|
||||
ImageFont = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模块级常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 匹配 Metadata/plate_N.png 或 Metadata/plate_N_small.png 的正则
|
||||
PREVIEW_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)
|
||||
# 合成预览图中最多展示的源文件数量(防止网格过于密集)
|
||||
MAX_COMPOSITE_PREVIEW_INPUTS = 9
|
||||
# ZIP 压缩等级合法范围
|
||||
MIN_ZIP_COMPRESS_LEVEL = 1
|
||||
MAX_ZIP_COMPRESS_LEVEL = 9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据结构
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PreparedPlateGcode:
|
||||
"""预处理后的板层 G-code 数据(内部使用,不可变)。
|
||||
|
||||
属性:
|
||||
text: 规范化并可选打过 G-code 补丁的 G-code 文本。
|
||||
m73_template: 若启用板号显示,则为 M73 偏移模板,否则为 None。
|
||||
"""
|
||||
text: str
|
||||
m73_template: M73OffsetTemplate | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工具函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def normalized_zip_compress_level(level: int | None) -> int:
|
||||
"""将 ZIP 压缩等级规范化为合法范围内的整数值。
|
||||
|
||||
输入:
|
||||
level: 用户指定的压缩等级(0-9 或 None)。
|
||||
|
||||
输出:
|
||||
夹逼在 [MIN_ZIP_COMPRESS_LEVEL, MAX_ZIP_COMPRESS_LEVEL] 之间的整数。
|
||||
若 level 为 None 或类型错误,则使用默认值 DEFAULT_ZIP_COMPRESS_LEVEL。
|
||||
"""
|
||||
try:
|
||||
value = int(level if level is not None else DEFAULT_ZIP_COMPRESS_LEVEL)
|
||||
except (TypeError, ValueError):
|
||||
value = DEFAULT_ZIP_COMPRESS_LEVEL
|
||||
return min(MAX_ZIP_COMPRESS_LEVEL, max(MIN_ZIP_COMPRESS_LEVEL, value))
|
||||
|
||||
|
||||
def load_zlib_ng_module() -> object:
|
||||
"""加载 zlib-ng 模块以加速 ZIP 压缩。
|
||||
|
||||
输入:
|
||||
无。
|
||||
|
||||
输出:
|
||||
zlib_ng 模块对象。
|
||||
|
||||
异常:
|
||||
RuntimeError: 若 zlib-ng 未安装。
|
||||
"""
|
||||
try:
|
||||
from zlib_ng import zlib_ng
|
||||
except ImportError as exc: # pragma: no cover - exercised when dependency is missing
|
||||
raise RuntimeError("zlib-ng is required for 3MF ZIP compression. Install it with: pip install zlib-ng") from exc
|
||||
return zlib_ng
|
||||
|
||||
|
||||
@contextmanager
|
||||
def use_zlib_ng_for_zipfile() -> object:
|
||||
"""上下文管理器:用 zlib-ng 替换 zipfile 模块内的默认 zlib 实现。
|
||||
|
||||
在 with 块内,所有 zipfile 操作自动使用 zlib-ng 压缩算法,
|
||||
退出时自动恢复原始 zlib。
|
||||
|
||||
输入:
|
||||
无。
|
||||
|
||||
输出:
|
||||
yield 的返回值(通常不使用)。
|
||||
"""
|
||||
original_zlib = zipfile.zlib
|
||||
zipfile.zlib = load_zlib_ng_module()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
zipfile.zlib = original_zlib
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 板源加载与展开
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def load_plate_sources(job: PlateJob) -> list[PlateSource]:
|
||||
"""从单个 PlateJob 的源 3MF 文件中加载所有板层的 G-code 及元数据。
|
||||
|
||||
输入:
|
||||
job: 包含 source_3mf 路径和 copies 数量的 PlateJob 对象。
|
||||
|
||||
输出:
|
||||
该 3MF 中每个 G-code 成员对应的 PlateSource 列表,每个 PlateSource
|
||||
携带规范化的 G-code 文本、预测打印时间、耗材重量/长度等信息。
|
||||
|
||||
异常:
|
||||
ValueError: 若 copies < 1 或未找到任何 G-code 成员。
|
||||
"""
|
||||
if job.copies < 1:
|
||||
raise ValueError(f"Invalid copy count for {job.source_3mf}: {job.copies}")
|
||||
with zipfile.ZipFile(job.source_3mf, "r") as archive:
|
||||
members = list_gcode_members(archive)
|
||||
if not members:
|
||||
raise ValueError(f"No Metadata/plate_N.gcode member was found in {job.source_3mf}")
|
||||
# 读取板层切片元数据(预测时间、重量等)
|
||||
plate_metadata = read_slice_plate_metadata(archive)
|
||||
# 读取耗材用量元数据
|
||||
filament_metadata = read_filament_metadata(archive)
|
||||
sources: list[PlateSource] = []
|
||||
for member in members:
|
||||
@@ -46,6 +177,7 @@ def load_plate_sources(job: PlateJob) -> list[PlateSource]:
|
||||
text = raw.decode("utf-8-sig", errors="replace")
|
||||
metadata = plate_metadata.get(plate_index, {})
|
||||
filament = filament_metadata.get(plate_index, {})
|
||||
# 重量优先取 metadata.weight,其次取 filament.used_g
|
||||
weight = safe_float(metadata.get("weight"))
|
||||
used_g = safe_float(filament.get("used_g"))
|
||||
sources.append(
|
||||
@@ -63,6 +195,20 @@ def load_plate_sources(job: PlateJob) -> list[PlateSource]:
|
||||
|
||||
|
||||
def expand_jobs(jobs: list[PlateJob]) -> list[PlateSource]:
|
||||
"""展开 Job 列表,按 copies 数量复制每个板层为多个 PlateSource。
|
||||
|
||||
例如,若某 PlateJob 有 2 个 copies 且包含 3 个板层,
|
||||
则展开后产生 2 * 3 = 6 个 PlateSource。
|
||||
|
||||
输入:
|
||||
jobs: PlateJob 列表,每个可指定 copies > 0。
|
||||
|
||||
输出:
|
||||
按顺序展开后的 PlateSource 列表。
|
||||
|
||||
异常:
|
||||
ValueError: 若展开后为空(没有任何输入板)。
|
||||
"""
|
||||
expanded: list[PlateSource] = []
|
||||
for job in jobs:
|
||||
sources = load_plate_sources(job)
|
||||
@@ -73,6 +219,10 @@ def expand_jobs(jobs: list[PlateJob]) -> list[PlateSource]:
|
||||
return expanded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# G-code 加工流水线
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def process_plate_gcode(
|
||||
source: PlateSource,
|
||||
plate_number: int,
|
||||
@@ -81,19 +231,145 @@ def process_plate_gcode(
|
||||
swap_gcode_text: str,
|
||||
patch_config: GcodePatchConfig,
|
||||
) -> str:
|
||||
"""完整的 G-code 加工流水线:预处理 + 换板注入。
|
||||
|
||||
输入:
|
||||
source: 板层数据源(含 G-code 文本)。
|
||||
plate_number: 当前板号(从 1 开始)。
|
||||
total_plates: 总板数。
|
||||
options: 构建选项(控制板号显示、换板行为、行尾格式等)。
|
||||
swap_gcode_text: 换板 G-code 模板文本。
|
||||
patch_config: G-code 补丁配置。
|
||||
|
||||
输出:
|
||||
加工完成后的单板 G-code 文本字符串。
|
||||
"""
|
||||
prepared = _prepare_plate_gcode(source, options, patch_config)
|
||||
return _process_prepared_plate_gcode(
|
||||
prepared,
|
||||
plate_number,
|
||||
total_plates,
|
||||
options,
|
||||
swap_gcode_text,
|
||||
patch_config,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_plate_gcode(
|
||||
source: PlateSource,
|
||||
options: BuildOptions,
|
||||
patch_config: GcodePatchConfig,
|
||||
) -> _PreparedPlateGcode:
|
||||
"""G-code 预处理阶段:规范化行尾、应用补丁、提取 M73 模板。
|
||||
|
||||
输入:
|
||||
source: 板层数据源。
|
||||
options: 构建选项。
|
||||
patch_config: G-code 补丁配置。
|
||||
|
||||
输出:
|
||||
预处理后的 _PreparedPlateGcode 对象。
|
||||
"""
|
||||
# 第一步:统一行尾为 \n(后续处理依赖一致的换行符)
|
||||
text = normalize_newlines(source.gcode_text)
|
||||
# 第二步:如启用,应用用户自定义 G-code 补丁规则
|
||||
if options.apply_gcode_patches:
|
||||
text = apply_gcode_patches(text, patch_config)
|
||||
if options.show_plate_number:
|
||||
text = apply_plate_number_offset(text, plate_number)
|
||||
# 第三步:如启用板号显示,提取 M73 进度模板用于后续偏移计算
|
||||
m73_template = prepare_m73_offset_template(text) if options.show_plate_number else None
|
||||
return _PreparedPlateGcode(text, m73_template)
|
||||
|
||||
|
||||
def _process_prepared_plate_gcode(
|
||||
prepared: _PreparedPlateGcode,
|
||||
plate_number: int,
|
||||
total_plates: int,
|
||||
options: BuildOptions,
|
||||
swap_gcode_text: str,
|
||||
patch_config: GcodePatchConfig,
|
||||
) -> str:
|
||||
"""G-code 后处理阶段:应用 M73 板号偏移、插入换板块。
|
||||
|
||||
输入:
|
||||
prepared: 预处理后的板层 G-code。
|
||||
plate_number: 当前板号(从 1 开始)。
|
||||
total_plates: 总板数。
|
||||
options: 构建选项。
|
||||
swap_gcode_text: 换板 G-code 模板。
|
||||
patch_config: G-code 补丁配置(含 insert_before_marker 标记位置)。
|
||||
|
||||
输出:
|
||||
最终的单板 G-code 文本字符串。
|
||||
"""
|
||||
text = prepared.text
|
||||
# 若启用板号显示且成功提取了 M73 模板,则将 M73 进度值偏移到正确范围
|
||||
if options.show_plate_number and prepared.m73_template is not None:
|
||||
text = prepared.m73_template.apply(plate_number)
|
||||
# 判断是否需要在当前板后插入换板逻辑:
|
||||
# - swap_after_final 为 True 时最后一板也换
|
||||
# - 非最后一板始终需要换板
|
||||
should_swap = options.swap_after_final or plate_number < total_plates
|
||||
if should_swap:
|
||||
# 构建换板 G-code 块(降温 + 等待取件)
|
||||
swap_block = build_swap_block(swap_gcode_text, options.cool_bed_temp, options.wait_after_eject_seconds)
|
||||
# 在指定标记位置插入换板块
|
||||
text = insert_swap_block(text, swap_block, patch_config.insert_before_marker)
|
||||
return text.rstrip("\n") + "\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 缓存机制
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _plate_gcode_cache_key(source: PlateSource) -> tuple[Path, str]:
|
||||
"""生成板层 G-code 的缓存键。
|
||||
|
||||
同一个 3MF 文件中同一成员名的板层,其预处理结果(规范化 + 补丁 + M73 模板)
|
||||
是完全相同的,因此可以安全缓存复用,避免重复处理。
|
||||
|
||||
输入:
|
||||
source: 板层数据源。
|
||||
|
||||
输出:
|
||||
由 (源文件绝对路径, 成员名) 组成的元组。
|
||||
"""
|
||||
return (source.source_3mf.resolve(strict=False), source.member_name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 预览图处理
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def save_png_bytes(image: object) -> bytes:
|
||||
"""将 PIL Image 对象编码为 PNG 字节流。
|
||||
|
||||
输入:
|
||||
image: PIL Image 对象。
|
||||
|
||||
输出:
|
||||
PNG 格式的字节数据。
|
||||
"""
|
||||
import io
|
||||
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> bytes:
|
||||
"""在预览图左下角叠加文字标签(如 "5 P" 表示 5 个板)。
|
||||
|
||||
自动适配系统可用字体(Windows Arial Bold / Linux DejaVu Sans Bold / macOS Arial Bold),
|
||||
若全部缺失则回退到 PIL 默认字体。PIL 不可用时原样返回 PNG 字节。
|
||||
|
||||
输入:
|
||||
png_bytes: 原始预览图 PNG 字节数据。
|
||||
label: 要叠加的文字标签。
|
||||
small: 是否为缩略图(True 时使用更小的字体)。
|
||||
|
||||
输出:
|
||||
叠加文字后的 PNG 字节数据。
|
||||
"""
|
||||
if Image is None or ImageDraw is None:
|
||||
return png_bytes
|
||||
import io
|
||||
@@ -103,8 +379,10 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b
|
||||
except Exception:
|
||||
return png_bytes
|
||||
draw = ImageDraw.Draw(image)
|
||||
# 根据图片尺寸和大小标志计算字体大小
|
||||
size = max(16, image.width // (12 if small else 8))
|
||||
font = None
|
||||
# 按平台优先级尝试加载粗体系统字
|
||||
for font_path in (
|
||||
Path(os.environ.get("WINDIR", "")) / "Fonts" / "arialbd.ttf",
|
||||
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
||||
@@ -116,56 +394,429 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b
|
||||
break
|
||||
except Exception:
|
||||
font = None
|
||||
# 所有系统字体都失败时,使用 PIL 内置默认字体
|
||||
if font is None and ImageFont is not None:
|
||||
font = ImageFont.load_default()
|
||||
# 文字位置:左下角,留出边距
|
||||
margin = max(8, image.width // 24)
|
||||
text_bbox = draw.textbbox((0, 0), label, font=font)
|
||||
x = margin
|
||||
y = image.height - margin - (text_bbox[3] - text_bbox[1])
|
||||
# 半透明绿色文字,保证在各种背景上都可见
|
||||
draw.text((x, y), label, font=font, fill=(0, 255, 0, 255))
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
return save_png_bytes(image)
|
||||
|
||||
|
||||
def preview_member_name_for_gcode_member(gcode_member: str, small: bool = False) -> str | None:
|
||||
"""根据 G-code 成员名推导对应的预览图成员名。
|
||||
|
||||
例如 "Metadata/plate_3.gcode" -> "Metadata/plate_3.png" 或 "Metadata/plate_3_small.png"。
|
||||
|
||||
输入:
|
||||
gcode_member: G-code 成员路径,如 "Metadata/plate_1.gcode"。
|
||||
small: 是否请求缩略图版本。
|
||||
|
||||
输出:
|
||||
对应的 PNG 成员路径,若不匹配正则则返回 None。
|
||||
"""
|
||||
match = GCODE_MEMBER_RE.match(gcode_member)
|
||||
if not match:
|
||||
return None
|
||||
suffix = "_small" if small else ""
|
||||
return f"Metadata/plate_{match.group(1)}{suffix}.png"
|
||||
|
||||
|
||||
def preview_member_sort_key(member_name: str) -> tuple[int, int, str]:
|
||||
"""生成预览图成员的排序键:先按是否为缩略图分组,再按板号排序。
|
||||
|
||||
输入:
|
||||
member_name: ZIP 成员名称。
|
||||
|
||||
输出:
|
||||
(is_small, plate_number, member_name.lower()) 排序元组。
|
||||
"""
|
||||
match = PREVIEW_MEMBER_RE.match(member_name)
|
||||
plate_number = int(match.group(1)) if match else 9999
|
||||
is_small = 1 if member_name.lower().endswith("_small.png") else 0
|
||||
return (is_small, plate_number, member_name.lower())
|
||||
|
||||
|
||||
def select_preview_sources(sources: list[PlateSource], max_inputs: int = MAX_COMPOSITE_PREVIEW_INPUTS) -> list[PlateSource]:
|
||||
"""从板源列表中选取用于合成预览图的唯一板源(去重,限数量)。
|
||||
|
||||
同一个 3MF 文件只取一个代表,避免重复展示同一模型的缩略图。
|
||||
|
||||
输入:
|
||||
sources: 全部板源列表(可能含重复)。
|
||||
max_inputs: 最多选取的数量,默认 9。
|
||||
|
||||
输出:
|
||||
去重并按原顺序截取后的 PlateSource 列表。
|
||||
"""
|
||||
selected: list[PlateSource] = []
|
||||
seen_paths: set[Path] = set()
|
||||
for source in sources:
|
||||
key = source.source_3mf.resolve(strict=False)
|
||||
if key in seen_paths:
|
||||
continue
|
||||
seen_paths.add(key)
|
||||
selected.append(source)
|
||||
if len(selected) >= max_inputs:
|
||||
break
|
||||
return selected
|
||||
|
||||
|
||||
def source_preview_member(archive: zipfile.ZipFile, source: PlateSource, small: bool = False) -> str | None:
|
||||
"""在 3MF 归档中查找与给定板源最匹配的预览图成员。
|
||||
|
||||
查找策略(按优先级递减):
|
||||
1. 精确匹配:与 G-code 成员同板号的同名 PNG。
|
||||
2. 备选尺寸:同板号但不同大小的 PNG(如请求小图但只有大图)。
|
||||
3. 同类型回退:任意板号的同大小 PNG。
|
||||
4. 全尺寸回退:任意板号的任意大小 PNG。
|
||||
5. 通配回退:Metadata/ 下任意 PNG。
|
||||
|
||||
输入:
|
||||
archive: 已打开的 3MF ZIP 归档。
|
||||
source: 板层数据源。
|
||||
small: 是否优先缩略图。
|
||||
|
||||
输出:
|
||||
匹配的成员名,若完全找不到则返回 None。
|
||||
"""
|
||||
names = set(archive.namelist())
|
||||
# 解析实际输出的 G-code 成员名(可能与原始成员不同)
|
||||
active_member = resolve_output_gcode_member(archive, source.member_name)
|
||||
# 策略1:精确匹配
|
||||
preferred = preview_member_name_for_gcode_member(active_member, small)
|
||||
if preferred in names:
|
||||
return preferred
|
||||
# 策略2:相同板号但不同尺寸
|
||||
alternate = preview_member_name_for_gcode_member(active_member, not small)
|
||||
if alternate in names:
|
||||
return alternate
|
||||
|
||||
# 策略3:同大小类型的任意 PNG
|
||||
candidates = [
|
||||
name
|
||||
for name in names
|
||||
if PREVIEW_MEMBER_RE.match(name) and name.lower().endswith("_small.png") == small
|
||||
]
|
||||
if not candidates:
|
||||
# 策略4:所有 plate_N.png
|
||||
candidates = [name for name in names if PREVIEW_MEMBER_RE.match(name)]
|
||||
if not candidates:
|
||||
# 策略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:
|
||||
"""将合并后的 G-code 写入新的 3MF 文件。
|
||||
|
||||
处理步骤:
|
||||
1. 计算最终 G-code 的 MD5 校验和。
|
||||
2. 基于源 3MF 创建新的 ZIP 归档(使用 zlib-ng 加速)。
|
||||
3. 复制所有非 G-code 成员到新归档,对特定成员做处理:
|
||||
- slice_info.config: 更新为多板信息。
|
||||
- 预览图 PNG: 如启用标签,合成为多板拼接预览图。
|
||||
4. 写入合并后的 G-code 及对应的 MD5 文件。
|
||||
|
||||
输入:
|
||||
base_3mf: 源 3MF 文件路径。
|
||||
output_3mf: 输出 3MF 文件路径。
|
||||
gcode_bytes: 合并打包后的完整 G-code 字节数据。
|
||||
sources: 所有板源列表。
|
||||
options: 构建选项。
|
||||
|
||||
输出:
|
||||
最终 G-code 的 MD5 十六进制字符串。
|
||||
"""
|
||||
# 计算 G-code 的 MD5 校验和
|
||||
md5 = hashlib.md5(gcode_bytes).hexdigest()
|
||||
with zipfile.ZipFile(base_3mf, "r") as src, zipfile.ZipFile(output_3mf, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=1) as dst:
|
||||
compress_level = normalized_zip_compress_level(options.zip_compress_level)
|
||||
# 使用 zlib-ng 替换 zipfile 的默认压缩器以加速写入
|
||||
with use_zlib_ng_for_zipfile():
|
||||
with zipfile.ZipFile(base_3mf, "r") as src, zipfile.ZipFile(
|
||||
output_3mf,
|
||||
"w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=compress_level,
|
||||
) as dst:
|
||||
# 解析输出 G-code 成员名(可能与源文件不同)
|
||||
gcode_member = resolve_output_gcode_member(src, sources[0].member_name)
|
||||
# 找出对应的预览图成员,以便后续做标签叠加处理
|
||||
preview_members = preview_members_for_gcode_member(gcode_member)
|
||||
# 逐项复制源归档中的成员
|
||||
for item in src.infolist():
|
||||
name = item.filename
|
||||
# 跳过旧的 G-code 和 MD5 成员(将被新的替换)
|
||||
if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name):
|
||||
continue
|
||||
data = src.read(name)
|
||||
# 更新切片信息配置,写入多板汇总数据
|
||||
if name == "Metadata/slice_info.config":
|
||||
data = update_first_slice_info(data, sources, options)
|
||||
elif options.add_preview_label and name in {"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("Metadata/plate_1.gcode", gcode_bytes)
|
||||
dst.writestr("Metadata/plate_1.gcode.md5", md5)
|
||||
# 写入合并后的 G-code 及其 MD5 校验文件
|
||||
dst.writestr(gcode_member, gcode_bytes)
|
||||
dst.writestr(f"{gcode_member}.md5", md5)
|
||||
return md5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 顶层构建入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_packed_3mf(jobs: list[PlateJob], options: BuildOptions) -> BuildResult:
|
||||
"""顶层入口:执行完整的换板 3MF 打包流程。
|
||||
|
||||
整体流程:
|
||||
1. 展开 Job 列表为板源列表(处理 copies)。
|
||||
2. 加载换板 G-code 模板。
|
||||
3. 解析 G-code 补丁配置。
|
||||
4. 对每个板源依次预处理 G-code(含缓存复用)并注入换板块。
|
||||
5. 拼接所有板层 G-code。
|
||||
6. 写入最终 3MF 文件(含预览图合成、元数据更新)。
|
||||
|
||||
输入:
|
||||
jobs: PlateJob 列表,每个指定源 3MF 及 copies 数量。
|
||||
options: 构建选项,控制所有行为开关。
|
||||
|
||||
输出:
|
||||
BuildResult 对象,包含输出文件路径、总板数、预测时间和重量等汇总信息。
|
||||
"""
|
||||
# 第一步:展开并加载所有板源
|
||||
sources = expand_jobs(jobs)
|
||||
# 第二步:加载换板 G-code 模板
|
||||
swap_gcode_path = resolve_swap_gcode_path(options.swap_gcode, options.swap_gcode_dir)
|
||||
swap_gcode_text = read_swap_gcode_file(swap_gcode_path)
|
||||
# 第三步:解析 G-code 补丁配置
|
||||
patch_config = parse_patch_config() if options.apply_gcode_patches else GcodePatchConfig(rules=())
|
||||
processed: list[str] = []
|
||||
# 第四步:G-code 加工流水线(含预处理缓存)
|
||||
# 同一源文件 + 同一成员名的板层只需预处理一次,后续复用缓存结果
|
||||
prepared_gcode_cache: dict[tuple[Path, str], _PreparedPlateGcode] = {}
|
||||
for plate_number, source in enumerate(sources, start=1):
|
||||
processed.append(process_plate_gcode(source, plate_number, len(sources), options, swap_gcode_text, patch_config))
|
||||
cache_key = _plate_gcode_cache_key(source)
|
||||
prepared_text = prepared_gcode_cache.get(cache_key)
|
||||
if prepared_text is None:
|
||||
# 缓存未命中:执行预处理(行尾规范化 + 补丁应用 + M73 模板提取)
|
||||
prepared_text = _prepare_plate_gcode(source, options, patch_config)
|
||||
prepared_gcode_cache[cache_key] = prepared_text
|
||||
# 后处理:M73 板号偏移 + 换板块注入
|
||||
processed.append(
|
||||
_process_prepared_plate_gcode(
|
||||
prepared_text,
|
||||
plate_number,
|
||||
len(sources),
|
||||
options,
|
||||
swap_gcode_text,
|
||||
patch_config,
|
||||
)
|
||||
)
|
||||
# 第五步:拼接所有板层 G-code
|
||||
final_gcode = "\n".join(item.rstrip("\n") for item in processed) + "\n"
|
||||
gcode_bytes = apply_line_ending(final_gcode, options.line_ending)
|
||||
# 第六步:写入输出 3MF 文件(通过临时文件保证原子性)
|
||||
output_3mf = options.output_3mf
|
||||
output_3mf.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf", dir=str(output_3mf.parent)) as temp_file:
|
||||
temp_path = Path(temp_file.name)
|
||||
try:
|
||||
md5 = write_output_3mf(sources[0].source_3mf, temp_path, gcode_bytes, sources, options)
|
||||
# 原子性替换:先在临时文件完成写入,再移动到目标路径
|
||||
shutil.move(str(temp_path), str(output_3mf))
|
||||
finally:
|
||||
# 确保清理临时文件
|
||||
if temp_path.exists():
|
||||
temp_path.unlink(missing_ok=True)
|
||||
# 汇总统计信息
|
||||
total_prediction = sum(value for value in (source.prediction_seconds for source in sources) if value is not None)
|
||||
total_weight = sum(value for value in (source.weight_grams for source in sources) if value is not None)
|
||||
return BuildResult(
|
||||
|
||||
+111
-31
@@ -1,12 +1,19 @@
|
||||
"""命令行接口模块:定义参数解析器和命令处理函数。
|
||||
|
||||
提供 `build`(构建打包 3MF)和 `list-swap-gcode`(列出换盘 G-code 文件)
|
||||
两个子命令,以及 `main` 入口函数。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import APP_NAME
|
||||
from . import APP_NAME, APP_TITLE, __version__
|
||||
from .core import (
|
||||
BuildOptions,
|
||||
DEFAULT_ZIP_COMPRESS_LEVEL,
|
||||
PlateJob,
|
||||
build_packed_3mf,
|
||||
list_swap_gcode_files,
|
||||
@@ -15,30 +22,55 @@ from .paths import default_patch_config_path, default_swap_gcode_dir
|
||||
|
||||
|
||||
def parse_item(values: list[str]) -> PlateJob:
|
||||
"""解析 --item 参数的键值对,生成一个 PlateJob 实例。
|
||||
|
||||
Args:
|
||||
values: 包含两个字符串的列表 [路径, 份数]
|
||||
|
||||
Returns:
|
||||
PlateJob: 对应的板作业对象
|
||||
|
||||
Raises:
|
||||
argparse.ArgumentTypeError: 参数格式或类型不合法时抛出
|
||||
"""
|
||||
# --item 必须提供恰好两个值:路径和份数
|
||||
if len(values) != 2:
|
||||
raise argparse.ArgumentTypeError("Each --item needs a path and a copy count.")
|
||||
raise argparse.ArgumentTypeError("每个 --item 需要一个路径和一个份数。")
|
||||
path = Path(values[0])
|
||||
# 份数必须可以解析为整数
|
||||
try:
|
||||
copies = int(values[1])
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(f"Invalid copy count: {values[1]}") from exc
|
||||
raise argparse.ArgumentTypeError(f"无效的份数:{values[1]}") from exc
|
||||
return PlateJob(path, copies)
|
||||
|
||||
|
||||
def build_command(args: argparse.Namespace) -> int:
|
||||
"""执行 'build' 子命令:收集输入、组装选项、调用构建。
|
||||
|
||||
Args:
|
||||
args: 解析后的命令行命名空间
|
||||
|
||||
Returns:
|
||||
int: 退出码,成功为 0
|
||||
"""
|
||||
jobs: list[PlateJob] = []
|
||||
# 处理 --item 参数(每个可独立指定份数)
|
||||
for item in args.item or []:
|
||||
jobs.append(parse_item(item))
|
||||
# 处理位置参数输入(共用 --copies 份数)
|
||||
for input_path in args.inputs or []:
|
||||
jobs.append(PlateJob(Path(input_path), args.copies))
|
||||
# 没有输入文件时直接报错退出
|
||||
if not jobs:
|
||||
raise SystemExit("No input 3MF file was provided.")
|
||||
raise SystemExit("未提供输入 3MF 文件。")
|
||||
# 根据 --no-bed-cooldown 决定是否写入床温指令
|
||||
cool_bed_temp = None if args.no_bed_cooldown else args.cool_bed
|
||||
options = BuildOptions(
|
||||
swap_gcode=args.swap_gcode,
|
||||
output_3mf=Path(args.output),
|
||||
cool_bed_temp=cool_bed_temp,
|
||||
wait_after_eject_seconds=args.wait,
|
||||
wait_after_eject_seconds=0 if args.no_eject_wait else args.wait,
|
||||
show_plate_number=args.show_plate_number,
|
||||
swap_after_final=not args.no_swap_after_final,
|
||||
metadata_mode=args.metadata_mode,
|
||||
@@ -46,23 +78,36 @@ def build_command(args: argparse.Namespace) -> int:
|
||||
add_preview_label=not args.no_preview_label,
|
||||
apply_gcode_patches=not args.no_gcode_patches,
|
||||
swap_gcode_dir=Path(args.swap_gcode_dir) if args.swap_gcode_dir else None,
|
||||
zip_compress_level=args.zip_level,
|
||||
)
|
||||
# 调用核心构建函数
|
||||
result = build_packed_3mf(jobs, options)
|
||||
print(f"Output: {result.output_3mf}")
|
||||
print(f"Plates: {result.plate_count}")
|
||||
print(f"G-code MD5: {result.gcode_md5}")
|
||||
# 输出构建结果摘要
|
||||
print(f"输出:{result.output_3mf}")
|
||||
print(f"板数:{result.plate_count}")
|
||||
print(f"G-code MD5:{result.gcode_md5}")
|
||||
if result.total_prediction_seconds is not None:
|
||||
print(f"Source print time: {int(result.total_prediction_seconds)} seconds")
|
||||
print(f"源文件打印时间:{int(result.total_prediction_seconds)} 秒")
|
||||
if result.total_weight_grams is not None:
|
||||
print(f"Source filament weight: {result.total_weight_grams:.2f} g")
|
||||
print(f"源文件耗材重量:{result.total_weight_grams:.2f} g")
|
||||
return 0
|
||||
|
||||
|
||||
def list_swap_gcode_command(args: argparse.Namespace) -> int:
|
||||
"""执行 'list-swap-gcode' 子命令:列出可用的换盘 G-code 文件。
|
||||
|
||||
Args:
|
||||
args: 解析后的命令行命名空间
|
||||
|
||||
Returns:
|
||||
int: 退出码,成功为 0
|
||||
"""
|
||||
# 解析目录:优先使用命令行参数,否则使用默认目录
|
||||
directory = Path(args.swap_gcode_dir) if args.swap_gcode_dir else default_swap_gcode_dir()
|
||||
files = list_swap_gcode_files(directory)
|
||||
# 目录为空时给出提示
|
||||
if not files:
|
||||
print(f"No swap G-code files found in: {directory}")
|
||||
print(f"在 {directory} 中未找到换盘 G-code 文件")
|
||||
return 0
|
||||
for path in files:
|
||||
print(path.name)
|
||||
@@ -70,40 +115,75 @@ def list_swap_gcode_command(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def create_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="a1-swap-mod-packer", description="Pack repeated Bambu A1 SwapMod plates into one 3MF job.")
|
||||
"""创建并配置命令行参数解析器。
|
||||
|
||||
包含两个子命令:
|
||||
- build: 构建打包的 3MF 文件
|
||||
- list-swap-gcode: 列出换盘 G-code 文件
|
||||
|
||||
Returns:
|
||||
argparse.ArgumentParser: 配置完成的参数解析器
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="a1-swap-mod-packer",
|
||||
description=f"{APP_TITLE} - 将重复的 Bambu A1 SwapMod 面板打包为一个 3MF 作业。",
|
||||
)
|
||||
parser.add_argument("--version", action="version", version=f"{APP_NAME} {__version__}")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
build = 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 子命令 ----
|
||||
build = subparsers.add_parser("build", help="构建一个打包的 3MF 文件。")
|
||||
# 位置参数:输入文件路径(共用统一的份数)
|
||||
build.add_argument("inputs", nargs="*", help="输入 3MF 文件。使用 --copies 为所有文件设置相同的份数。")
|
||||
# --item 参数:每个文件可独立指定份数
|
||||
build.add_argument("--item", nargs=2, action="append", metavar=("PATH", "COPIES"), help="添加一个带有独立份数的输入 3MF 文件。可重复使用。")
|
||||
build.add_argument("-o", "--output", required=True, help="输出 3MF 路径。")
|
||||
# 换盘 G-code 相关
|
||||
build.add_argument("--swap-gcode", required=True, help="换盘 G-code 文件名,位于 swap_gcode 中,或显式文件路径。")
|
||||
build.add_argument("--swap-gcode-dir", default=None, help=f"模板目录。默认值:{default_swap_gcode_dir()}")
|
||||
# 份数与温度
|
||||
build.add_argument("--copies", type=int, default=1, help="位置参数输入文件的复制份数。")
|
||||
build.add_argument("--cool-bed", type=int, default=45, help="执行换盘 G-code 前等待的床温。")
|
||||
build.add_argument("--no-bed-cooldown", action="store_true", help="不在换料代码前插入 M190。")
|
||||
# 时间与编号
|
||||
build.add_argument("--wait", type=int, default=45, help="板换盘后等待的秒数。")
|
||||
build.add_argument("--no-eject-wait", action="store_true", help="不插入换盘后等待指令 G4。")
|
||||
build.add_argument("--show-plate-number", action="store_true", help="每板号向 M73 R 值增加 100 小时。")
|
||||
# 行为开关
|
||||
build.add_argument("--no-swap-after-final", action="store_true", help="最后一块盘后不执行换盘 G-code。")
|
||||
build.add_argument("--metadata-mode", choices=("source", "sum"), default="sum", help="如何写入 slice_info 的 prediction 和 weight。")
|
||||
build.add_argument("--line-ending", choices=("lf", "crlf"), default="crlf", help="生成 G-code 的换行符。")
|
||||
build.add_argument("--zip-level", type=int, choices=range(1, 10), default=DEFAULT_ZIP_COMPRESS_LEVEL, metavar="1-9", help="输出 3MF 的 zlib-ng Deflate 压缩级别。默认值:7。")
|
||||
build.add_argument("--no-preview-label", action="store_true", help="不重写预览图标签/合成图。")
|
||||
build.add_argument("--no-gcode-patches", action="store_true", help=f"不应用来自 {default_patch_config_path()} 的可编辑补丁。")
|
||||
# 绑定命令处理函数
|
||||
build.set_defaults(func=build_command)
|
||||
|
||||
list_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-swap-gcode 子命令 ----
|
||||
list_cmd = subparsers.add_parser("list-swap-gcode", help="列出 swap_gcode 目录中的文件。")
|
||||
list_cmd.add_argument("--swap-gcode-dir", default=None, help=f"模板目录。默认值:{default_swap_gcode_dir()}")
|
||||
list_cmd.set_defaults(func=list_swap_gcode_command)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""命令行入口函数:解析参数并分发到对应的子命令处理函数。
|
||||
|
||||
Args:
|
||||
argv: 命令行参数列表,为 None 时使用 sys.argv
|
||||
|
||||
Returns:
|
||||
int: 程序退出码,0 表示成功,1 表示失败
|
||||
"""
|
||||
# 构建解析器并解析参数
|
||||
parser = create_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
# 根据子命令调用绑定的处理函数
|
||||
return int(args.func(args))
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
# 异常信息输出到 stderr,返回非零退出码
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""核心公开 API 模块。
|
||||
|
||||
作为 a1_swap_mod_packer 包的统一入口,通过 __all__ 列表集中导出
|
||||
所有对外公开的符号(类、函数、常量),方便外部调用者从一个模块导入所有所需接口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
|
||||
@@ -33,6 +39,7 @@ from .metadata import (
|
||||
)
|
||||
from .models import (
|
||||
DEFAULT_INSERT_BEFORE_MARKER,
|
||||
DEFAULT_ZIP_COMPRESS_LEVEL,
|
||||
BuildOptions,
|
||||
BuildResult,
|
||||
BuildSummary,
|
||||
@@ -56,9 +63,11 @@ from .planning import (
|
||||
three_mf_summary_from_mapping,
|
||||
)
|
||||
|
||||
# 公开 API:集中管理所有对外的符号导出
|
||||
__all__ = [
|
||||
"DEFAULT_INSERT_BEFORE_MARKER",
|
||||
"DEFAULT_OUTPUT_PATTERN",
|
||||
"DEFAULT_ZIP_COMPRESS_LEVEL",
|
||||
"GCODE_MEMBER_RE",
|
||||
"M73_RE",
|
||||
"MD5_MEMBER_RE",
|
||||
|
||||
+176
-6
@@ -1,19 +1,45 @@
|
||||
"""G-code 文件处理工具集。
|
||||
|
||||
本模块提供了 G-code 文本的读取、换行符标准化、时间/耗材格式化、
|
||||
M73 剩余时间偏移计算、换板代码块构建与插入等核心功能。
|
||||
所有对换板 G-code 的文本操作均通过本模块完成。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .models import DEFAULT_INSERT_BEFORE_MARKER, LineEnding
|
||||
from .paths import default_swap_gcode_dir
|
||||
|
||||
# 匹配 M73 剩余时间指令,捕获 P/R 参数中的分钟数值
|
||||
# 示例: M73 P10 R120 ; 表示当前进度 10%,剩余 120 分钟
|
||||
M73_RE = re.compile(r"^(M73\s+P\d+\s+R)(\d+)(.*)$", re.MULTILINE)
|
||||
|
||||
|
||||
def normalize_newlines(text: str) -> str:
|
||||
"""将文本中的 CRLF 和 CR 统一转换为 LF 换行符。
|
||||
|
||||
参数:
|
||||
text: 待处理的原始文本。
|
||||
|
||||
返回:
|
||||
使用 LF 换行的规范化文本。
|
||||
"""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
|
||||
def apply_line_ending(text: str, line_ending: LineEnding) -> bytes:
|
||||
"""将文本按指定的换行符风格编码为字节串。
|
||||
|
||||
参数:
|
||||
text: 待编码的文本。
|
||||
line_ending: 目标换行风格,"lf" 或 "crlf"。
|
||||
|
||||
返回:
|
||||
使用指定换行符编码的 UTF-8 字节串。
|
||||
"""
|
||||
normalized = normalize_newlines(text)
|
||||
if line_ending == "crlf":
|
||||
normalized = normalized.replace("\n", "\r\n")
|
||||
@@ -21,10 +47,35 @@ def apply_line_ending(text: str, line_ending: LineEnding) -> bytes:
|
||||
|
||||
|
||||
def read_swap_gcode_file(path: Path) -> str:
|
||||
"""读取换板 G-code 文件内容,自动处理 BOM 头。
|
||||
|
||||
参数:
|
||||
path: G-code 文件路径。
|
||||
|
||||
返回:
|
||||
文件的文本内容。
|
||||
"""
|
||||
return path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
|
||||
|
||||
def resolve_swap_gcode_path(value: Path | str, swap_gcode_dir: Path | None = None) -> Path:
|
||||
"""将用户输入的文件名或路径解析为实际存在的换板 G-code 文件路径。
|
||||
|
||||
解析策略(按优先级):
|
||||
1. 直接作为路径查找。
|
||||
2. 在 swap_gcode_dir 目录下查找。
|
||||
3. 无后缀名时依次尝试 .gcode, .nc, .ngc, .txt 后缀。
|
||||
|
||||
参数:
|
||||
value: 文件名字符串或路径。
|
||||
swap_gcode_dir: 默认搜索目录,为 None 时使用内置默认目录。
|
||||
|
||||
返回:
|
||||
解析后的文件路径。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 无法在任何位置找到该文件。
|
||||
"""
|
||||
raw = Path(value)
|
||||
if raw.exists():
|
||||
return raw
|
||||
@@ -32,6 +83,7 @@ def resolve_swap_gcode_path(value: Path | str, swap_gcode_dir: Path | None = Non
|
||||
candidate = search_dir / str(value)
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
# 无后缀名时自动尝试常见 G-code 后缀
|
||||
if raw.suffix:
|
||||
raise FileNotFoundError(f"Swap G-code file not found: {value}")
|
||||
for suffix in (".gcode", ".nc", ".ngc", ".txt"):
|
||||
@@ -42,16 +94,33 @@ def resolve_swap_gcode_path(value: Path | str, swap_gcode_dir: Path | None = Non
|
||||
|
||||
|
||||
def list_swap_gcode_files(directory: Path | None = None) -> list[Path]:
|
||||
"""列出指定目录下所有可用的换板 G-code 文件。
|
||||
|
||||
参数:
|
||||
directory: 要扫描的目录,为 None 时使用内置默认目录。
|
||||
|
||||
返回:
|
||||
按名称排序的 G-code 文件路径列表。
|
||||
"""
|
||||
root = directory or default_swap_gcode_dir()
|
||||
if not root.exists():
|
||||
return []
|
||||
allowed = {".gcode", ".nc", ".ngc", ".txt"}
|
||||
# 过滤掉隐藏文件(以 . 开头),只保留允许的后缀名
|
||||
return sorted(path for path in root.iterdir() if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in allowed)
|
||||
|
||||
|
||||
def format_duration(seconds: float | None) -> str:
|
||||
"""将秒数格式化为易读的时间字符串。
|
||||
|
||||
参数:
|
||||
seconds: 秒数,可为 None。
|
||||
|
||||
返回:
|
||||
格式化后的时间字符串,如 "2h 30m"、"5m 20s"、"45s",未知则返回 "未知"。
|
||||
"""
|
||||
if seconds is None:
|
||||
return "Unknown"
|
||||
return "0s"
|
||||
total = max(0, int(round(seconds)))
|
||||
hours, rem = divmod(total, 3600)
|
||||
minutes, secs = divmod(rem, 60)
|
||||
@@ -63,8 +132,17 @@ def format_duration(seconds: float | None) -> str:
|
||||
|
||||
|
||||
def format_filament(weight_grams: float | None, used_m: float | None = None) -> str:
|
||||
"""将耗材重量和长度格式化为易读字符串。
|
||||
|
||||
参数:
|
||||
weight_grams: 耗材重量(克),可为 None。
|
||||
used_m: 耗材使用长度(米),可为 None。
|
||||
|
||||
返回:
|
||||
格式化后的耗材信息字符串,未知则返回 "未知"。
|
||||
"""
|
||||
if weight_grams is None and used_m is None:
|
||||
return "Unknown"
|
||||
return "0.00 g"
|
||||
parts: list[str] = []
|
||||
if weight_grams is not None:
|
||||
parts.append(f"{weight_grams:.2f} g")
|
||||
@@ -73,34 +151,126 @@ def format_filament(weight_grams: float | None, used_m: float | None = None) ->
|
||||
return " / ".join(parts)
|
||||
|
||||
|
||||
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
|
||||
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:
|
||||
"""构建换板代码块。
|
||||
|
||||
由可选的热床降温 M190 指令、换板 G-code 主体和可选的暂停等待 G4 指令组成。
|
||||
|
||||
参数:
|
||||
swap_gcode: 换板 G-code 主体内容。
|
||||
cool_bed_temp: 目标热床温度(°C),为 None 时不插入降温指令。
|
||||
wait_seconds: 换板后等待秒数,0 时不插入等待指令。
|
||||
|
||||
返回:
|
||||
完整的换板代码块字符串。
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if cool_bed_temp is not None:
|
||||
parts.append(f"M190 S{int(cool_bed_temp)}")
|
||||
parts.append(normalize_newlines(swap_gcode).rstrip("\n"))
|
||||
if wait_seconds > 0:
|
||||
parts.append(f"G4 P{int(wait_seconds) * 1000}")
|
||||
# 过滤空字符串后拼接,末尾加换行
|
||||
return "\n".join(part for part in parts if part) + "\n"
|
||||
|
||||
|
||||
def insert_swap_block(gcode: str, swap_block: str, insert_before_marker: str = DEFAULT_INSERT_BEFORE_MARKER) -> str:
|
||||
"""在原始 G-code 中的指定标记之前插入换板代码块。
|
||||
|
||||
如果找不到标记,则将换板代码块追加到 G-code 末尾。
|
||||
|
||||
参数:
|
||||
gcode: 原始 G-code 文本。
|
||||
swap_block: 要插入的换板代码块。
|
||||
insert_before_marker: 插入位置标记,在该标记所在行之前插入。
|
||||
|
||||
返回:
|
||||
插入换板代码块后的 G-code 文本。
|
||||
"""
|
||||
text = normalize_newlines(gcode)
|
||||
marker = insert_before_marker.strip()
|
||||
if marker:
|
||||
# 查找标记行,支持换行后的标记或文件开头的标记
|
||||
marker_index = text.find("\n" + marker)
|
||||
if marker_index == -1 and text.startswith(marker):
|
||||
marker_index = 0
|
||||
if marker_index != -1:
|
||||
# 在标记行之前插入换板代码块
|
||||
prefix = text[: marker_index + (1 if marker_index > 0 else 0)]
|
||||
suffix = text[marker_index + (1 if marker_index > 0 else 0) :]
|
||||
return prefix + swap_block + suffix
|
||||
# 未找到标记时追加到末尾
|
||||
return text.rstrip("\n") + "\n" + swap_block
|
||||
|
||||
+820
-196
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,9 @@
|
||||
"""3MF 文件元数据读取与处理模块。
|
||||
|
||||
提供从 3MF 归档文件中提取切片信息、耗材数据、模型设置等元数据的功能,
|
||||
以及基于元数据生成构建摘要(时间预估、耗材用量等)的辅助函数。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
@@ -10,6 +16,18 @@ from .models import BuildOptions, BuildSummary, PlateJob, PlateSource, ThreeMfSu
|
||||
|
||||
|
||||
def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]]:
|
||||
"""读取 3MF 归档中各打印板的切片元数据。
|
||||
|
||||
从 Metadata/slice_info.config 中解析每个 <plate> 元素内的 <metadata> 键值对,
|
||||
按盘索引号 index 组织为字典。
|
||||
|
||||
参数:
|
||||
archive: 已打开的 3MF 压缩文件对象。
|
||||
|
||||
返回:
|
||||
dict[int, dict[str, str]]: plate_index -> {metadata_key: metadata_value},
|
||||
若配置文件不存在则返回空字典。
|
||||
"""
|
||||
try:
|
||||
data = archive.read("Metadata/slice_info.config")
|
||||
except KeyError:
|
||||
@@ -23,6 +41,7 @@ def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, s
|
||||
value = metadata.attrib.get("value")
|
||||
if key is not None and value is not None:
|
||||
plate_data[key] = value
|
||||
# 使用 index 字段确定盘编号,缺失或无效时自动递增
|
||||
index_text = plate_data.get("index")
|
||||
try:
|
||||
index = int(index_text) if index_text else len(result) + 1
|
||||
@@ -33,6 +52,18 @@ def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, s
|
||||
|
||||
|
||||
def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]]:
|
||||
"""读取 3MF 归档中各打印板的耗材(filament)元数据。
|
||||
|
||||
从 Metadata/slice_info.config 中提取每个 <plate> 下 <filament> 元素的属性,
|
||||
按盘索引号组织。
|
||||
|
||||
参数:
|
||||
archive: 已打开的 3MF 压缩文件对象。
|
||||
|
||||
返回:
|
||||
dict[int, dict[str, str]]: plate_index -> {filament_attr: value},
|
||||
若配置文件不存在则返回空字典。
|
||||
"""
|
||||
try:
|
||||
data = archive.read("Metadata/slice_info.config")
|
||||
except KeyError:
|
||||
@@ -41,6 +72,7 @@ def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]
|
||||
result: dict[int, dict[str, str]] = {}
|
||||
for plate in root.findall("plate"):
|
||||
plate_index = 1
|
||||
# 先获取当前盘的 index 编号
|
||||
for metadata in plate.findall("metadata"):
|
||||
if metadata.attrib.get("key") == "index":
|
||||
try:
|
||||
@@ -54,7 +86,48 @@ def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]
|
||||
return result
|
||||
|
||||
|
||||
def read_model_settings_gcode_members(archive: zipfile.ZipFile) -> list[str]:
|
||||
"""读取模型设置中配置的 G-code 文件成员名称列表。
|
||||
|
||||
从 Metadata/model_settings.config 中提取所有 plate 的 gcode_file 元数据值,
|
||||
用于确定需要处理的 G-code 归档成员。
|
||||
|
||||
参数:
|
||||
archive: 已打开的 3MF 压缩文件对象。
|
||||
|
||||
返回:
|
||||
list[str]: 符合 G-code 命名规范的成员路径列表,若配置不存在则返回空列表。
|
||||
"""
|
||||
try:
|
||||
data = archive.read("Metadata/model_settings.config")
|
||||
except KeyError:
|
||||
return []
|
||||
try:
|
||||
root = ET.fromstring(data.decode("utf-8-sig", errors="replace"))
|
||||
except Exception:
|
||||
return []
|
||||
members: list[str] = []
|
||||
for plate in root.findall("plate"):
|
||||
for metadata in plate.findall("metadata"):
|
||||
if metadata.attrib.get("key") != "gcode_file":
|
||||
continue
|
||||
# 规范化路径分隔符并去掉前导斜杠
|
||||
value = metadata.attrib.get("value", "").strip().replace("\\", "/").lstrip("/")
|
||||
if value and GCODE_MEMBER_RE.match(value):
|
||||
members.append(value)
|
||||
break
|
||||
return members
|
||||
|
||||
|
||||
def safe_float(value: str | None) -> float | None:
|
||||
"""安全地将字符串转换为浮点数,失败时返回 None。
|
||||
|
||||
参数:
|
||||
value: 待转换的字符串或 None。
|
||||
|
||||
返回:
|
||||
float | None: 转换成功返回浮点数,否则返回 None。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
@@ -64,6 +137,14 @@ def safe_float(value: str | None) -> float | None:
|
||||
|
||||
|
||||
def _sum_optional(values: Iterable[float | None]) -> float | None:
|
||||
"""对一组可选浮点值求和,全为 None 时返回 None。
|
||||
|
||||
参数:
|
||||
values: 包含 float 或 None 的可迭代对象。
|
||||
|
||||
返回:
|
||||
float | None: 有效值的总和,若无任何有效值则返回 None。
|
||||
"""
|
||||
total = 0.0
|
||||
found = False
|
||||
for value in values:
|
||||
@@ -74,6 +155,19 @@ def _sum_optional(values: Iterable[float | None]) -> float | None:
|
||||
|
||||
|
||||
def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary:
|
||||
"""读取单个 3MF 文件的完整摘要信息。
|
||||
|
||||
汇总文件内所有打印板的预估时间、重量、耗材用量等数据。
|
||||
|
||||
参数:
|
||||
source_3mf: 3MF 源文件路径。
|
||||
|
||||
返回:
|
||||
ThreeMfSummary: 包含盘数量、预估秒数、重量克数、耗材米数和克数的摘要对象。
|
||||
|
||||
抛出:
|
||||
ValueError: 若文件中未找到任何 G-code 成员。
|
||||
"""
|
||||
with zipfile.ZipFile(source_3mf, "r") as archive:
|
||||
members = list_gcode_members(archive)
|
||||
if not members:
|
||||
@@ -90,13 +184,16 @@ def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary:
|
||||
plate_index = int(match.group(1)) if match else 1
|
||||
metadata = plate_metadata.get(plate_index, {})
|
||||
filament = filament_metadata.get(plate_index, {})
|
||||
# 提取各项元数据值
|
||||
prediction = safe_float(metadata.get("prediction"))
|
||||
weight = safe_float(metadata.get("weight"))
|
||||
used_m = safe_float(filament.get("used_m"))
|
||||
used_g = safe_float(filament.get("used_g"))
|
||||
predictions.append(prediction)
|
||||
# 重量优先取 weight,若无则回退到 used_g
|
||||
weights.append(weight if weight is not None else used_g)
|
||||
used_m_values.append(used_m)
|
||||
# 耗材克数优先取 used_g,若无则回退到 weight
|
||||
used_g_values.append(used_g if used_g is not None else weight)
|
||||
|
||||
return ThreeMfSummary(
|
||||
@@ -110,6 +207,17 @@ def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary:
|
||||
|
||||
|
||||
def multiply_summary(summary: ThreeMfSummary, copies: int) -> BuildSummary:
|
||||
"""将单个 3MF 摘要按指定份数倍增,生成构建摘要。
|
||||
|
||||
用于计算同一文件多次打印时的累计耗时和耗材用量。
|
||||
|
||||
参数:
|
||||
summary: 单个 3MF 文件的摘要对象。
|
||||
copies: 打印份数(至少为 1)。
|
||||
|
||||
返回:
|
||||
BuildSummary: 倍增后的构建摘要。
|
||||
"""
|
||||
multiplier = max(1, int(copies))
|
||||
return BuildSummary(
|
||||
plate_count=summary.plate_count * multiplier,
|
||||
@@ -121,6 +229,16 @@ def multiply_summary(summary: ThreeMfSummary, copies: int) -> BuildSummary:
|
||||
|
||||
|
||||
def summarize_jobs(jobs: Iterable[PlateJob]) -> BuildSummary:
|
||||
"""汇总所有换板作业的构建摘要。
|
||||
|
||||
遍历所有 PlateJob,依次读取各自的 3MF 摘要并按份数倍增后累加。
|
||||
|
||||
参数:
|
||||
jobs: PlateJob 对象的可迭代集合。
|
||||
|
||||
返回:
|
||||
BuildSummary: 所有作业合并后的构建摘要。
|
||||
"""
|
||||
plate_count = 0
|
||||
predictions: list[float | None] = []
|
||||
weights: list[float | None] = []
|
||||
@@ -143,12 +261,26 @@ def summarize_jobs(jobs: Iterable[PlateJob]) -> BuildSummary:
|
||||
|
||||
|
||||
def update_first_slice_info(base_data: bytes, sources: list[PlateSource], options: BuildOptions) -> bytes:
|
||||
"""更新打包后 3MF 中首个 slice_info.config 的元数据。
|
||||
|
||||
根据 metadata_mode 选项决定是保留源数据还是累加所有来源的预估时间和耗材用量。
|
||||
|
||||
参数:
|
||||
base_data: 原始 slice_info.config 的字节数据。
|
||||
sources: 所有 PlateSource 的列表。
|
||||
options: 构建选项(含 metadata_mode 字段)。
|
||||
|
||||
返回:
|
||||
bytes: 更新后的 slice_info.config 字节数据。
|
||||
"""
|
||||
if options.metadata_mode == "source":
|
||||
# 源模式:保持原始数据不变
|
||||
return base_data
|
||||
try:
|
||||
root = ET.fromstring(base_data.decode("utf-8-sig", errors="replace"))
|
||||
except Exception:
|
||||
return base_data
|
||||
# 累加所有来源的预估时间和耗材用量
|
||||
total_prediction = sum(value for value in (source.prediction_seconds for source in sources) if value is not None)
|
||||
total_weight = sum(value for value in (source.weight_grams for source in sources) if value is not None)
|
||||
total_used_m = sum(value for value in (source.filament_used_m for source in sources) if value is not None)
|
||||
@@ -156,6 +288,7 @@ def update_first_slice_info(base_data: bytes, sources: list[PlateSource], option
|
||||
first_plate = root.find("plate")
|
||||
if first_plate is None:
|
||||
return base_data
|
||||
# 更新第一个 plate 的 metadata 和 filament 字段
|
||||
for metadata in first_plate.findall("metadata"):
|
||||
key = metadata.attrib.get("key")
|
||||
if key == "prediction" and total_prediction > 0:
|
||||
|
||||
+117
-24
@@ -1,67 +1,142 @@
|
||||
"""数据模型模块。
|
||||
|
||||
定义换板打包器所用的全部数据类、类型别名和常量,包括:
|
||||
- 板件任务 (PlateJob)
|
||||
- G-code 补丁规则与配置 (GcodePatchRule / GcodePatchConfig)
|
||||
- 构建选项 (BuildOptions)
|
||||
- 板件来源与摘要 (PlateSource / ThreeMfSummary / BuildSummary)
|
||||
- 构建结果 (BuildResult)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
# 默认插入标记:在此行之前插入换板 G-code
|
||||
DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound========="
|
||||
# 默认 ZIP 压缩级别 (0-9,7 为平衡选择)
|
||||
DEFAULT_ZIP_COMPRESS_LEVEL = 7
|
||||
|
||||
# 元数据计算模式:取源文件数值还是累加求和
|
||||
MetadataMode = Literal["source", "sum"]
|
||||
# 换行符风格:LF (Unix) 或 CRLF (Windows)
|
||||
LineEnding = Literal["lf", "crlf"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlateJob:
|
||||
"""单个板件打包任务。
|
||||
|
||||
Attributes:
|
||||
source_3mf: 源 3MF 文件路径。
|
||||
copies: 该板件的副本数量,默认为 1。
|
||||
"""
|
||||
source_3mf: Path
|
||||
copies: int = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GcodePatchRule:
|
||||
"""单条 G-code 查找替换规则。
|
||||
|
||||
Attributes:
|
||||
name: 规则名称,用于识别和日志。
|
||||
find: 要查找的目标字符串。
|
||||
replace: 替换后的字符串。
|
||||
flag: 正则标志(可选),如 "i" 表示忽略大小写。
|
||||
enabled: 是否启用该规则,默认启用。
|
||||
max_count: 最大替换次数,默认 1(仅替换首次出现)。
|
||||
"""
|
||||
name: str
|
||||
find: str
|
||||
replace: str
|
||||
flag: str = ""
|
||||
enabled: bool = True
|
||||
max_count: int = 1
|
||||
flag: str = "" # 正则标志,例如 'i' 表示忽略大小写
|
||||
enabled: bool = True # 是否启用此规则
|
||||
max_count: int = 1 # 最多替换次数
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GcodePatchConfig:
|
||||
"""G-code 补丁配置,包含一组规则和插入标记。
|
||||
|
||||
Attributes:
|
||||
rules: 补丁规则元组。
|
||||
insert_before_marker: 在此标记行之前插入换板 G-code。
|
||||
"""
|
||||
rules: tuple[GcodePatchRule, ...]
|
||||
insert_before_marker: str = DEFAULT_INSERT_BEFORE_MARKER
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildOptions:
|
||||
"""构建打包选项。
|
||||
|
||||
Attributes:
|
||||
swap_gcode: 换板 G-code 文件路径或内容字符串。
|
||||
output_3mf: 输出 3MF 文件的保存路径。
|
||||
cool_bed_temp: 冷却热床目标温度(摄氏度),None 表示不等待冷却。
|
||||
wait_after_eject_seconds: 推板后等待秒数。
|
||||
show_plate_number: 是否在屏幕上显示当前板件编号。
|
||||
swap_after_final: 最终板件完成后是否也执行换板。
|
||||
metadata_mode: 元数据模式,"source" 取源文件值,"sum" 累加求和。
|
||||
line_ending: 输出 G-code 的换行符风格。
|
||||
add_preview_label: 是否为板件添加预览标签注释。
|
||||
apply_gcode_patches: 是否应用 G-code 补丁规则。
|
||||
swap_gcode_dir: 换板 G-code 文件所在目录,默认自动检测。
|
||||
zip_compress_level: 3MF (ZIP) 压缩级别,默认 7。
|
||||
"""
|
||||
swap_gcode: Path | str
|
||||
output_3mf: Path
|
||||
cool_bed_temp: int | None = 45
|
||||
wait_after_eject_seconds: int = 45
|
||||
show_plate_number: bool = True
|
||||
swap_after_final: bool = True
|
||||
metadata_mode: MetadataMode = "source"
|
||||
line_ending: LineEnding = "crlf"
|
||||
add_preview_label: bool = True
|
||||
apply_gcode_patches: bool = True
|
||||
swap_gcode_dir: Path | None = None
|
||||
cool_bed_temp: int | None = 45 # 热床冷却目标温度,None 跳过冷却
|
||||
wait_after_eject_seconds: int = 45 # 推板完成后等待时间
|
||||
show_plate_number: bool = True # 是否显示板件编号到屏幕
|
||||
swap_after_final: bool = True # 最后一块是否也换板
|
||||
metadata_mode: MetadataMode = "sum" # 元数据计算模式
|
||||
line_ending: LineEnding = "crlf" # 输出换行符
|
||||
add_preview_label: bool = True # 是否添加预览注释
|
||||
apply_gcode_patches: bool = True # 是否启用 G-code 补丁
|
||||
swap_gcode_dir: Path | None = None # 换板 G-code 所在目录
|
||||
zip_compress_level: int = DEFAULT_ZIP_COMPRESS_LEVEL # ZIP 压缩级别
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlateSource:
|
||||
"""单板 G-code 来源数据。
|
||||
|
||||
Attributes:
|
||||
source_3mf: 来源 3MF 文件。
|
||||
member_name: 3MF 内部成员名称,用于正确提取 G-code。
|
||||
gcode_text: 提取出的 G-code 文本内容。
|
||||
prediction_seconds: 预估打印时间(秒)。
|
||||
weight_grams: 预估耗材重量(克)。
|
||||
filament_used_m: 预估耗材用量(米)。
|
||||
filament_used_g: 预估耗材用量(克),部分切片软件以此字段为主。
|
||||
"""
|
||||
source_3mf: Path
|
||||
member_name: str
|
||||
gcode_text: str
|
||||
prediction_seconds: float | None = None
|
||||
weight_grams: float | None = None
|
||||
filament_used_m: float | None = None
|
||||
filament_used_g: float | None = None
|
||||
member_name: str # 3MF 内的成员名,用于定位 G-code
|
||||
gcode_text: str # 完整的 G-code 文本
|
||||
prediction_seconds: float | None = None # 预估打印时间(秒)
|
||||
weight_grams: float | None = None # 预估耗材重量(克)
|
||||
filament_used_m: float | None = None # 耗材用量(米)
|
||||
filament_used_g: float | None = None # 耗材用量(克)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThreeMfSummary:
|
||||
"""单个 3MF 文件的元数据摘要。
|
||||
|
||||
Attributes:
|
||||
source_3mf: 来源 3MF 文件。
|
||||
plate_count: 该文件中包含的板件数量。
|
||||
prediction_seconds: 预估打印时间(秒)。
|
||||
weight_grams: 预估耗材重量(克)。
|
||||
filament_used_m: 预估耗材用量(米)。
|
||||
filament_used_g: 预估耗材用量(克)。
|
||||
"""
|
||||
source_3mf: Path
|
||||
plate_count: int
|
||||
plate_count: int # 板件数量
|
||||
prediction_seconds: float | None = None
|
||||
weight_grams: float | None = None
|
||||
filament_used_m: float | None = None
|
||||
@@ -70,17 +145,35 @@ class ThreeMfSummary:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuildSummary:
|
||||
"""总体构建摘要。
|
||||
|
||||
Attributes:
|
||||
plate_count: 总板件数。
|
||||
total_prediction_seconds: 总预估打印时间(秒)。
|
||||
total_weight_grams: 总耗材重量(克)。
|
||||
total_filament_used_m: 总耗材用量(米)。
|
||||
total_filament_used_g: 总耗材用量(克)。
|
||||
"""
|
||||
plate_count: int
|
||||
total_prediction_seconds: float | None
|
||||
total_weight_grams: float | None
|
||||
total_filament_used_m: float | None
|
||||
total_filament_used_g: float | None
|
||||
total_prediction_seconds: float | None # 总预估时间
|
||||
total_weight_grams: float | None # 总耗材重量
|
||||
total_filament_used_m: float | None # 总耗材长度
|
||||
total_filament_used_g: float | None # 总耗材质量
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildResult:
|
||||
"""单次构建打包的最终结果。
|
||||
|
||||
Attributes:
|
||||
output_3mf: 输出 3MF 文件路径。
|
||||
plate_count: 包含的板件总数。
|
||||
total_prediction_seconds: 总预估打印时间(秒)。
|
||||
total_weight_grams: 总耗材重量(克)。
|
||||
gcode_md5: 最终 G-code 内容的 MD5 哈希值,用于唯一性校验。
|
||||
"""
|
||||
output_3mf: Path
|
||||
plate_count: int
|
||||
total_prediction_seconds: float | None
|
||||
total_weight_grams: float | None
|
||||
gcode_md5: str
|
||||
gcode_md5: str # G-code MD5 哈希值
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""G-code 补丁配置解析与应用。
|
||||
|
||||
本模块提供了从 INI 配置文件读取 G-code 修改规则的功能,
|
||||
支持两种配置节格式:新版 [patch.<名称>] 节和旧版 [Gcode] 节。
|
||||
补丁规则可用于在打包前对换板 G-code 进行查找替换等文本修改。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
@@ -9,24 +15,53 @@ from .paths import default_patch_config_path
|
||||
|
||||
|
||||
def parse_bool(value: str | None, default: bool = True) -> bool:
|
||||
"""将 INI 配置中的字符串值解析为布尔值。
|
||||
|
||||
支持的值: "1", "true", "yes", "on", "enabled"(不区分大小写)。
|
||||
|
||||
参数:
|
||||
value: 配置字符串值,可为 None。
|
||||
default: value 为 None 时的默认返回值。
|
||||
|
||||
返回:
|
||||
解析后的布尔值。
|
||||
"""
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on", "enabled"}
|
||||
|
||||
|
||||
def parse_patch_config(path: Path | None = None) -> GcodePatchConfig:
|
||||
"""从 INI 配置文件解析 G-code 补丁配置。
|
||||
|
||||
支持两种配置格式:
|
||||
1. 新版 [patch.<规则名>] 节,每个节定义一组查找/替换规则。
|
||||
2. 旧版 [Gcode] 节,通过 Postion<键> / Postion<键>_edit / Postion<键>_flag
|
||||
三联键定义规则,FinishFlag 定义插入位置标记。
|
||||
|
||||
参数:
|
||||
path: 配置文件路径,为 None 时使用默认路径。
|
||||
|
||||
返回:
|
||||
包含所有补丁规则和插入标记的 GcodePatchConfig 实例。
|
||||
"""
|
||||
config_path = path or default_patch_config_path()
|
||||
if not config_path.exists():
|
||||
return GcodePatchConfig(rules=())
|
||||
|
||||
# configparser 配置: 禁用插值,保持键名大小写
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
parser.optionxform = str
|
||||
parser.read(config_path, encoding="utf-8-sig")
|
||||
|
||||
# 读取 [swap] 节中的插入标记配置
|
||||
insert_before_marker = DEFAULT_INSERT_BEFORE_MARKER
|
||||
if parser.has_section("swap"):
|
||||
insert_before_marker = parser.get("swap", "insert_before_marker", fallback=insert_before_marker).strip().strip('"')
|
||||
|
||||
rules: list[GcodePatchRule] = []
|
||||
|
||||
# 解析新版 [patch.<名称>] 格式的补丁规则
|
||||
for section in parser.sections():
|
||||
if section.lower().startswith("patch."):
|
||||
enabled = parse_bool(parser.get(section, "enabled", fallback="true"), True)
|
||||
@@ -38,11 +73,14 @@ def parse_patch_config(path: Path | None = None) -> GcodePatchConfig:
|
||||
max_count = max(0, int(max_count_text))
|
||||
except ValueError:
|
||||
max_count = 1
|
||||
# 仅当 find 非空且规则启用时才添加
|
||||
if find and enabled:
|
||||
rules.append(GcodePatchRule(section.split(".", 1)[1], find, replace, flag, enabled, max_count))
|
||||
|
||||
# 解析旧版 [Gcode] 格式的补丁规则(兼容旧配置文件)
|
||||
if parser.has_section("Gcode"):
|
||||
gcode = parser["Gcode"]
|
||||
# 找出所有 Postion<键>_flag 键,提取基准键名
|
||||
base_names = sorted(
|
||||
key[:-5]
|
||||
for key in gcode.keys()
|
||||
@@ -54,33 +92,64 @@ def parse_patch_config(path: Path | None = None) -> GcodePatchConfig:
|
||||
flag = gcode.get(f"{base}_flag", "").strip().strip('"')
|
||||
if find and replace:
|
||||
rules.append(GcodePatchRule(base, find, replace, flag, True, 1))
|
||||
# FinishFlag 作为插入位置标记
|
||||
marker = gcode.get("FinishFlag", "").strip().strip('"')
|
||||
if marker:
|
||||
insert_before_marker = marker
|
||||
|
||||
return GcodePatchConfig(tuple(rules), insert_before_marker)
|
||||
|
||||
|
||||
def apply_patch_rule(text: str, rule: GcodePatchRule) -> str:
|
||||
"""对 G-code 文本应用单条补丁规则。
|
||||
|
||||
查找替换逻辑:
|
||||
- 如果规则未启用、查找文本为空或 max_count 为 0,则原样返回。
|
||||
- 如果指定了 flag 标记行,则只在该标记行之后开始查找替换。
|
||||
- 每次替换后计数器递增,达到 max_count 后停止。
|
||||
|
||||
参数:
|
||||
text: 待处理的 G-code 文本。
|
||||
rule: 要应用的补丁规则。
|
||||
|
||||
返回:
|
||||
应用规则后的 G-code 文本。
|
||||
"""
|
||||
if not rule.enabled or not rule.find or rule.max_count == 0:
|
||||
return text
|
||||
|
||||
lines = normalize_newlines(text).split("\n")
|
||||
start_index = 0
|
||||
|
||||
# 如果指定了 flag 标记,找到标记行后从下一行开始查找替换
|
||||
if rule.flag:
|
||||
for index, line in enumerate(lines):
|
||||
if line.strip() == rule.flag or rule.flag in line:
|
||||
start_index = index + 1
|
||||
break
|
||||
|
||||
replacements = 0
|
||||
# 在指定范围内查找并替换匹配行
|
||||
for index in range(start_index, len(lines)):
|
||||
if lines[index].strip() == rule.find or lines[index] == rule.find:
|
||||
lines[index] = rule.replace
|
||||
replacements += 1
|
||||
if replacements >= rule.max_count:
|
||||
break
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def apply_gcode_patches(text: str, config: GcodePatchConfig) -> str:
|
||||
"""对 G-code 文本依次应用配置中的所有补丁规则。
|
||||
|
||||
参数:
|
||||
text: 待处理的 G-code 文本。
|
||||
config: 包含补丁规则列表的配置对象。
|
||||
|
||||
返回:
|
||||
应用所有规则后的 G-code 文本。
|
||||
"""
|
||||
patched = normalize_newlines(text)
|
||||
for rule in config.rules:
|
||||
patched = apply_patch_rule(patched, rule)
|
||||
|
||||
@@ -1,26 +1,78 @@
|
||||
"""路径管理模块。
|
||||
|
||||
提供程序根目录和关键资源路径的计算逻辑,处理以下场景:
|
||||
- 正常 Python 运行(通过 __file__ 定位)
|
||||
- PyInstaller/Nuitka 单文件打包(通过 __compiled__ 信息定位)
|
||||
- PyInstaller 冻结目录模式(通过 sys.frozen / sys.executable 定位)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 应用程序包目录名
|
||||
APP_DIR_NAME = "a1_swap_mod_packer"
|
||||
# 换板 G-code 文件的默认子目录名
|
||||
SWAP_GCODE_DIR_NAME = "swap_gcode"
|
||||
# G-code 补丁配置文件名
|
||||
PATCH_CONFIG_FILE_NAME = "gcode_patches.ini"
|
||||
|
||||
|
||||
def program_root() -> Path:
|
||||
"""返回程序根目录的绝对路径。
|
||||
|
||||
根据运行环境自动选择定位策略:
|
||||
- Nuitka 编译的单文件:通过 __compiled__.containing_dir 获取。
|
||||
- Nuitka 但无 containing_dir:通过 __compiled__.original_argv0 推算。
|
||||
- PyInstaller 冻结模式:通过 sys.executable 所在目录获取。
|
||||
- 正常 Python 运行:基于本模块文件路径向上两级推算。
|
||||
|
||||
Returns:
|
||||
Path: 程序根目录的绝对路径。
|
||||
"""
|
||||
# Nuitka 编译标记,单文件模式下需特殊处理
|
||||
compiled = globals().get("__compiled__")
|
||||
if compiled is not None and getattr(compiled, "onefile", False):
|
||||
# Nuitka 提供的包含目录
|
||||
containing_dir = getattr(compiled, "containing_dir", None)
|
||||
if containing_dir:
|
||||
return Path(containing_dir).resolve()
|
||||
|
||||
# 回退:通过原始可执行文件路径推算
|
||||
original_argv0 = getattr(compiled, "original_argv0", None)
|
||||
if original_argv0:
|
||||
return Path(original_argv0).resolve().parent
|
||||
|
||||
# PyInstaller 冻结模式
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
# 正常 Python 解释器运行:向上两级回到项目根目录
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def default_swap_gcode_dir() -> Path:
|
||||
"""返回换板 G-code 文件的默认存放目录。
|
||||
|
||||
Returns:
|
||||
Path: 程序根目录下的 swap_gcode 子目录。
|
||||
"""
|
||||
return program_root() / SWAP_GCODE_DIR_NAME
|
||||
|
||||
|
||||
def default_patch_config_path() -> Path:
|
||||
"""返回 G-code 补丁配置文件的默认路径。
|
||||
|
||||
Returns:
|
||||
Path: 程序根目录下的 gcode_patches.ini 文件路径。
|
||||
"""
|
||||
return program_root() / PATCH_CONFIG_FILE_NAME
|
||||
|
||||
|
||||
def user_settings_path() -> Path:
|
||||
"""返回用户设置文件的路径。
|
||||
|
||||
Returns:
|
||||
Path: 程序根目录下的 settings.json 文件路径。
|
||||
"""
|
||||
return program_root() / "settings.json"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""输出规划与文件命名模块。
|
||||
|
||||
提供换板打包任务的输出路径解析、文件命名规则、摘要汇总等功能。
|
||||
支持基于模板的输出文件名生成以及同一批次内文件路径去重。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
@@ -9,13 +15,24 @@ from typing import Callable, Mapping, Sequence
|
||||
from .metadata import read_3mf_summary
|
||||
from .models import PlateJob, ThreeMfSummary
|
||||
|
||||
DEFAULT_OUTPUT_PATTERN = "{plates} Plates - {sources}.3mf"
|
||||
# 默认输出文件名模板,{plates} 为总盘数,{sources} 为来源标识
|
||||
DEFAULT_OUTPUT_PATTERN = "{plates}-Plates-{sources}.3mf"
|
||||
|
||||
# 摘要解析器类型别名,接受 Path 返回 ThreeMfSummary
|
||||
SummaryResolver = Callable[[Path], ThreeMfSummary]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputSummary:
|
||||
"""输出摘要数据类,汇总一次换板打包的整体统计信息。
|
||||
|
||||
属性:
|
||||
plate_count: 总盘数(含份数倍增)。
|
||||
copy_count: 总份数。
|
||||
prediction_seconds: 预估总耗时(秒),不可用时为 None。
|
||||
weight_grams: 总重量(克),不可用时为 None。
|
||||
filament_used_m: 耗材总长度(米),不可用时为 None。
|
||||
"""
|
||||
plate_count: int
|
||||
copy_count: int
|
||||
prediction_seconds: float | None = None
|
||||
@@ -25,16 +42,37 @@ class OutputSummary:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputNamingOptions:
|
||||
"""输出命名选项,控制生成文件的目录和命名规则。
|
||||
|
||||
属性:
|
||||
output_directory: 输出目录路径,None 表示使用第一个输入文件所在目录。
|
||||
filename_rule: 输出文件名模板字符串。
|
||||
"""
|
||||
output_directory: Path | str | None = None
|
||||
filename_rule: str = DEFAULT_OUTPUT_PATTERN
|
||||
|
||||
|
||||
class SafeFormatDict(dict):
|
||||
"""安全的格式化字典。
|
||||
|
||||
当 str.format_map 请求不存在的键时,返回原始占位符而非抛出 KeyError,
|
||||
保证模板中未提供的占位符被原样保留在输出中。
|
||||
"""
|
||||
def __missing__(self, key: str) -> str:
|
||||
return "{" + key + "}"
|
||||
|
||||
|
||||
def three_mf_summary_from_mapping(data: Mapping[str, object]) -> ThreeMfSummary:
|
||||
"""从类字典的映射对象构建 ThreeMfSummary。
|
||||
|
||||
用于将外部传入的配置数据(如 GUI 表格行)转换为标准摘要对象。
|
||||
|
||||
参数:
|
||||
data: 包含摘要字段的映射对象(键名与 ThreeMfSummary 字段名对应)。
|
||||
|
||||
返回:
|
||||
ThreeMfSummary: 解析后的摘要对象,缺失字段使用默认值。
|
||||
"""
|
||||
source_value = data.get("source_3mf")
|
||||
return ThreeMfSummary(
|
||||
source_3mf=Path(str(source_value)) if source_value is not None else Path(),
|
||||
@@ -50,6 +88,18 @@ def summarize_jobs_for_output(
|
||||
jobs: Sequence[PlateJob],
|
||||
summary_resolver: SummaryResolver = read_3mf_summary,
|
||||
) -> OutputSummary:
|
||||
"""将一组换板作业汇总为 OutputSummary。
|
||||
|
||||
分别计算总盘数、总份数,以及各项可选的累计统计值。
|
||||
支持自定义的摘要解析器,默认为 read_3mf_summary。
|
||||
|
||||
参数:
|
||||
jobs: PlateJob 序列。
|
||||
summary_resolver: 用于获取单个 3MF 摘要的可调用对象。
|
||||
|
||||
返回:
|
||||
OutputSummary: 汇总后的输出摘要。
|
||||
"""
|
||||
plate_count = 0
|
||||
copy_count = 0
|
||||
prediction_total = 0.0
|
||||
@@ -72,6 +122,7 @@ def summarize_jobs_for_output(
|
||||
if summary.filament_used_m is not None:
|
||||
used_m_total += summary.filament_used_m * copies
|
||||
used_m_found = True
|
||||
# 仅当至少有一个有效值时才会设置对应字段
|
||||
return OutputSummary(
|
||||
plate_count=plate_count,
|
||||
copy_count=copy_count,
|
||||
@@ -82,7 +133,20 @@ def summarize_jobs_for_output(
|
||||
|
||||
|
||||
def sanitize_filename(file_name: str) -> str:
|
||||
"""清理文件名中的非法字符,确保生成合法的文件路径。
|
||||
|
||||
将 Windows/Unix 中不允许出现在文件名中的字符替换为下划线,
|
||||
并去掉末尾的点和空格。
|
||||
|
||||
参数:
|
||||
file_name: 原始文件名。
|
||||
|
||||
返回:
|
||||
str: 清理后的安全文件名,至少返回 "packed.3mf"。
|
||||
"""
|
||||
# 将非法字符替换为下划线
|
||||
sanitized = re.sub(r"[\\/:*?\"<>|]+", "_", file_name).strip()
|
||||
# 去除末尾的点号和空格(Windows 禁止)
|
||||
sanitized = sanitized.rstrip(". ")
|
||||
return sanitized or "packed.3mf"
|
||||
|
||||
@@ -93,15 +157,35 @@ def resolve_output_path(
|
||||
summary_resolver: SummaryResolver = read_3mf_summary,
|
||||
now: datetime | None = None,
|
||||
) -> Path:
|
||||
"""根据作业列表和命名选项解析最终输出文件路径。
|
||||
|
||||
使用模板变量(source, sources, plates, copies, date, time)替换
|
||||
filename_rule 中的占位符,生成输出文件名。
|
||||
|
||||
参数:
|
||||
jobs: PlateJob 序列。
|
||||
naming: 输出命名选项。
|
||||
summary_resolver: 摘要解析器,默认 read_3mf_summary。
|
||||
now: 当前时间,None 时使用系统当前时间。
|
||||
|
||||
返回:
|
||||
Path: 解析后的输出文件完整路径。
|
||||
|
||||
抛出:
|
||||
ValueError: 若 jobs 为空。
|
||||
"""
|
||||
if not jobs:
|
||||
raise ValueError("No input 3MF file was provided.")
|
||||
summary = summarize_jobs_for_output(jobs, summary_resolver)
|
||||
first_input = jobs[0].source_3mf
|
||||
# 收集所有输入文件的主文件名(不含扩展名)
|
||||
stems = [job.source_3mf.stem for job in jobs]
|
||||
unique_stems = list(dict.fromkeys(stems))
|
||||
unique_stems = list(dict.fromkeys(stems)) # 保持顺序的去重
|
||||
source_token = first_input.stem
|
||||
# 当有多个不同来源时,生成 "xxx_and_N_more" 格式的标记
|
||||
sources_token = source_token if len(unique_stems) == 1 else f"{source_token}_and_{len(unique_stems) - 1}_more"
|
||||
timestamp = now or datetime.now()
|
||||
# 构建模板变量字典
|
||||
tokens = SafeFormatDict(
|
||||
source=source_token,
|
||||
sources=sources_token,
|
||||
@@ -112,6 +196,7 @@ def resolve_output_path(
|
||||
)
|
||||
pattern = naming.filename_rule.strip() or DEFAULT_OUTPUT_PATTERN
|
||||
file_name = sanitize_filename(pattern.format_map(tokens))
|
||||
# 确保文件扩展名为 .3mf
|
||||
if not file_name.lower().endswith(".3mf"):
|
||||
file_name += ".3mf"
|
||||
output_dir_text = "" if naming.output_directory is None else str(naming.output_directory).strip()
|
||||
@@ -120,6 +205,17 @@ def resolve_output_path(
|
||||
|
||||
|
||||
def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path:
|
||||
"""为单次运行确保输出路径唯一,通过追加递增序号避免冲突。
|
||||
|
||||
若目标路径已被使用,则在文件名后添加 _2、_3 等后缀直至唯一。
|
||||
|
||||
参数:
|
||||
path: 期望的输出路径。
|
||||
used_paths: 已使用路径的集合(会原地修改)。
|
||||
|
||||
返回:
|
||||
Path: 去重后的唯一路径。
|
||||
"""
|
||||
resolved_key = path.resolve(strict=False)
|
||||
if resolved_key not in used_paths:
|
||||
used_paths.add(resolved_key)
|
||||
@@ -128,6 +224,7 @@ def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path:
|
||||
suffix = path.suffix
|
||||
parent = path.parent
|
||||
index = 2
|
||||
# 递增序号直到找到未使用的路径
|
||||
while True:
|
||||
candidate = parent / f"{stem}_{index}{suffix}"
|
||||
candidate_key = candidate.resolve(strict=False)
|
||||
@@ -138,6 +235,14 @@ def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path:
|
||||
|
||||
|
||||
def _optional_float(value: object) -> float | None:
|
||||
"""安全地将任意对象转换为浮点数,失败时返回 None。
|
||||
|
||||
参数:
|
||||
value: 待转换的任意对象。
|
||||
|
||||
返回:
|
||||
float | None: 转换成功返回浮点数,否则返回 None。
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal
|
||||
|
||||
set "PY=.venv\Scripts\python.exe"
|
||||
set "DIST=build\onefile"
|
||||
|
||||
if not exist "%DIST%" mkdir "%DIST%"
|
||||
|
||||
"%PY%" -m nuitka ^
|
||||
--mode=onefile ^
|
||||
--msvc=latest ^
|
||||
--enable-plugin=pyside6 ^
|
||||
--include-qt-plugins=platforms,imageformats,styles,iconengines ^
|
||||
--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.
|
||||
echo Next step is building CLI.
|
||||
|
||||
"%PY%" -m nuitka ^
|
||||
--mode=onefile ^
|
||||
--msvc=latest ^
|
||||
--output-dir="%DIST%" ^
|
||||
--output-filename=a1packer-cli.exe ^
|
||||
--remove-output ^
|
||||
run_cli.py
|
||||
if errorlevel 1 exit /b %ERRORLEVEL%
|
||||
|
||||
robocopy "swap_gcode" "%DIST%\swap_gcode" /E
|
||||
if %ERRORLEVEL% GEQ 8 exit /b %ERRORLEVEL%
|
||||
|
||||
copy /Y "gcode_patches.ini" "%DIST%\gcode_patches.ini" >nul
|
||||
if errorlevel 1 exit /b %ERRORLEVEL%
|
||||
|
||||
copy /Y "x.png" "%DIST%\x.png" >nul
|
||||
if errorlevel 1 exit /b %ERRORLEVEL%
|
||||
|
||||
echo.
|
||||
echo Build done: %DIST%
|
||||
exit /b 0
|
||||
@@ -0,0 +1,285 @@
|
||||
# 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 | 所有重复份的总重量 |
|
||||
|
||||
如果选了"保留原始预测和重量",此文件原样保留。
|
||||
|
||||
### 合成预览图(可选)
|
||||
|
||||
如果启用了预览(CLI 中默认开启):
|
||||
|
||||
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)`,CLI 和 GUI 会在调用它之前做不同的准备工作。
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph CLI["CLI 入口: run_cli.py → cli.py"]
|
||||
C1["argparse 解析参数"] --> C2["收集 --item / 位置参数"]
|
||||
C2 --> C3["构造 BuildOptions"]
|
||||
C3 --> BUILD["build_packed_3mf()"]
|
||||
end
|
||||
subgraph GUI["GUI 入口: run_gui.py → gui.py"]
|
||||
G1["拖放/添加文件 → 表格"] --> G2["每行: 3MF路径 + 份数"]
|
||||
G2 --> G3["控件值 → BuildOptions"]
|
||||
G3 --> G4{"批处理模式?"}
|
||||
G4 -- 合并 --> BUILD
|
||||
G4 -- 独立 --> G5["逐行调 build_packed_3mf()<br/>ProcessPoolExecutor 并行"]
|
||||
end
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
一条命令搞定。指定输入文件、份数、输出路径和各项参数:
|
||||
|
||||
```bash
|
||||
python -m a1_swap_mod_packer.cli build \
|
||||
--item "A.3mf" 3 \
|
||||
--swap-gcode "LX_1.02.26.gcode" \
|
||||
--cool-bed 45 \
|
||||
--wait 45 \
|
||||
--show-plate-number \
|
||||
-o "3 Plates - A.3mf"
|
||||
```
|
||||
|
||||
CLI 做了这些:
|
||||
|
||||
1. `argparse` 解析参数(文件、份数、换盘模板、冷却温度、等待秒数...)
|
||||
2. 拼成 `PlateJob` 列表和 `BuildOptions`
|
||||
3. 调一次 `build_packed_3mf()`(合并模式)
|
||||
4. 把结果打印到 stdout
|
||||
|
||||
新增 `--no-eject-wait` 选项可禁用换盘后等待时间(`G4 P...` 行),与 GUI 中的勾选框对应。
|
||||
|
||||
**所有输入合并成 1 个输出**。参数靠命令行传,没有交互、没有预览。
|
||||
|
||||
### GUI
|
||||
|
||||
窗口操作,参数靠控件选、文件靠拖放。最终也是调 `build_packed_3mf()`,但准备阶段和 CLI 不同:
|
||||
|
||||
#### GUI 独有的准备阶段
|
||||
|
||||
1. **文件管理** — 表格里每行一个源文件 + 份数,可拖放、排序、移除
|
||||
2. **元数据预读** — 添加文件时就解析 `slice_info.config`,表格直接显示时间和耗材(CLI 不显示这个,只在构建后输出)
|
||||
3. **缩略图预览** — 选中行 3MF → 右侧显示 `plate_N.png` 缩略图
|
||||
4. **输出路径自动算** — 默认留空,自动写到第一个输入文件旁边。文件名按规则 `{plates} Plates - {sources}.3mf` 生成(CLI 必须手动用 `-o`)
|
||||
5. **设置持久化** — 控件值自动存 `settings.json`,重启恢复
|
||||
|
||||
#### 合并模式(默认)
|
||||
|
||||
和 CLI 一样,所有行合并调一次 `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
|
||||
```
|
||||
|
||||
构建前:
|
||||
- 每行自动算各自的输出路径(文件名规则 + 去重防覆盖)
|
||||
- 弹出进度日志,记录每个文件的构建结果
|
||||
- 有关闭全局预览标签的选项
|
||||
|
||||
#### GUI vs CLI 能力速查
|
||||
|
||||
| 能力 | CLI | GUI |
|
||||
|------|:---:|:---:|
|
||||
| 合并模式 | ✅ | ✅ |
|
||||
| 独立批处理 | ❌ | ✅ |
|
||||
| 拖放添加文件 | ❌ | ✅ |
|
||||
| 表格编辑份数 | ❌ | ✅(每行 SpinBox) |
|
||||
| 元数据预显示 | ❌ | ✅(表格直接看时间/耗材) |
|
||||
| 缩略图预览 | ❌ | ✅ |
|
||||
| 输出路径自动生成 | ❌ | ✅(文件名规则) |
|
||||
| 设置持久化 | ❌ | ✅(settings.json) |
|
||||
| 脚本/自动化友好 | ✅ | ❌ |
|
||||
@@ -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
|
||||
Pillow>=10.0
|
||||
zlib-ng>=0.5
|
||||
|
||||
@@ -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
|
||||
G1 Y150 F1200
|
||||
G1 Y40 F1200
|
||||
;G1 Y260 F3000
|
||||
;The platform ensures that new plate can be added properly even when no plate are available.
|
||||
G380 S2 Y264 F2000
|
||||
; --- 阶段2:Z 轴交替下压(G380 S3/S2 ×4轮),压紧打印板 ---
|
||||
G91 ; 切换相对坐标模式
|
||||
G380 S3 Z-25 F1200 ; 力传感器碰撞退出:Z 轴下降 25mm 直到碰到板
|
||||
G380 S2 Z85 F1200 ; 力传感器目标位移动:Z 轴上升 85mm 到预设力值
|
||||
G380 S3 Z-20 F1200 ; 第二轮下压碰撞检测
|
||||
G380 S2 Z85 F1200 ; 第二轮力控上升
|
||||
G380 S3 Z-20 F1200 ; 第三轮下压
|
||||
G380 S2 Z85 F1200 ; 第三轮上升
|
||||
G380 S3 Z-20 F1200 ; 第四轮下压(确保板完全贴合热床)
|
||||
G380 S2 Z85 F1200 ; 第四轮上升
|
||||
G1 Z-5 F1200 ; Z 轴下降 5mm 释放上升张力
|
||||
G90 ; 切回绝对坐标模式
|
||||
|
||||
G91;
|
||||
;G380 S3 Z-55 F1200
|
||||
G1 Z-55 F1200
|
||||
G90;
|
||||
; --- 阶段3:驻留稳定 ---
|
||||
G4 P2000 ; 驻留 2 秒,让板稳定贴合
|
||||
|
||||
G1 Y120 F4000
|
||||
; --- 阶段4:前推弹板 — 利用惯性将板从热床上推离 ---
|
||||
G1 Y15 F2000 ; 先推到前端附近 Y15
|
||||
G1 Y150 F1200 ; 后退一段
|
||||
G1 Y40 F1200 ; 再推向前端
|
||||
|
||||
G28 Y
|
||||
;Avoiding the use of the G380 command is to prevent failure in reaching the correct position.
|
||||
G1 Y-1 F1200
|
||||
;G380 S3 Y-1 F1200
|
||||
; 以下注释掉的 Y260 是备选方案
|
||||
; G1 Y260 F3000
|
||||
|
||||
; 用触力传感器推到最前端 Y264(力控,不会硬撞)
|
||||
G380 S2 Y264 F2000 ; A1 专有:以预设力矩正向推进到 Y264
|
||||
|
||||
; --- 阶段5:Z 轴上撤退让,为弹射留出空间 ---
|
||||
G91 ; 切相对坐标
|
||||
; G380 S3 Z-55 F1200 ; 注释掉:原用触力退出方式
|
||||
G1 Z-55 F1200 ; Z 轴下降 55mm(使喷嘴远离板面)
|
||||
G90 ; 切回绝对坐标
|
||||
|
||||
; --- 阶段6:Y 轴快速移动 + 微调定位 ---
|
||||
G1 Y120 F4000 ; 快速移动到 Y120
|
||||
G28 Y ; Y 轴再次归零校准
|
||||
|
||||
; 使用 G1 而非 G380 是为了防止触力传感器干扰定位精度
|
||||
G1 Y-1 F1200 ; Y 轴后移 1mm(为抖板准备起始位置)
|
||||
; G380 S3 Y-1 F1200 ; 注释掉:原触力退出方式
|
||||
|
||||
G4 P500 ; 驻留 500ms
|
||||
|
||||
; ═══════════════════════════════════════════
|
||||
; 抖板 第1轮:微小 Y 轴往复振动,确保板完全脱离
|
||||
; 振幅逐步增大:1mm → 3.5mm
|
||||
; ═══════════════════════════════════════════
|
||||
; 开始 振动触发换板
|
||||
G1 Y1 F100 ; 前进 1mm(慢速)
|
||||
G1 Y0 F20000 ; 快速退回
|
||||
G4 P500 ; 驻留 500ms
|
||||
|
||||
G1 Y1.5 F100 ; 前进 1.5mm
|
||||
G1 Y0 F20000 ; 快速退回
|
||||
G4 P500
|
||||
|
||||
;start Shake-triggered plate replacement
|
||||
G1 Y1 F100
|
||||
G1 Y0 F20000
|
||||
G1 Y2 F100 ; 前进 2mm
|
||||
G1 Y0 F20000 ; 快速退回
|
||||
G4 P500
|
||||
G1 Y1.5 F100
|
||||
G1 Y0 F20000
|
||||
G4 P500
|
||||
G1 Y2 F100
|
||||
G1 Y0 F20000
|
||||
G4 P500
|
||||
G1 Y2.5 F100
|
||||
G1 Y0 F20000
|
||||
G4 P500
|
||||
G1 Y3 F100
|
||||
G1 Y0 F10000
|
||||
G4 P500
|
||||
G1 Y3.5 F100
|
||||
G1 Y0 F5000
|
||||
;end Shake-triggered plate replacement
|
||||
|
||||
G1 Y235 F1200
|
||||
G1 Y110 F5000
|
||||
G1 Y200 F1200
|
||||
;G1 Y266 F1500
|
||||
G380 S2 Y264 F2000
|
||||
G1 Y2.5 F100 ; 前进 2.5mm
|
||||
G1 Y0 F20000 ; 快速退回
|
||||
G4 P500
|
||||
|
||||
;After retracting the build plate to the frontmost position, dwell for 1 second.
|
||||
G4 P1000
|
||||
G1 Y3 F100 ; 前进 3mm
|
||||
G1 Y0 F10000 ; 退回(降速)
|
||||
G4 P500
|
||||
|
||||
G1 Y40 F4000
|
||||
G1 Y-2 F5000
|
||||
G1 Y3.5 F100 ; 前进 3.5mm(最大振幅)
|
||||
G1 Y0 F5000 ; 退回(进一步降速)
|
||||
; 结束 振动触发换板
|
||||
|
||||
;start Shake-triggered plate replacement
|
||||
; --- 阶段7:推板前移 + 力控校准 ---
|
||||
G1 Y235 F1200 ; 推到 Y235
|
||||
G1 Y110 F5000 ; 后退
|
||||
G1 Y200 F1200 ; 再前推
|
||||
; G1 Y266 F1500 ; 注释掉:备选位置
|
||||
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
|
||||
|
||||
; 将打印板缩回到最前端后,驻留 1 秒
|
||||
G4 P1000 ; 驻留 1 秒
|
||||
|
||||
; --- 阶段8:再次弹射 ---
|
||||
G1 Y40 F4000 ; 推到 Y40
|
||||
G1 Y-2 F5000 ; ⚡ 高速反向弹射 2mm
|
||||
|
||||
; ═══════════════════════════════════════════
|
||||
; 抖板 第2轮:再次振动,确保没有残留的板
|
||||
; 振幅:1mm → 3.5mm(与第1轮相同)
|
||||
; ═══════════════════════════════════════════
|
||||
; 开始 振动触发换板
|
||||
G1 Y1 F100
|
||||
G1 Y0 F20000
|
||||
G4 P500
|
||||
@@ -88,13 +118,15 @@ G4 P500
|
||||
G1 Y3.5 F100
|
||||
G1 Y0 F5000
|
||||
|
||||
;G1 Y2.5 F100
|
||||
;G1 Y0 F8000
|
||||
;G4 P1000
|
||||
;G1 Y3.5 F100
|
||||
;G1 Y0 F5000
|
||||
;end Shake-triggered plate replacement
|
||||
; 以下为注释掉的备选抖板参数
|
||||
; G1 Y2.5 F100
|
||||
; G1 Y0 F8000
|
||||
; G4 P1000
|
||||
; G1 Y3.5 F100
|
||||
; G1 Y0 F5000
|
||||
; 结束 振动触发换板
|
||||
|
||||
G1 Y120 F2000
|
||||
; --- 阶段9:归位到待机位置 ---
|
||||
G1 Y120 F2000 ; 回到 Y120 中间待机位置
|
||||
|
||||
;end change plate
|
||||
; 结束换板
|
||||
|
||||
+94
-69
@@ -1,93 +1,119 @@
|
||||
;==========================================================
|
||||
; A1 SwapMod 换板 G-code — 类型 C1:安全换板(标准版)
|
||||
; 特点:M400/M17 保护、G1 Z- + G380 S2 纯力控下压、双轮抖板(2.5mm偏移)
|
||||
;==========================================================
|
||||
|
||||
;start change plate
|
||||
; 开始换板
|
||||
|
||||
G28 Y
|
||||
G1 Y262 F2000
|
||||
; --- 阶段1:Y 轴归零并退到后端 ---
|
||||
G28 Y ; Y 轴归零(寻找限位开关)
|
||||
G1 Y262 F2000 ; 推到 Y262(后端位置,速度 F2000)
|
||||
|
||||
M400 ; wait all motion done
|
||||
M17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom
|
||||
; --- 阶段2:运动保护 — 等所有运动停止 + 降低 Z 电机电流 ---
|
||||
M400 ; 等待所有运动队列清空
|
||||
M17 Z0.4 ; 将 Z 轴电机电流降至 0.4A,降低阻力,防止后续 Y 轴移动时 Z 轴硬怼损伤打印件
|
||||
|
||||
G91
|
||||
G1 Z-35 F1200
|
||||
G380 S2 Z95
|
||||
; --- 阶段3:Z 轴下压(G1 Z- + G380 S2 纯力控 ×4轮),压紧打印板 ---
|
||||
G91 ; 切换相对坐标模式
|
||||
G1 Z-35 F1200 ; Z 轴下降 35mm
|
||||
G380 S2 Z95 ; 力控上升 95mm 到预设力值
|
||||
|
||||
G1 Z-25
|
||||
G1 Z-25 ; Z 轴再降 25mm
|
||||
G380 S2 Z90 ; 力控上升 90mm
|
||||
|
||||
G1 Z-25 ; 第三轮
|
||||
G380 S2 Z90
|
||||
|
||||
G1 Z-25
|
||||
G380 S2 Z90
|
||||
|
||||
G1 Z-30
|
||||
G1 Z-30 ; 第四轮
|
||||
G380 S2 Z95
|
||||
|
||||
G1 Z-5
|
||||
G90
|
||||
G1 Z-5 ; Z 轴微降 5mm 释放张力
|
||||
G90 ; 切回绝对坐标
|
||||
|
||||
G4 P2000
|
||||
; --- 阶段4:驻留稳定 ---
|
||||
G4 P2000 ; 驻留 2 秒
|
||||
|
||||
G1 Y15 F800
|
||||
G1 Y150 F1200
|
||||
; --- 阶段5:前推弹板 — 利用惯性将板推离 ---
|
||||
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
|
||||
G380 S2 Y264 F2000
|
||||
; 平台通过力控确保即使没有板也能正常装入新板
|
||||
; G1 Y260 F3000 ; 注释掉:备选方案
|
||||
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
|
||||
|
||||
G91;
|
||||
G1 Z-70 F1200
|
||||
G90;
|
||||
; --- 阶段6:Z 轴上撤退让 ---
|
||||
G91 ; 切相对坐标
|
||||
G1 Z-70 F1200 ; Z 轴下降 70mm(使喷嘴远离板面,为弹射留空间)
|
||||
G90 ; 切回绝对坐标
|
||||
|
||||
;
|
||||
G1 Y200 F10000
|
||||
; --- 阶段7:Y 轴高速移动 + 微调到弹射起始位 ---
|
||||
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.
|
||||
G1 Y-1 F20000
|
||||
;G380 S3 Y-1 F1200
|
||||
G4 P500 ; 驻留 500ms
|
||||
|
||||
; ═══════════════════════════════════════════
|
||||
; 抖板 第1轮:微小 Y 轴往复振动,确保板完全脱离
|
||||
; 振幅逐步增大:1mm → 2.5mm(比类型B更保守)
|
||||
; ═══════════════════════════════════════════
|
||||
|
||||
G28 Y ; 先 Y 轴归零校准
|
||||
|
||||
; 开始 振动触发换板
|
||||
G1 Y1 F100 ; 前进 1mm(慢速)
|
||||
G1 Y0 F20000 ; 快速退回
|
||||
G4 P500
|
||||
|
||||
G28 Y
|
||||
G1 Y1.5 F100 ; 前进 1.5mm
|
||||
G1 Y0 F20000 ; 快速退回
|
||||
G4 P500
|
||||
|
||||
;start Shake-triggered plate replacement
|
||||
G1 Y1 F100
|
||||
G1 Y0 F20000
|
||||
G1 Y2 F100 ; 前进 2mm
|
||||
G1 Y0 F20000 ; 快速退回
|
||||
G4 P500
|
||||
G1 Y1.5 F100
|
||||
G1 Y0 F20000
|
||||
G4 P500
|
||||
G1 Y2 F100
|
||||
G1 Y0 F20000
|
||||
G4 P500
|
||||
G1 Y2.5 F100
|
||||
G1 Y0 F20000
|
||||
G4 P500
|
||||
G1 Y2.5 F100
|
||||
G1 Y0 F10000
|
||||
G4 P500
|
||||
;G1 Y3.5 F100
|
||||
;G1 Y0 F5000
|
||||
;end Shake-triggered plate replacement
|
||||
|
||||
G1 Y235 F1200
|
||||
G1 Y110 F5000
|
||||
G1 Y200 F1200
|
||||
;G1 Y266 F1500
|
||||
G380 S2 Y264 F2000
|
||||
G1 Y2.5 F100 ; 前进 2.5mm
|
||||
G1 Y0 F20000 ; 快速退回
|
||||
G4 P500
|
||||
|
||||
;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
|
||||
|
||||
G1 Y25 F4000
|
||||
G1 Y-2 F20000
|
||||
;G1 Y-2 F1000
|
||||
; --- 阶段9:第2轮弹射 ---
|
||||
G1 Y25 F4000 ; 推到 Y25
|
||||
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 Y0 F20000
|
||||
G4 P500
|
||||
@@ -104,12 +130,11 @@ G1 Y2.5 F100
|
||||
G1 Y0 F10000
|
||||
G4 P500
|
||||
|
||||
; 结束 振动触发换板
|
||||
|
||||
;end Shake-triggered plate replacement
|
||||
; --- 阶段10:归位 ---
|
||||
; 推到前端
|
||||
G1 Y262 F1500 ; 推到 Y262
|
||||
G1 Y120 F2000 ; 回到 Y120 待机位置
|
||||
|
||||
;move to front
|
||||
G1 Y262 F1500
|
||||
|
||||
G1 Y120 F2000
|
||||
|
||||
;end change plate
|
||||
; 结束换板
|
||||
|
||||
@@ -1,58 +1,71 @@
|
||||
;==========================================================
|
||||
; A1 SwapMod 换板 G-code — 类型 C2:安全换板(慢速版)
|
||||
; 特点:与 C1 标准版完全相同,仅第2轮弹射速度从 F20000 降为 F1000
|
||||
; 更温和的弹射,减少对板和打印机的冲击
|
||||
;==========================================================
|
||||
|
||||
;start change plate
|
||||
; 开始换板
|
||||
|
||||
G28 Y
|
||||
G1 Y262 F2000
|
||||
; --- 阶段1:Y 轴归零并退到后端 ---
|
||||
G28 Y ; Y 轴归零(寻找限位开关)
|
||||
G1 Y262 F2000 ; 推到 Y262(后端位置)
|
||||
|
||||
M400 ; wait all motion done
|
||||
M17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom
|
||||
; --- 阶段2:运动保护 ---
|
||||
M400 ; 等待所有运动队列清空
|
||||
M17 Z0.4 ; 将 Z 轴电机电流降至 0.4A,降低阻力,防止碰撞损伤打印件
|
||||
|
||||
G91
|
||||
G1 Z-35 F1200
|
||||
G380 S2 Z95
|
||||
; --- 阶段3:Z 轴下压(G1 Z- + G380 S2 纯力控 ×4轮)---
|
||||
G91 ; 切换相对坐标
|
||||
G1 Z-35 F1200 ; Z 轴下降 35mm
|
||||
G380 S2 Z95 ; 力控上升 95mm
|
||||
|
||||
G1 Z-25
|
||||
G1 Z-25 ; 第二轮
|
||||
G380 S2 Z90
|
||||
|
||||
G1 Z-25
|
||||
G1 Z-25 ; 第三轮
|
||||
G380 S2 Z90
|
||||
|
||||
G1 Z-30
|
||||
G1 Z-30 ; 第四轮
|
||||
G380 S2 Z95
|
||||
|
||||
G1 Z-5
|
||||
G90
|
||||
G1 Z-5 ; Z 轴微降释放张力
|
||||
G90 ; 切回绝对坐标
|
||||
|
||||
G4 P2000
|
||||
; --- 阶段4:驻留稳定 ---
|
||||
G4 P2000 ; 驻留 2 秒
|
||||
|
||||
G1 Y15 F800
|
||||
G1 Y150 F1200
|
||||
; --- 阶段5:前推弹板 ---
|
||||
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
|
||||
G380 S2 Y264 F2000
|
||||
; 平台通过力控确保即使没有板也能正常装入新板
|
||||
; G1 Y260 F3000
|
||||
G380 S2 Y264 F2000 ; 力控推到最前端 Y264
|
||||
|
||||
G91;
|
||||
G1 Z-70 F1200
|
||||
G90;
|
||||
; --- 阶段6:Z 轴上撤退让 ---
|
||||
G91 ; 切相对坐标
|
||||
G1 Z-70 F1200 ; Z 轴下降 70mm(为弹射留空间)
|
||||
G90 ; 切回绝对坐标
|
||||
|
||||
;
|
||||
G1 Y200 F10000
|
||||
; --- 阶段7:Y 轴高速移动 + 微调定位 ---
|
||||
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.
|
||||
G1 Y-1 F20000
|
||||
;G380 S3 Y-1 F1200
|
||||
G4 P500 ; 驻留 500ms
|
||||
|
||||
G4 P500
|
||||
; --- 抖板校准归零 ---
|
||||
G28 Y ; Y 轴归零校准
|
||||
|
||||
G28 Y
|
||||
|
||||
;start Shake-triggered plate replacement
|
||||
; ═══════════════════════════════════════════
|
||||
; 抖板 第1轮:振动确保板完全脱离(1→2.5mm)
|
||||
; ═══════════════════════════════════════════
|
||||
; 开始 振动触发换板
|
||||
G1 Y1 F100
|
||||
G1 Y0 F20000
|
||||
G4 P500
|
||||
@@ -68,26 +81,32 @@ G4 P500
|
||||
G1 Y2.5 F100
|
||||
G1 Y0 F10000
|
||||
G4 P500
|
||||
;G1 Y3.5 F100
|
||||
;G1 Y0 F5000
|
||||
;end Shake-triggered plate replacement
|
||||
; G1 Y3.5 F100 ; 注释掉:更大的振幅(备选)
|
||||
; G1 Y0 F5000
|
||||
; 结束 振动触发换板
|
||||
|
||||
; --- 阶段8:推板 + 力控校准 ---
|
||||
G1 Y235 F1200
|
||||
G1 Y110 F5000
|
||||
G1 Y200 F1200
|
||||
;G1 Y266 F1500
|
||||
G380 S2 Y264 F2000
|
||||
; G1 Y266 F1500
|
||||
G380 S2 Y264 F2000 ; 力控推到最前端
|
||||
|
||||
;After retracting the build plate to the frontmost position, dwell for 1 second.
|
||||
; 驻留 1 秒
|
||||
G4 P1000
|
||||
|
||||
G1 Y25 F4000
|
||||
;G1 Y-2 F20000
|
||||
G1 Y-2 F1000
|
||||
; --- 阶段9:第2轮弹射(🐌 慢速版关键差异)---
|
||||
G1 Y25 F4000 ; 推到 Y25
|
||||
; G1 Y-2 F20000 ; 注释掉:原标准版高速弹射
|
||||
G1 Y-2 F1000 ; 🐌 慢速弹射(F1000 vs 标准版 F20000)
|
||||
; 更温和,减少对板和结构的冲击
|
||||
|
||||
G4 P500
|
||||
|
||||
;start Shake-triggered plate replacement
|
||||
; ═══════════════════════════════════════════
|
||||
; 抖板 第2轮:再次振动(1→2.5mm)
|
||||
; ═══════════════════════════════════════════
|
||||
; 开始 振动触发换板
|
||||
G1 Y1 F100
|
||||
G1 Y0 F20000
|
||||
G4 P500
|
||||
@@ -104,12 +123,11 @@ G1 Y2.5 F100
|
||||
G1 Y0 F10000
|
||||
G4 P500
|
||||
|
||||
; 结束 振动触发换板
|
||||
|
||||
;end Shake-triggered plate replacement
|
||||
|
||||
;move to front
|
||||
; --- 阶段10:归位 ---
|
||||
; 推到前端
|
||||
G1 Y262 F1500
|
||||
G1 Y120 F2000 ; 回到 Y120 待机位置
|
||||
|
||||
G1 Y120 F2000
|
||||
|
||||
;end change plate
|
||||
; 结束换板
|
||||
|
||||
@@ -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,40 @@
|
||||
"""测试应用程序版本号的显示一致性。
|
||||
|
||||
验证内容:
|
||||
- APP_TITLE 使用统一的 __version__ 构建
|
||||
- CLI 的 --help 和 --version 输出均使用共享的版本号
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import unittest
|
||||
|
||||
from a1_swap_mod_packer import APP_NAME, APP_TITLE, __version__
|
||||
from a1_swap_mod_packer.cli import create_parser
|
||||
|
||||
|
||||
class VersionDisplayTest(unittest.TestCase):
|
||||
"""测试版本信息在应用标题和 CLI 中的一致性。"""
|
||||
|
||||
def test_app_title_uses_shared_version(self) -> None:
|
||||
"""验证 APP_TITLE 由 APP_NAME 与 __version__ 拼接而成。"""
|
||||
self.assertEqual(APP_TITLE, f"{APP_NAME} v{__version__}")
|
||||
|
||||
def test_cli_help_and_version_use_shared_version(self) -> None:
|
||||
"""验证 CLI 帮助信息和 --version 输出中均包含统一版本号。"""
|
||||
parser = create_parser()
|
||||
# 帮助信息应包含 APP_TITLE
|
||||
self.assertIn(APP_TITLE, parser.format_help())
|
||||
|
||||
stdout = io.StringIO()
|
||||
# --version 参数触发 SystemExit(0) 并打印版本信息
|
||||
with self.assertRaises(SystemExit) as caught, contextlib.redirect_stdout(stdout):
|
||||
parser.parse_args(["--version"])
|
||||
|
||||
self.assertEqual(caught.exception.code, 0) # 正常退出码
|
||||
self.assertEqual(stdout.getvalue().strip(), f"{APP_NAME} {__version__}") # 版本信息匹配
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user