12 Commits

Author SHA1 Message Date
hyyz17200 6ac5331932 Add Windows build script and update README with build instructions 2026-05-08 17:36:55 +08:00
hyyz17200 4ba654ca3f Add parallel processing for individual batch builds 2026-05-08 12:17:27 +08:00
hyyz17200 8126a9c608 Optimize G-code processing time by using M73OffsetTemplate for plate number offsets 2026-05-08 12:02:15 +08:00
hyyz17200 7dabdc676a Introduce caching for prepared G-code 2026-05-08 11:29:09 +08:00
hyyz17200 26b2591786 Use zlib-ng to do ZIP compression 2026-05-08 11:02:41 +08:00
hyyz17200 8bfaa1d1fe Enhance thumbnail generation 2026-05-08 02:37:59 +08:00
hyyz17200 61b2b7ae44 Show version number 2026-05-08 02:34:40 +08:00
hyyz17200 87167d2616 Added an experimental workaround for the Z-axis homing bug on the A1. Kindly provided by QQ group member "风信子号" 2026-05-08 02:06:37 +08:00
hyyz17200 b334b16c42 Enhance GUI: Add order controls, thumbnail preview, and improve more UI stuff 2026-05-08 02:02:47 +08:00
hyyz17200 5f3b5c716c Add SuccessToast as notifications 2026-05-08 01:05:04 +08:00
hyyz17200 614c65b30e Fix a bug: A1 refuses to print when packing any non first plate G-code 2026-05-08 00:48:13 +08:00
hyyz17200 287509411a Add screenshot and real photo
Add screenshot and real photo
2026-04-28 17:48:59 +08:00
22 changed files with 1751 additions and 109 deletions
+1
View File
@@ -15,4 +15,5 @@ Thumbs.db
*.bak *.bak
/settings.json /settings.json
/reference
codec.py codec.py
+204 -2
View File
@@ -1,9 +1,15 @@
# A1 Swap Mod Packer # A1 Swap Mod Packer
Current version: **v0.5.0**
A1 Swap Mod Packer is an open-source implementation 3MF packer for Bambu Lab A1 SwapMod workflows. A1 Swap Mod Packer is an open-source implementation 3MF packer for Bambu Lab A1 SwapMod workflows.
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. 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 Screenshot](docs/GUI_window.webp)
<img src="docs/a1_swapmod_realphoto.webp" width="50%" />
## Notes ## Notes
- This project contains no proprietary code. All functionalities were implemented independently by comparing the states before and after 3MF file generation. - This project contains no proprietary code. All functionalities were implemented independently by comparing the states before and after 3MF file generation.
@@ -20,19 +26,33 @@ It takes one or more A1-sliced `.3mf` files, repeats their plate G-code accordin
- External SwapMod G-code template folder. - External SwapMod G-code template folder.
- Editable G-code patch file. - Editable G-code patch file.
- Optional remaining-time plate-number encoding through `M73 R...`. - Optional remaining-time plate-number encoding through `M73 R...`.
- Selected-input thumbnail preview in the GUI.
- Packed preview image composition from up to 9 unique input files, with a short `{plates} P` label.
- Combined mode: all input rows become one packed 3MF. - Combined mode: all input rows become one packed 3MF.
- Individual batch mode: every input row becomes its own packed 3MF. - Individual batch mode: every input row becomes its own packed 3MF.
- zlib-ng Deflate ZIP compression level, defaulting to Level 7.
- GUI settings are saved to the program folder as `settings.json`. - GUI settings are saved to the program folder as `settings.json`.
## Installation for source use ## Installation for source use
Python 3.10 or newer is recommended. Python 3.10 or newer is recommended.
Install dependency: Install dependencies:
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
Current runtime dependencies:
| Dependency | Used for |
|---|---|
| `PySide6` | GUI application, drag-and-drop table, file dialogs, thumbnail preview, and settings UI |
| `Pillow` | Reading and composing PNG preview images inside the output 3MF, including the `{plates} P` label |
| `zlib-ng` | Required ZIP Deflate backend for writing compressed 3MF files and honoring the compression level |
No Java runtime, Bambu Studio SDK, external archive tool, or encrypted/template decoder is required by the current Python source version.
Run the GUI: Run the GUI:
```bash ```bash
@@ -57,6 +77,105 @@ or
python run_cli.py python run_cli.py
``` ```
Show the version:
```bash
python -m a1_swap_mod_packer.cli --version
```
## Build Windows executables
The repository includes a Windows build script:
```cmd
build_win.cmd
```
The script uses Nuitka onefile mode and creates this portable release folder:
```text
build/onefile/
```
Expected output:
```text
build/onefile/
a1packer.exe
a1packer-cli.exe
gcode_patches.ini
swap_gcode/
settings.json Optional; created by the GUI after settings are saved
```
External resources are intentionally kept outside the executables. Do not pack these files into the onefile binary:
```text
swap_gcode/
gcode_patches.ini
settings.json
```
The application resolves these paths from the executable folder when running as a packaged exe.
### Windows build requirements
Tested build environment:
- Windows 10.
- Python 3.10 or newer.
- A virtual environment at `.venv/`.
- Project runtime dependencies from `requirements.txt`.
- Nuitka with onefile support.
- Microsoft C++ Build Tools / Visual Studio Build Tools with the MSVC compiler and Windows SDK.
Install the Python dependencies in a fresh virtual environment:
```cmd
py -3.12 -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]"
```
Then run:
```cmd
build_win.cmd
```
The build script performs two Nuitka builds:
- GUI: `run_gui.py` -> `build/onefile/a1packer.exe`
- CLI: `run_cli.py` -> `build/onefile/a1packer-cli.exe`
The GUI build enables the PySide6 plugin and includes the Qt plugin groups needed by the current interface:
```text
platforms,imageformats,styles,iconengines
```
### After building
Before publishing the folder, verify the CLI can see the external resources:
```cmd
build\onefile\a1packer-cli.exe --version
build\onefile\a1packer-cli.exe list-swap-gcode
```
The second command should list files from:
```text
build/onefile/swap_gcode/
```
Also start `build/onefile/a1packer.exe` once and confirm that:
- The Swap G-code dropdown lists the copied templates.
- Editing GUI options creates or updates `build/onefile/settings.json`.
- The app still runs if `settings.json` is absent before first launch.
## Swap G-code templates ## Swap G-code templates
The GUI automatically scans this fixed folder: The GUI automatically scans this fixed folder:
@@ -65,7 +184,16 @@ The GUI automatically scans this fixed folder:
swap_gcode/ swap_gcode/
``` ```
Plain UTF-8 G-code files are read directly. Plain UTF-8 text files are read directly. The current scanner accepts these suffixes:
```text
.gcode
.nc
.ngc
.txt
```
The current source version does not decode encrypted or vendor-template G-code archives.
In the GUI: In the GUI:
@@ -138,6 +266,8 @@ If the source 3MF lacks this metadata, the GUI shows `Unknown`.
The summary line under the input list shows the total plate count, estimated time, and filament for the current table. The summary line under the input list shows the total plate count, estimated time, and filament for the current table.
The right-side thumbnail panel shows the selected input file's active plate preview when one is available in the 3MF.
### Build 3MF button ### Build 3MF button
The **Build 3MF** button is placed below the input list on the right side for fast batch operation. The **Build 3MF** button is placed below the input list on the right side for fast batch operation.
@@ -231,6 +361,21 @@ Options:
- **Sum prediction and filament** - **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. 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.
### Preview image handling
By default, the output 3MF keeps the base archive preview members for the active output plate and rewrites those PNG previews with:
- a composite of up to 9 unique input-file preview images;
- a short green label such as `5 P`, applied once to the final composite.
If preview images are missing or cannot be read, the packer keeps the available base preview and still tries to apply the short plate label.
The CLI can disable preview rewriting with:
```bash
--no-preview-label
```
### Batch mode ### Batch mode
#### Combined mode #### Combined mode
@@ -276,6 +421,8 @@ Output:
This is intended for quickly converting a large batch of independent single-plate 3MF files into multi-copy SwapMod packs. This is intended for quickly converting a large batch of independent single-plate 3MF files into multi-copy SwapMod packs.
The GUI builds these independent outputs in parallel, capped by CPU count and a small safety limit.
It does **not** combine all inputs into one file. It does **not** combine all inputs into one file.
### Input handling ### Input handling
@@ -348,6 +495,7 @@ Saved options include:
- Final swap switch. - Final swap switch.
- G-code patch switch. - G-code patch switch.
- Metadata mode. - Metadata mode.
- ZIP compression level.
- Individual batch mode. - Individual batch mode.
- Input handling options. - Input handling options.
- Output directory. - Output directory.
@@ -355,6 +503,12 @@ Saved options include:
## CLI examples ## CLI examples
List available Swap G-code files:
```bash
python -m a1_swap_mod_packer.cli list-swap-gcode
```
Build one source with five copies: Build one source with five copies:
```bash ```bash
@@ -397,3 +551,51 @@ python -m a1_swap_mod_packer.cli build \
-o "5 Plates - A.3mf" -o "5 Plates - A.3mf"
``` ```
Build positional inputs with the same copy count:
```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"
```
Use an explicit Swap G-code folder and ZIP compression level:
```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"
```
Disable final swap and preview label rewriting:
```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"
```
Useful CLI options:
| Option | Meaning |
|---|---|
| `--version` | Print the current version |
| `list-swap-gcode` | List files found in the Swap G-code folder |
| `--item PATH COPIES` | Add one input with its own copy count; can be repeated |
| positional `inputs` + `--copies N` | Add multiple inputs with the same copy count |
| `--swap-gcode-dir DIR` | Use a custom Swap G-code folder |
| `--no-bed-cooldown` | Do not insert `M190` before the swap block |
| `--no-swap-after-final` | Do not run the swap block after the final plate |
| `--line-ending lf|crlf` | Choose generated G-code line endings; default is `crlf` |
| `--zip-level 1-9` | zlib-ng Deflate compression level; default is `7` |
| `--no-preview-label` | Do not rewrite the preview image label/composite |
| `--no-gcode-patches` | Do not apply rules from `gcode_patches.ini` |
+2 -1
View File
@@ -1,2 +1,3 @@
__version__ = "0.4.0"
APP_NAME = "A1 Swap Mod Packer" APP_NAME = "A1 Swap Mod Packer"
__version__ = "0.5.0"
APP_TITLE = f"{APP_NAME} v{__version__}"
+90
View File
@@ -0,0 +1,90 @@
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:
if task_count <= 0:
return 0
if max_workers is not None:
return min(task_count, max(1, int(max_workers)))
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:
return build_packed_3mf([task.job], task.options)
def _run_individual_batch_serial(
tasks: Sequence[IndividualBuildTask],
worker_count: int,
) -> 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:
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_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)))
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,
)
+271 -14
View File
@@ -2,23 +2,33 @@ from __future__ import annotations
import hashlib import hashlib
import os import os
import re
import shutil import shutil
import tempfile import tempfile
import zipfile import zipfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
from .gcode import ( from .gcode import (
M73OffsetTemplate,
apply_line_ending, apply_line_ending,
apply_plate_number_offset,
build_swap_block, build_swap_block,
insert_swap_block, insert_swap_block,
normalize_newlines, normalize_newlines,
prepare_m73_offset_template,
read_swap_gcode_file, read_swap_gcode_file,
resolve_swap_gcode_path, resolve_swap_gcode_path,
) )
from .metadata import read_filament_metadata, read_slice_plate_metadata, safe_float, update_first_slice_info from .metadata import (
from .models import BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource read_filament_metadata,
read_model_settings_gcode_members,
read_slice_plate_metadata,
safe_float,
update_first_slice_info,
)
from .models import DEFAULT_ZIP_COMPRESS_LEVEL, BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource
from .patches import apply_gcode_patches, parse_patch_config from .patches import apply_gcode_patches, parse_patch_config
try: try:
@@ -28,6 +38,43 @@ except Exception: # pragma: no cover
ImageDraw = None ImageDraw = None
ImageFont = None ImageFont = None
PREVIEW_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)
MAX_COMPOSITE_PREVIEW_INPUTS = 9
MIN_ZIP_COMPRESS_LEVEL = 1
MAX_ZIP_COMPRESS_LEVEL = 9
@dataclass(frozen=True)
class _PreparedPlateGcode:
text: str
m73_template: M73OffsetTemplate | None = None
def normalized_zip_compress_level(level: int | None) -> int:
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:
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:
original_zlib = zipfile.zlib
zipfile.zlib = load_zlib_ng_module()
try:
yield
finally:
zipfile.zlib = original_zlib
def load_plate_sources(job: PlateJob) -> list[PlateSource]: def load_plate_sources(job: PlateJob) -> list[PlateSource]:
if job.copies < 1: if job.copies < 1:
@@ -81,11 +128,40 @@ def process_plate_gcode(
swap_gcode_text: str, swap_gcode_text: str,
patch_config: GcodePatchConfig, patch_config: GcodePatchConfig,
) -> str: ) -> str:
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:
text = normalize_newlines(source.gcode_text) text = normalize_newlines(source.gcode_text)
if options.apply_gcode_patches: if options.apply_gcode_patches:
text = apply_gcode_patches(text, patch_config) text = apply_gcode_patches(text, patch_config)
if options.show_plate_number: m73_template = prepare_m73_offset_template(text) if options.show_plate_number else None
text = apply_plate_number_offset(text, plate_number) 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:
text = prepared.text
if options.show_plate_number and prepared.m73_template is not None:
text = prepared.m73_template.apply(plate_number)
should_swap = options.swap_after_final or plate_number < total_plates should_swap = options.swap_after_final or plate_number < total_plates
if should_swap: if should_swap:
swap_block = build_swap_block(swap_gcode_text, options.cool_bed_temp, options.wait_after_eject_seconds) swap_block = build_swap_block(swap_gcode_text, options.cool_bed_temp, options.wait_after_eject_seconds)
@@ -93,6 +169,18 @@ def process_plate_gcode(
return text.rstrip("\n") + "\n" return text.rstrip("\n") + "\n"
def _plate_gcode_cache_key(source: PlateSource) -> tuple[Path, str]:
return (source.source_3mf.resolve(strict=False), source.member_name)
def save_png_bytes(image: object) -> bytes:
import io
output = io.BytesIO()
image.save(output, format="PNG")
return output.getvalue()
def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> bytes: def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> bytes:
if Image is None or ImageDraw is None: if Image is None or ImageDraw is None:
return png_bytes return png_bytes
@@ -123,14 +211,168 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b
x = margin x = margin
y = image.height - margin - (text_bbox[3] - text_bbox[1]) y = image.height - margin - (text_bbox[3] - text_bbox[1])
draw.text((x, y), label, font=font, fill=(0, 255, 0, 255)) draw.text((x, y), label, font=font, fill=(0, 255, 0, 255))
output = io.BytesIO() return save_png_bytes(image)
image.save(output, format="PNG")
return output.getvalue()
def preview_member_name_for_gcode_member(gcode_member: str, small: bool = False) -> str | None:
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]:
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]:
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:
names = set(archive.namelist())
active_member = resolve_output_gcode_member(archive, source.member_name)
preferred = preview_member_name_for_gcode_member(active_member, small)
if preferred in names:
return preferred
alternate = preview_member_name_for_gcode_member(active_member, not small)
if alternate in names:
return alternate
candidates = [
name
for name in names
if PREVIEW_MEMBER_RE.match(name) and name.lower().endswith("_small.png") == small
]
if not candidates:
candidates = [name for name in names if PREVIEW_MEMBER_RE.match(name)]
if not candidates:
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:
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]:
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:
resized = image.copy()
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:
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)
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:
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]:
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}
def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sources: list[PlateSource], options: BuildOptions) -> str: def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sources: list[PlateSource], options: BuildOptions) -> str:
md5 = hashlib.md5(gcode_bytes).hexdigest() md5 = hashlib.md5(gcode_bytes).hexdigest()
with zipfile.ZipFile(base_3mf, "r") as src, zipfile.ZipFile(output_3mf, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=1) as dst: compress_level = normalized_zip_compress_level(options.zip_compress_level)
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:
gcode_member = resolve_output_gcode_member(src, sources[0].member_name)
preview_members = preview_members_for_gcode_member(gcode_member)
for item in src.infolist(): for item in src.infolist():
name = item.filename name = item.filename
if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name): if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name):
@@ -138,11 +380,11 @@ def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sourc
data = src.read(name) data = src.read(name)
if name == "Metadata/slice_info.config": if name == "Metadata/slice_info.config":
data = update_first_slice_info(data, sources, options) data = update_first_slice_info(data, sources, options)
elif options.add_preview_label and name in {"Metadata/plate_1.png", "Metadata/plate_1_small.png"}: elif options.add_preview_label and name in preview_members:
data = update_preview_label(data, f"{len(sources)} plates", small=name.endswith("_small.png")) data = compose_preview_image(data, sources, f"{len(sources)} P", small=name.endswith("_small.png"))
dst.writestr(item, data) dst.writestr(item, data)
dst.writestr("Metadata/plate_1.gcode", gcode_bytes) dst.writestr(gcode_member, gcode_bytes)
dst.writestr("Metadata/plate_1.gcode.md5", md5) dst.writestr(f"{gcode_member}.md5", md5)
return md5 return md5
@@ -152,8 +394,23 @@ def build_packed_3mf(jobs: list[PlateJob], options: BuildOptions) -> BuildResult
swap_gcode_text = read_swap_gcode_file(swap_gcode_path) swap_gcode_text = read_swap_gcode_file(swap_gcode_path)
patch_config = parse_patch_config() if options.apply_gcode_patches else GcodePatchConfig(rules=()) patch_config = parse_patch_config() if options.apply_gcode_patches else GcodePatchConfig(rules=())
processed: list[str] = [] processed: list[str] = []
prepared_gcode_cache: dict[tuple[Path, str], _PreparedPlateGcode] = {}
for plate_number, source in enumerate(sources, start=1): for plate_number, source in enumerate(sources, start=1):
processed.append(process_plate_gcode(source, plate_number, len(sources), options, swap_gcode_text, patch_config)) cache_key = _plate_gcode_cache_key(source)
prepared_text = prepared_gcode_cache.get(cache_key)
if prepared_text is None:
prepared_text = _prepare_plate_gcode(source, options, patch_config)
prepared_gcode_cache[cache_key] = prepared_text
processed.append(
_process_prepared_plate_gcode(
prepared_text,
plate_number,
len(sources),
options,
swap_gcode_text,
patch_config,
)
)
final_gcode = "\n".join(item.rstrip("\n") for item in processed) + "\n" final_gcode = "\n".join(item.rstrip("\n") for item in processed) + "\n"
gcode_bytes = apply_line_ending(final_gcode, options.line_ending) gcode_bytes = apply_line_ending(final_gcode, options.line_ending)
output_3mf = options.output_3mf output_3mf = options.output_3mf
+10 -3
View File
@@ -4,9 +4,10 @@ import argparse
import sys import sys
from pathlib import Path from pathlib import Path
from . import APP_NAME from . import APP_NAME, APP_TITLE, __version__
from .core import ( from .core import (
BuildOptions, BuildOptions,
DEFAULT_ZIP_COMPRESS_LEVEL,
PlateJob, PlateJob,
build_packed_3mf, build_packed_3mf,
list_swap_gcode_files, list_swap_gcode_files,
@@ -46,6 +47,7 @@ def build_command(args: argparse.Namespace) -> int:
add_preview_label=not args.no_preview_label, add_preview_label=not args.no_preview_label,
apply_gcode_patches=not args.no_gcode_patches, apply_gcode_patches=not args.no_gcode_patches,
swap_gcode_dir=Path(args.swap_gcode_dir) if args.swap_gcode_dir else None, 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) result = build_packed_3mf(jobs, options)
print(f"Output: {result.output_3mf}") print(f"Output: {result.output_3mf}")
@@ -70,7 +72,11 @@ def list_swap_gcode_command(args: argparse.Namespace) -> int:
def create_parser() -> argparse.ArgumentParser: def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="a1-swap-mod-packer", description="Pack repeated Bambu A1 SwapMod plates into one 3MF job.") parser = argparse.ArgumentParser(
prog="a1-swap-mod-packer",
description=f"{APP_TITLE} - Pack repeated Bambu A1 SwapMod plates into one 3MF job.",
)
parser.add_argument("--version", action="version", version=f"{APP_NAME} {__version__}")
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
build = subparsers.add_parser("build", help="Build a packed 3MF file.") build = subparsers.add_parser("build", help="Build a packed 3MF file.")
@@ -87,7 +93,8 @@ def create_parser() -> argparse.ArgumentParser:
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("--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("--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("--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("--zip-level", type=int, choices=range(1, 10), default=DEFAULT_ZIP_COMPRESS_LEVEL, metavar="1-9", help="zlib-ng Deflate compression level for the output 3MF. Default: 7.")
build.add_argument("--no-preview-label", action="store_true", help="Do not rewrite the preview image label/composite.")
build.add_argument("--no-gcode-patches", action="store_true", help=f"Do not apply editable patches from {default_patch_config_path()}.") build.add_argument("--no-gcode-patches", action="store_true", help=f"Do not apply editable patches from {default_patch_config_path()}.")
build.set_defaults(func=build_command) build.set_defaults(func=build_command)
+2
View File
@@ -33,6 +33,7 @@ from .metadata import (
) )
from .models import ( from .models import (
DEFAULT_INSERT_BEFORE_MARKER, DEFAULT_INSERT_BEFORE_MARKER,
DEFAULT_ZIP_COMPRESS_LEVEL,
BuildOptions, BuildOptions,
BuildResult, BuildResult,
BuildSummary, BuildSummary,
@@ -59,6 +60,7 @@ from .planning import (
__all__ = [ __all__ = [
"DEFAULT_INSERT_BEFORE_MARKER", "DEFAULT_INSERT_BEFORE_MARKER",
"DEFAULT_OUTPUT_PATTERN", "DEFAULT_OUTPUT_PATTERN",
"DEFAULT_ZIP_COMPRESS_LEVEL",
"GCODE_MEMBER_RE", "GCODE_MEMBER_RE",
"M73_RE", "M73_RE",
"MD5_MEMBER_RE", "MD5_MEMBER_RE",
+29 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .models import DEFAULT_INSERT_BEFORE_MARKER, LineEnding from .models import DEFAULT_INSERT_BEFORE_MARKER, LineEnding
@@ -73,13 +74,37 @@ def format_filament(weight_grams: float | None, used_m: float | None = None) ->
return " / ".join(parts) return " / ".join(parts)
def apply_plate_number_offset(gcode: str, plate_number: int) -> str: @dataclass(frozen=True)
class M73OffsetTemplate:
segments: tuple[str, ...]
base_remaining_minutes: tuple[int, ...]
def apply(self, plate_number: int) -> str:
if not self.base_remaining_minutes:
return self.segments[0]
minute_offset = plate_number * 100 * 60 minute_offset = plate_number * 100 * 60
parts: list[str] = []
for index, remaining_minutes in enumerate(self.base_remaining_minutes):
parts.append(self.segments[index])
parts.append(str(remaining_minutes + minute_offset))
parts.append(self.segments[-1])
return "".join(parts)
def replace(match: re.Match[str]) -> str:
return f"{match.group(1)}{int(match.group(2)) + minute_offset}{match.group(3)}"
return M73_RE.sub(replace, gcode) def prepare_m73_offset_template(gcode: str) -> M73OffsetTemplate:
segments: list[str] = []
base_remaining_minutes: list[int] = []
last_index = 0
for match in M73_RE.finditer(gcode):
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:
return prepare_m73_offset_template(gcode).apply(plate_number)
def build_swap_block(swap_gcode: str, cool_bed_temp: int | None, wait_seconds: int) -> str: def build_swap_block(swap_gcode: str, cool_bed_temp: int | None, wait_seconds: int) -> str:
+433 -74
View File
@@ -2,8 +2,11 @@ from __future__ import annotations
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import zipfile
from multiprocessing import freeze_support
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
@@ -11,8 +14,8 @@ if sys.platform.startswith("win") and "QT_QPA_PLATFORM" not in os.environ:
os.environ["QT_QPA_PLATFORM"] = "windows:fontengine=freetype" os.environ["QT_QPA_PLATFORM"] = "windows:fontengine=freetype"
try: try:
from PySide6.QtCore import QEvent, Qt, QUrl from PySide6.QtCore import QEasingCurve, QEvent, QPropertyAnimation, Qt, QTimer, QUrl
from PySide6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QFont from PySide6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QFont, QKeyEvent, QPixmap
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QAbstractItemView, QAbstractItemView,
@@ -20,6 +23,7 @@ try:
QComboBox, QComboBox,
QFileDialog, QFileDialog,
QFormLayout, QFormLayout,
QGraphicsOpacityEffect,
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
QHeaderView, QHeaderView,
@@ -29,26 +33,32 @@ try:
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QSpinBox, QSpinBox,
QStyle,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
QTextEdit, QTextEdit,
QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
except ImportError as exc: # pragma: no cover except ImportError as exc: # pragma: no cover
raise SystemExit("PySide6 is required to run the GUI. Install it with: pip install PySide6") from exc raise SystemExit("PySide6 is required to run the GUI. Install it with: pip install PySide6") from exc
from . import APP_NAME from . import APP_NAME, APP_TITLE
from .batch import IndividualBuildTask, individual_batch_worker_count, run_individual_batch_builds
from .core import ( from .core import (
BuildOptions, BuildOptions,
DEFAULT_ZIP_COMPRESS_LEVEL,
PlateJob, PlateJob,
ThreeMfSummary, ThreeMfSummary,
build_packed_3mf, build_packed_3mf,
format_duration, format_duration,
format_filament, format_filament,
list_gcode_members,
list_swap_gcode_files, list_swap_gcode_files,
read_3mf_summary, read_3mf_summary,
) )
from .builder import preview_members_for_gcode_member, resolve_output_gcode_member
from .paths import default_patch_config_path, default_swap_gcode_dir, user_settings_path from .paths import default_patch_config_path, default_swap_gcode_dir, user_settings_path
from .planning import ( from .planning import (
DEFAULT_OUTPUT_PATTERN, DEFAULT_OUTPUT_PATTERN,
@@ -61,17 +71,145 @@ from .planning import (
) )
SUMMARY_ROLE = Qt.UserRole + 100 SUMMARY_ROLE = Qt.UserRole + 100
PATH_ROLE = Qt.UserRole + 101
ORDER_COLUMN = 0
FILE_COLUMN = 1
COPIES_COLUMN = 2
TIME_COLUMN = 3
FILAMENT_COLUMN = 4
PREVIEW_IMAGE_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)
def preview_image_sort_key(member_name: str) -> tuple[int, int, str]:
match = PREVIEW_IMAGE_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 first_preview_image_member(member_names: list[str], gcode_member: str | None = None) -> str | None:
available_members = set(member_names)
if gcode_member is not None:
candidates = [name for name in preview_members_for_gcode_member(gcode_member) if name in available_members]
if candidates:
return sorted(candidates, key=preview_image_sort_key)[0]
candidates = [name for name in member_names if PREVIEW_IMAGE_RE.match(name)]
if not candidates:
candidates = [
name
for name in member_names
if name.lower().startswith("metadata/") and name.lower().endswith(".png")
]
if not candidates:
return None
return sorted(candidates, key=preview_image_sort_key)[0]
class SuccessToast(QLabel):
FADE_MS = 1000
HOLD_MS = 5000
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.setAlignment(Qt.AlignCenter)
self.setWordWrap(True)
self.setAttribute(Qt.WA_TransparentForMouseEvents)
self.setStyleSheet(
"""
QLabel {
background: #f4fff6;
color: #183f22;
border: 3px solid #2e7d32;
border-radius: 8px;
padding: 20px 32px;
font-size: 18px;
font-weight: 700;
}
"""
)
self._opacity_effect = QGraphicsOpacityEffect(self)
self.setGraphicsEffect(self._opacity_effect)
self._fade_in = QPropertyAnimation(self._opacity_effect, b"opacity", self)
self._fade_in.setDuration(self.FADE_MS)
self._fade_in.setEasingCurve(QEasingCurve.InOutQuad)
self._fade_out = QPropertyAnimation(self._opacity_effect, b"opacity", self)
self._fade_out.setDuration(self.FADE_MS)
self._fade_out.setEasingCurve(QEasingCurve.InOutQuad)
self._fade_out.finished.connect(self.hide)
self._hold_timer = QTimer(self)
self._hold_timer.setSingleShot(True)
self._hold_timer.timeout.connect(self._start_fade_out)
parent.installEventFilter(self)
self.hide()
def show_message(self, message: str) -> None:
self._hold_timer.stop()
self._fade_in.stop()
self._fade_out.stop()
self.setText(message)
self._fit_to_parent()
self._position()
self._opacity_effect.setOpacity(0.0)
self.show()
self.raise_()
self._fade_in.setStartValue(0.0)
self._fade_in.setEndValue(1.0)
self._fade_in.start()
self._hold_timer.start(self.FADE_MS + self.HOLD_MS)
def eventFilter(self, watched: object, event: QEvent) -> bool:
if watched is self.parentWidget() and event.type() == QEvent.Resize and self.isVisible():
self._fit_to_parent()
self._position()
return super().eventFilter(watched, event)
def _fit_to_parent(self) -> None:
parent = self.parentWidget()
if parent is None:
return
self.setMaximumWidth(max(360, min(720, parent.width() - 48)))
self.adjustSize()
def _position(self) -> None:
parent = self.parentWidget()
if parent is None:
return
margin = 24
x = max(margin, (parent.width() - self.width()) // 2)
y = max(margin, parent.height() - self.height() - margin)
self.move(x, y)
def _start_fade_out(self) -> None:
self._fade_out.stop()
self._fade_out.setStartValue(self._opacity_effect.opacity())
self._fade_out.setEndValue(0.0)
self._fade_out.start()
class DropTableWidget(QTableWidget): class DropTableWidget(QTableWidget):
def __init__(self, on_files_dropped: Callable[[list[Path]], None], parent: QWidget | None = None) -> None: def __init__(
self,
on_files_dropped: Callable[[list[Path]], None],
on_delete_pressed: Callable[[], None],
parent: QWidget | None = None,
) -> None:
super().__init__(parent) super().__init__(parent)
self.on_files_dropped = on_files_dropped self.on_files_dropped = on_files_dropped
self.on_delete_pressed = on_delete_pressed
self.setAcceptDrops(True) self.setAcceptDrops(True)
self.viewport().setAcceptDrops(True) self.viewport().setAcceptDrops(True)
self.viewport().installEventFilter(self) self.viewport().installEventFilter(self)
self.setDragDropMode(QAbstractItemView.DropOnly) self.setDragDropMode(QAbstractItemView.DropOnly)
def keyPressEvent(self, event: QKeyEvent) -> None:
if event.key() == Qt.Key_Delete and self.selectedIndexes():
self.on_delete_pressed()
event.accept()
return
super().keyPressEvent(event)
def eventFilter(self, watched: object, event: QEvent) -> bool: def eventFilter(self, watched: object, event: QEvent) -> bool:
if watched is self.viewport(): if watched is self.viewport():
if event.type() in {QEvent.DragEnter, QEvent.DragMove}: if event.type() in {QEvent.DragEnter, QEvent.DragMove}:
@@ -130,12 +268,13 @@ class DropTableWidget(QTableWidget):
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setWindowTitle(APP_NAME) self.setWindowTitle(APP_TITLE)
self.resize(1180, 860) self.resize(960, 860)
self.setAcceptDrops(True) self.setAcceptDrops(True)
self._updating_table = False self._updating_table = False
self._loading_settings = True self._loading_settings = True
self._settings = self.load_settings() self._settings = self.load_settings()
self._shared_growth_enabled = False
self.build_ui() self.build_ui()
self.load_swap_gcode_to_combo() self.load_swap_gcode_to_combo()
self.restore_settings_to_ui() self.restore_settings_to_ui()
@@ -162,31 +301,63 @@ class MainWindow(QMainWindow):
def build_ui(self) -> None: def build_ui(self) -> None:
central = QWidget(self) central = QWidget(self)
root = QVBoxLayout(central) root = QVBoxLayout(central)
self.root_layout = root
file_group = QGroupBox("Input 3MF files") file_group = QGroupBox("Input 3MF files")
self.file_group = file_group
file_layout = QVBoxLayout(file_group) file_layout = QVBoxLayout(file_group)
self.table = DropTableWidget(self.add_paths) file_body = QHBoxLayout()
self.table.setColumnCount(4) table_layout = QVBoxLayout()
self.table.setHorizontalHeaderLabels(["3MF path", "Copies", "Time", "Filament"]) self.table = DropTableWidget(self.add_paths, self.remove_selected)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.setColumnCount(5)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.table.setHorizontalHeaderLabels(["Order", "3MF file", "Copies", "Time", "Filament"])
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.table.horizontalHeader().setSectionResizeMode(ORDER_COLUMN, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) self.table.horizontalHeader().setSectionResizeMode(FILE_COLUMN, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(COPIES_COLUMN, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(TIME_COLUMN, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(FILAMENT_COLUMN, QHeaderView.ResizeToContents)
self.table.setColumnWidth(ORDER_COLUMN, 104)
self.table.verticalHeader().setDefaultSectionSize(38)
self.table.verticalHeader().hide()
self.table.setMinimumHeight(220)
self.table.setSelectionBehavior(QTableWidget.SelectRows) self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setAlternatingRowColors(True) self.table.setAlternatingRowColors(True)
self.table.setToolTip("Drag .3mf files or folders here. Folders add all top-level .3mf files.") self.table.setToolTip("Drag .3mf files or folders here. Folders add all top-level .3mf files.")
self.table.itemChanged.connect(self.on_table_item_changed) self.table.itemChanged.connect(self.on_table_item_changed)
file_layout.addWidget(self.table) self.table.itemSelectionChanged.connect(self.update_thumbnail_preview)
table_layout.addWidget(self.table)
self.total_summary_label = QLabel("Total: 0 plates | Time: Unknown | Filament: Unknown") self.total_summary_label = QLabel("Total: 0 plates | Time: Unknown | Filament: Unknown")
file_layout.addWidget(self.total_summary_label) table_layout.addWidget(self.total_summary_label)
file_body.addLayout(table_layout, 1)
preview_group = QGroupBox("Selected thumbnail")
preview_layout = QVBoxLayout(preview_group)
self.thumbnail_label = QLabel("Select an input file")
self.thumbnail_label.setAlignment(Qt.AlignCenter)
self.thumbnail_label.setFixedSize(280, 220)
self.thumbnail_label.setStyleSheet(
"""
QLabel {
background: #f8f8f8;
border: 1px solid #cfcfcf;
color: #666;
}
"""
)
self.thumbnail_name_label = QLabel("")
self.thumbnail_name_label.setWordWrap(True)
self.thumbnail_name_label.setMaximumWidth(280)
preview_layout.addWidget(self.thumbnail_label)
preview_layout.addWidget(self.thumbnail_name_label)
preview_layout.addStretch(1)
file_body.addWidget(preview_group)
file_layout.addLayout(file_body)
file_buttons = QHBoxLayout() file_buttons = QHBoxLayout()
add_button = QPushButton("Add 3MF") add_button = QPushButton("Add 3MF")
remove_button = QPushButton("Remove") remove_button = QPushButton("Remove")
remove_all_button = QPushButton("Remove All") remove_all_button = QPushButton("Remove All")
up_button = QPushButton("Move Up")
down_button = QPushButton("Move Down")
apply_default_copies_button = QPushButton("Apply Default Copies to Selected") apply_default_copies_button = QPushButton("Apply Default Copies to Selected")
self.build_button = QPushButton("Build 3MF") self.build_button = QPushButton("Build 3MF")
self.build_button.setMinimumHeight(64) self.build_button.setMinimumHeight(64)
@@ -199,26 +370,28 @@ class MainWindow(QMainWindow):
add_button.clicked.connect(self.add_files) add_button.clicked.connect(self.add_files)
remove_button.clicked.connect(self.remove_selected) remove_button.clicked.connect(self.remove_selected)
remove_all_button.clicked.connect(self.remove_all) remove_all_button.clicked.connect(self.remove_all)
up_button.clicked.connect(lambda: self.move_selected(-1))
down_button.clicked.connect(lambda: self.move_selected(1))
apply_default_copies_button.clicked.connect(self.apply_default_copies_to_selected) apply_default_copies_button.clicked.connect(self.apply_default_copies_to_selected)
self.build_button.clicked.connect(self.build_output) self.build_button.clicked.connect(self.build_output)
for button in (add_button, remove_button, remove_all_button, up_button, down_button, apply_default_copies_button): for button in (add_button, remove_button, remove_all_button, apply_default_copies_button):
button.setMinimumHeight(44)
file_buttons.addWidget(button) file_buttons.addWidget(button)
file_buttons.addStretch(1) file_buttons.addStretch(1)
file_buttons.addWidget(self.build_button) file_buttons.addWidget(self.build_button)
file_layout.addLayout(file_buttons) file_layout.addLayout(file_buttons)
root.addWidget(file_group) root.addWidget(file_group, 1)
options_group = QGroupBox("Packing options") options_group = QGroupBox("Packing options")
options_layout = QFormLayout(options_group) options_layout = QFormLayout(options_group)
self.swap_gcode_combo = QComboBox() self.swap_gcode_combo = QComboBox()
self.swap_gcode_combo.setMinimumWidth(520) self.swap_gcode_combo.setMinimumWidth(260)
self.swap_gcode_combo.setMaximumWidth(440)
swap_gcode_row = QHBoxLayout() swap_gcode_row = QHBoxLayout()
refresh_button = QPushButton("Refresh") refresh_button = QPushButton("Refresh")
open_folder_button = QPushButton("Open Folder") open_folder_button = QPushButton("Open Folder")
refresh_button.setFixedWidth(refresh_button.sizeHint().width())
open_folder_button.setFixedWidth(open_folder_button.sizeHint().width())
refresh_button.clicked.connect(self.load_swap_gcode_to_combo) refresh_button.clicked.connect(self.load_swap_gcode_to_combo)
open_folder_button.clicked.connect(self.open_swap_gcode_folder) open_folder_button.clicked.connect(self.open_swap_gcode_folder)
swap_gcode_row.addWidget(self.swap_gcode_combo, 1) swap_gcode_row.addWidget(self.swap_gcode_combo, 1)
@@ -229,6 +402,7 @@ class MainWindow(QMainWindow):
self.default_copies_spin = QSpinBox() self.default_copies_spin = QSpinBox()
self.default_copies_spin.setRange(1, 9999) self.default_copies_spin.setRange(1, 9999)
self.default_copies_spin.setValue(1) self.default_copies_spin.setValue(1)
self.default_copies_spin.setFixedWidth(96)
options_layout.addRow("Default copies for new inputs", self.default_copies_spin) options_layout.addRow("Default copies for new inputs", self.default_copies_spin)
self.bed_cooldown_check = QCheckBox("Wait for bed cooldown") self.bed_cooldown_check = QCheckBox("Wait for bed cooldown")
@@ -247,6 +421,7 @@ class MainWindow(QMainWindow):
self.wait_spin.setRange(0, 3600) self.wait_spin.setRange(0, 3600)
self.wait_spin.setValue(45) self.wait_spin.setValue(45)
self.wait_spin.setSuffix(" s") self.wait_spin.setSuffix(" s")
self.wait_spin.setFixedWidth(96)
options_layout.addRow("Wait after ejection", self.wait_spin) options_layout.addRow("Wait after ejection", self.wait_spin)
self.show_plate_number_check = QCheckBox("Show current plate in the hundreds digit of remaining time") self.show_plate_number_check = QCheckBox("Show current plate in the hundreds digit of remaining time")
@@ -257,23 +432,31 @@ class MainWindow(QMainWindow):
self.swap_final_check.setChecked(True) self.swap_final_check.setChecked(True)
options_layout.addRow("Final swap", self.swap_final_check) options_layout.addRow("Final swap", self.swap_final_check)
self.patch_check = QCheckBox("Apply editable G-code patches from gcode_patches.ini") self.patch_check = QCheckBox("Apply editable G-code patches")
self.patch_check.setToolTip("Uses gcode_patches.ini")
self.patch_check.setChecked(True) self.patch_check.setChecked(True)
patch_row = QHBoxLayout() patch_row = QHBoxLayout()
patch_path_label = QLabel(str(default_patch_config_path()))
patch_path_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
open_patch_button = QPushButton("Open Config") open_patch_button = QPushButton("Open Config")
open_patch_button.clicked.connect(self.open_patch_config) open_patch_button.clicked.connect(self.open_patch_config)
patch_row.addWidget(self.patch_check) patch_row.addWidget(self.patch_check)
patch_row.addWidget(patch_path_label, 1)
patch_row.addWidget(open_patch_button) patch_row.addWidget(open_patch_button)
patch_row.addStretch(1)
options_layout.addRow("G-code patches", patch_row) options_layout.addRow("G-code patches", patch_row)
self.metadata_combo = QComboBox() self.metadata_combo = QComboBox()
self.metadata_combo.addItem("Keep source prediction and weight", "source") self.metadata_combo.addItem("Keep source prediction and weight", "source")
self.metadata_combo.addItem("Sum prediction and filament", "sum") self.metadata_combo.addItem("Sum prediction and filament", "sum")
self.metadata_combo.setFixedWidth(260)
options_layout.addRow("3MF metadata", self.metadata_combo) options_layout.addRow("3MF metadata", self.metadata_combo)
self.zip_level_combo = QComboBox()
for level in range(1, 10):
self.zip_level_combo.addItem(f"Level {level}", level)
self.zip_level_combo.setCurrentIndex(DEFAULT_ZIP_COMPRESS_LEVEL - 1)
self.zip_level_combo.setFixedWidth(120)
self.zip_level_combo.setToolTip("zlib-ng Deflate compression level for the output 3MF.")
options_layout.addRow("ZIP compression", self.zip_level_combo)
self.individual_batch_check = QCheckBox("Individual batch mode") self.individual_batch_check = QCheckBox("Individual batch mode")
self.individual_batch_check.setToolTip( self.individual_batch_check.setToolTip(
"Build each input row as a separate output file, using that row's copy count." "Build each input row as a separate output file, using that row's copy count."
@@ -296,7 +479,10 @@ class MainWindow(QMainWindow):
output_dir_row = QHBoxLayout() output_dir_row = QHBoxLayout()
self.output_dir_edit = QLineEdit() self.output_dir_edit = QLineEdit()
self.output_dir_edit.setPlaceholderText("Leave empty to write next to the input file") self.output_dir_edit.setPlaceholderText("Leave empty to write next to the input file")
self.output_dir_edit.setMinimumWidth(280)
self.output_dir_edit.setMaximumWidth(560)
browse_output_dir_button = QPushButton("Browse") browse_output_dir_button = QPushButton("Browse")
browse_output_dir_button.setFixedWidth(browse_output_dir_button.sizeHint().width())
browse_output_dir_button.clicked.connect(self.choose_output_dir) browse_output_dir_button.clicked.connect(self.choose_output_dir)
output_dir_row.addWidget(self.output_dir_edit, 1) output_dir_row.addWidget(self.output_dir_edit, 1)
output_dir_row.addWidget(browse_output_dir_button) output_dir_row.addWidget(browse_output_dir_button)
@@ -304,6 +490,8 @@ class MainWindow(QMainWindow):
self.output_name_edit = QLineEdit(DEFAULT_OUTPUT_PATTERN) self.output_name_edit = QLineEdit(DEFAULT_OUTPUT_PATTERN)
self.output_name_edit.setPlaceholderText("Use tokens such as {source}, {sources}, {plates}, {copies}, {date}, {time}") self.output_name_edit.setPlaceholderText("Use tokens such as {source}, {sources}, {plates}, {copies}, {date}, {time}")
self.output_name_edit.setMinimumWidth(280)
self.output_name_edit.setMaximumWidth(560)
filename_rule_row = QHBoxLayout() filename_rule_row = QHBoxLayout()
output_rule_help_button = QPushButton("?") output_rule_help_button = QPushButton("?")
output_rule_help_button.setFixedWidth(34) output_rule_help_button.setFixedWidth(34)
@@ -313,16 +501,33 @@ class MainWindow(QMainWindow):
filename_rule_row.addWidget(output_rule_help_button) filename_rule_row.addWidget(output_rule_help_button)
output_layout.addRow("Output filename rule", filename_rule_row) output_layout.addRow("Output filename rule", filename_rule_row)
self.output_preview_label = QLabel("Output path preview: -") self.output_preview_label = QLabel("-")
output_layout.addRow("Preview", self.output_preview_label) output_layout.addRow("Preview", self.output_preview_label)
root.addWidget(output_group) root.addWidget(output_group)
self.log = QTextEdit() self.log = QTextEdit()
self.log.setReadOnly(True) self.log.setReadOnly(True)
self.log.setMinimumHeight(130) self.log.setMinimumHeight(100)
root.addWidget(self.log) root.addWidget(self.log, 0)
self.setCentralWidget(central) self.setCentralWidget(central)
self.success_toast = SuccessToast(central)
self.update_vertical_growth_policy()
def update_vertical_growth_policy(self) -> None:
if not hasattr(self, "root_layout") or not hasattr(self, "file_group") or not hasattr(self, "log"):
return
shared_growth = self.height() >= 980
if shared_growth == self._shared_growth_enabled:
return
self._shared_growth_enabled = shared_growth
self.root_layout.setStretchFactor(self.file_group, 4 if shared_growth else 1)
self.root_layout.setStretchFactor(self.log, 1 if shared_growth else 0)
def resizeEvent(self, event: QEvent) -> None:
super().resizeEvent(event)
self.update_vertical_growth_policy()
self.update_thumbnail_preview()
def connect_option_signals(self) -> None: def connect_option_signals(self) -> None:
self.swap_gcode_combo.currentIndexChanged.connect(self.save_current_settings) self.swap_gcode_combo.currentIndexChanged.connect(self.save_current_settings)
@@ -334,6 +539,7 @@ class MainWindow(QMainWindow):
self.swap_final_check.stateChanged.connect(self.save_current_settings) self.swap_final_check.stateChanged.connect(self.save_current_settings)
self.patch_check.stateChanged.connect(self.save_current_settings) self.patch_check.stateChanged.connect(self.save_current_settings)
self.metadata_combo.currentIndexChanged.connect(self.save_current_settings) self.metadata_combo.currentIndexChanged.connect(self.save_current_settings)
self.zip_level_combo.currentIndexChanged.connect(self.save_current_settings)
self.individual_batch_check.stateChanged.connect(self.on_individual_batch_toggled) self.individual_batch_check.stateChanged.connect(self.on_individual_batch_toggled)
self.clear_after_build_check.stateChanged.connect(self.save_current_settings) self.clear_after_build_check.stateChanged.connect(self.save_current_settings)
self.skip_duplicates_check.stateChanged.connect(self.save_current_settings) self.skip_duplicates_check.stateChanged.connect(self.save_current_settings)
@@ -360,6 +566,13 @@ class MainWindow(QMainWindow):
metadata_index = self.metadata_combo.findData(metadata_mode) metadata_index = self.metadata_combo.findData(metadata_mode)
if metadata_index >= 0: if metadata_index >= 0:
self.metadata_combo.setCurrentIndex(metadata_index) self.metadata_combo.setCurrentIndex(metadata_index)
try:
zip_level = int(options.get("zip_compress_level", DEFAULT_ZIP_COMPRESS_LEVEL))
except (TypeError, ValueError):
zip_level = DEFAULT_ZIP_COMPRESS_LEVEL
zip_level_index = self.zip_level_combo.findData(min(9, max(1, zip_level)))
if zip_level_index >= 0:
self.zip_level_combo.setCurrentIndex(zip_level_index)
swap_gcode = options.get("swap_gcode") or self._settings.get("last_swap_gcode") swap_gcode = options.get("swap_gcode") or self._settings.get("last_swap_gcode")
if swap_gcode: if swap_gcode:
index = self.swap_gcode_combo.findData(str(swap_gcode)) index = self.swap_gcode_combo.findData(str(swap_gcode))
@@ -379,6 +592,7 @@ class MainWindow(QMainWindow):
"swap_after_final": self.swap_final_check.isChecked(), "swap_after_final": self.swap_final_check.isChecked(),
"apply_gcode_patches": self.patch_check.isChecked(), "apply_gcode_patches": self.patch_check.isChecked(),
"metadata_mode": self.metadata_combo.currentData(), "metadata_mode": self.metadata_combo.currentData(),
"zip_compress_level": int(self.zip_level_combo.currentData() or DEFAULT_ZIP_COMPRESS_LEVEL),
"individual_batch_mode": self.individual_batch_check.isChecked(), "individual_batch_mode": self.individual_batch_check.isChecked(),
"clear_after_build": self.clear_after_build_check.isChecked(), "clear_after_build": self.clear_after_build_check.isChecked(),
"skip_duplicates": self.skip_duplicates_check.isChecked(), "skip_duplicates": self.skip_duplicates_check.isChecked(),
@@ -475,6 +689,98 @@ class MainWindow(QMainWindow):
files, _ = QFileDialog.getOpenFileNames(self, "Add 3MF files", "", "3MF files (*.3mf);;All files (*)") files, _ = QFileDialog.getOpenFileNames(self, "Add 3MF files", "", "3MF files (*.3mf);;All files (*)")
self.add_paths([Path(file_name) for file_name in files]) self.add_paths([Path(file_name) for file_name in files])
def create_order_widget(self, row: int) -> QWidget:
widget = QWidget()
layout = QHBoxLayout(widget)
layout.setContentsMargins(4, 0, 4, 0)
layout.setSpacing(4)
number_label = QLabel(str(row + 1))
number_label.setAlignment(Qt.AlignCenter)
number_label.setMinimumWidth(24)
up_button = QToolButton()
up_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp))
up_button.setToolTip("Move up")
up_button.setAutoRaise(True)
up_button.setFixedSize(28, 28)
up_button.setEnabled(row > 0)
up_button.clicked.connect(lambda _checked=False, current_row=row: self.move_row(current_row, -1))
down_button = QToolButton()
down_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
down_button.setToolTip("Move down")
down_button.setAutoRaise(True)
down_button.setFixedSize(28, 28)
down_button.setEnabled(row < self.table.rowCount() - 1)
down_button.clicked.connect(lambda _checked=False, current_row=row: self.move_row(current_row, 1))
layout.addWidget(number_label)
layout.addWidget(up_button)
layout.addWidget(down_button)
return widget
def update_order_controls(self) -> None:
for row in range(self.table.rowCount()):
self.table.setCellWidget(row, ORDER_COLUMN, self.create_order_widget(row))
self.table.setCellWidget(row, COPIES_COLUMN, self.create_copies_spin(row, self.get_row_copies(row)))
def create_copies_spin(self, row: int, copies: int) -> QSpinBox:
spin = QSpinBox()
spin.setRange(1, 9999)
spin.setFixedWidth(78)
spin.setAlignment(Qt.AlignCenter)
spin.setValue(max(1, int(copies)))
spin.valueChanged.connect(lambda value, current_row=row: self.on_row_copies_changed(current_row, value))
return spin
def row_path(self, row: int) -> Path | None:
item = self.table.item(row, FILE_COLUMN)
if item is None:
return None
path_value = item.data(PATH_ROLE)
if path_value:
return Path(str(path_value))
return Path(item.text())
def load_thumbnail_pixmap(self, path: Path) -> QPixmap | None:
with zipfile.ZipFile(path, "r") as archive:
gcode_members = list_gcode_members(archive)
active_gcode_member = resolve_output_gcode_member(archive, gcode_members[0]) if gcode_members else None
member_name = first_preview_image_member(archive.namelist(), active_gcode_member)
if member_name is None:
return None
data = archive.read(member_name)
pixmap = QPixmap()
if not pixmap.loadFromData(data):
return None
return pixmap
def update_thumbnail_preview(self) -> None:
if not hasattr(self, "thumbnail_label"):
return
row = self.selected_row()
path = self.row_path(row) if row is not None else None
self.thumbnail_label.clear()
if path is None:
self.thumbnail_label.setText("Select an input file")
self.thumbnail_name_label.setText("")
return
self.thumbnail_name_label.setText(path.name)
try:
pixmap = self.load_thumbnail_pixmap(path)
except Exception as exc:
self.thumbnail_label.setText("Preview unavailable")
self.thumbnail_label.setToolTip(str(exc))
return
if pixmap is None:
self.thumbnail_label.setText("No preview image")
self.thumbnail_label.setToolTip("")
return
scaled = pixmap.scaled(self.thumbnail_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.thumbnail_label.setPixmap(scaled)
self.thumbnail_label.setToolTip(str(path))
def add_paths(self, paths: list[Path]) -> None: def add_paths(self, paths: list[Path]) -> None:
expanded: list[Path] = [] expanded: list[Path] = []
for path in paths: for path in paths:
@@ -484,7 +790,11 @@ class MainWindow(QMainWindow):
expanded.append(path) expanded.append(path)
if not expanded: if not expanded:
return return
existing = {self.table.item(row, 0).text() for row in range(self.table.rowCount()) if self.table.item(row, 0)} existing: set[str] = set()
for row in range(self.table.rowCount()):
current_path = self.row_path(row)
if current_path is not None:
existing.add(str(current_path))
added = 0 added = 0
skipped = 0 skipped = 0
for path in expanded: for path in expanded:
@@ -513,24 +823,29 @@ class MainWindow(QMainWindow):
self._updating_table = True self._updating_table = True
try: try:
self.table.insertRow(row) self.table.insertRow(row)
path_item = QTableWidgetItem(str(path)) path_item = QTableWidgetItem(path.name)
path_item.setFlags(path_item.flags() & ~Qt.ItemIsEditable) path_item.setFlags(path_item.flags() & ~Qt.ItemIsEditable)
path_item.setData(PATH_ROLE, str(path))
path_item.setData(SUMMARY_ROLE, self.summary_to_dict(summary)) path_item.setData(SUMMARY_ROLE, self.summary_to_dict(summary))
path_item.setToolTip(self.base_summary_tooltip(summary)) path_item.setToolTip(f"{path}\n\n{self.base_summary_tooltip(summary)}")
copies_item = QTableWidgetItem(str(max(1, int(copies)))) path_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter)
copies_item.setTextAlignment(Qt.AlignCenter)
time_item = QTableWidgetItem("") time_item = QTableWidgetItem("")
filament_item = QTableWidgetItem("") filament_item = QTableWidgetItem("")
for item in (time_item, filament_item): for item in (time_item, filament_item):
item.setFlags(item.flags() & ~Qt.ItemIsEditable) item.setFlags(item.flags() & ~Qt.ItemIsEditable)
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
self.table.setItem(row, 0, path_item) self.table.setCellWidget(row, ORDER_COLUMN, self.create_order_widget(row))
self.table.setItem(row, 1, copies_item) self.table.setItem(row, FILE_COLUMN, path_item)
self.table.setItem(row, 2, time_item) self.table.setCellWidget(row, COPIES_COLUMN, self.create_copies_spin(row, copies))
self.table.setItem(row, 3, filament_item) self.table.setItem(row, TIME_COLUMN, time_item)
self.table.setItem(row, FILAMENT_COLUMN, filament_item)
self.table.setRowHeight(row, 38)
self.update_row_stats(row) self.update_row_stats(row)
finally: finally:
self._updating_table = False self._updating_table = False
self.update_order_controls()
if self.table.rowCount() == 1:
self.table.selectRow(0)
return True return True
def summary_to_dict(self, summary: ThreeMfSummary) -> dict[str, Any]: def summary_to_dict(self, summary: ThreeMfSummary) -> dict[str, Any]:
@@ -551,7 +866,10 @@ class MainWindow(QMainWindow):
) )
def get_row_copies(self, row: int) -> int: def get_row_copies(self, row: int) -> int:
item = self.table.item(row, 1) widget = self.table.cellWidget(row, COPIES_COLUMN)
if isinstance(widget, QSpinBox):
return max(1, int(widget.value()))
item = self.table.item(row, COPIES_COLUMN)
if item is None: if item is None:
return 1 return 1
try: try:
@@ -560,12 +878,28 @@ class MainWindow(QMainWindow):
return 1 return 1
def set_row_copies(self, row: int, copies: int) -> None: def set_row_copies(self, row: int, copies: int) -> None:
item = self.table.item(row, 1) value = max(1, int(copies))
widget = self.table.cellWidget(row, COPIES_COLUMN)
if isinstance(widget, QSpinBox):
widget.setValue(value)
return
item = self.table.item(row, COPIES_COLUMN)
if item is not None: if item is not None:
item.setText(str(max(1, int(copies)))) item.setText(str(value))
def on_row_copies_changed(self, row: int, copies: int) -> None:
if self._updating_table:
return
self._updating_table = True
try:
self.update_row_stats(row)
finally:
self._updating_table = False
self.update_total_summary()
self.update_output_preview()
def update_row_stats(self, row: int) -> None: def update_row_stats(self, row: int) -> None:
path_item = self.table.item(row, 0) path_item = self.table.item(row, FILE_COLUMN)
if path_item is None: if path_item is None:
return return
data = path_item.data(SUMMARY_ROLE) data = path_item.data(SUMMARY_ROLE)
@@ -579,13 +913,13 @@ class MainWindow(QMainWindow):
total_prediction = None if summary.prediction_seconds is None else summary.prediction_seconds * copies total_prediction = None if summary.prediction_seconds is None else summary.prediction_seconds * copies
total_weight = None if summary.weight_grams is None else summary.weight_grams * copies total_weight = None if summary.weight_grams is None else summary.weight_grams * copies
total_used_m = None if summary.filament_used_m is None else summary.filament_used_m * copies total_used_m = None if summary.filament_used_m is None else summary.filament_used_m * copies
self.table.item(row, 2).setText(format_duration(total_prediction)) self.table.item(row, TIME_COLUMN).setText(format_duration(total_prediction))
self.table.item(row, 3).setText(format_filament(total_weight, total_used_m)) self.table.item(row, FILAMENT_COLUMN).setText(format_filament(total_weight, total_used_m))
def on_table_item_changed(self, item: QTableWidgetItem) -> None: def on_table_item_changed(self, item: QTableWidgetItem) -> None:
if self._updating_table: if self._updating_table:
return return
if item.column() == 1: if item.column() == COPIES_COLUMN:
self._updating_table = True self._updating_table = True
try: try:
copies = self.get_row_copies(item.row()) copies = self.get_row_copies(item.row())
@@ -605,48 +939,54 @@ class MainWindow(QMainWindow):
def remove_selected(self) -> None: def remove_selected(self) -> None:
rows = sorted(self.selected_rows(), reverse=True) rows = sorted(self.selected_rows(), reverse=True)
next_row = min(rows) if rows else None
for row in rows: for row in rows:
self.table.removeRow(row) self.table.removeRow(row)
self.update_order_controls()
if next_row is not None and self.table.rowCount() > 0:
self.table.selectRow(min(next_row, self.table.rowCount() - 1))
self.update_total_summary() self.update_total_summary()
self.update_output_preview() self.update_output_preview()
self.update_thumbnail_preview()
def remove_all(self) -> None: def remove_all(self) -> None:
self.table.setRowCount(0) self.table.setRowCount(0)
self.update_order_controls()
self.update_total_summary() self.update_total_summary()
self.update_output_preview() self.update_output_preview()
self.update_thumbnail_preview()
def move_selected(self, delta: int) -> None: def move_selected(self, delta: int) -> None:
row = self.selected_row() row = self.selected_row()
if row is None: if row is None:
return return
self.move_row(row, delta)
def move_row(self, row: int, delta: int) -> None:
new_row = row + delta new_row = row + delta
if new_row < 0 or new_row >= self.table.rowCount(): if new_row < 0 or new_row >= self.table.rowCount():
return return
row_values: list[tuple[str, Any, Any, str]] = [] copies = self.get_row_copies(row)
row_items: list[QTableWidgetItem | None] = []
for col in range(self.table.columnCount()): for col in range(self.table.columnCount()):
item = self.table.item(row, col) item = self.table.item(row, col)
row_values.append((item.text() if item else "", item.flags() if item else Qt.ItemIsEnabled, item.data(SUMMARY_ROLE) if item else None, item.toolTip() if item else "")) row_items.append(item.clone() if item is not None else None)
self._updating_table = True self._updating_table = True
try: try:
self.table.removeRow(row) self.table.removeRow(row)
self.table.insertRow(new_row) self.table.insertRow(new_row)
for col, (text, flags, data, tooltip) in enumerate(row_values): for col, item in enumerate(row_items):
item = QTableWidgetItem(text) if item is not None:
item.setFlags(flags)
if col in {1}:
item.setTextAlignment(Qt.AlignCenter)
elif col in {2, 3}:
item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
if data is not None:
item.setData(SUMMARY_ROLE, data)
if tooltip:
item.setToolTip(tooltip)
self.table.setItem(new_row, col, item) self.table.setItem(new_row, col, item)
self.table.setCellWidget(new_row, COPIES_COLUMN, self.create_copies_spin(new_row, copies))
self.table.setRowHeight(new_row, 38)
finally: finally:
self._updating_table = False self._updating_table = False
self.update_order_controls()
self.table.selectRow(new_row) self.table.selectRow(new_row)
self.update_total_summary() self.update_total_summary()
self.update_output_preview() self.update_output_preview()
self.update_thumbnail_preview()
def apply_default_copies_to_selected(self) -> None: def apply_default_copies_to_selected(self) -> None:
rows = self.selected_rows() rows = self.selected_rows()
@@ -664,8 +1004,9 @@ class MainWindow(QMainWindow):
def choose_output_dir(self) -> None: def choose_output_dir(self) -> None:
start_dir = self.output_dir_edit.text().strip() start_dir = self.output_dir_edit.text().strip()
if not start_dir and self.table.rowCount() > 0 and self.table.item(0, 0): first_path = self.row_path(0) if self.table.rowCount() > 0 else None
start_dir = str(Path(self.table.item(0, 0).text()).parent) if not start_dir and first_path is not None:
start_dir = str(first_path.parent)
directory = QFileDialog.getExistingDirectory(self, "Choose output directory", start_dir or "") directory = QFileDialog.getExistingDirectory(self, "Choose output directory", start_dir or "")
if directory: if directory:
self.output_dir_edit.setText(directory) self.output_dir_edit.setText(directory)
@@ -673,17 +1014,18 @@ class MainWindow(QMainWindow):
def collect_jobs(self) -> list[PlateJob]: def collect_jobs(self) -> list[PlateJob]:
jobs: list[PlateJob] = [] jobs: list[PlateJob] = []
for row in range(self.table.rowCount()): for row in range(self.table.rowCount()):
path_item = self.table.item(row, 0) path = self.row_path(row)
if path_item is None: if path is None:
continue continue
jobs.append(PlateJob(Path(path_item.text()), self.get_row_copies(row))) jobs.append(PlateJob(path, self.get_row_copies(row)))
return jobs return jobs
def summary_for_path(self, path: Path) -> ThreeMfSummary: def summary_for_path(self, path: Path) -> ThreeMfSummary:
normalized = str(path) normalized = str(path)
for row in range(self.table.rowCount()): for row in range(self.table.rowCount()):
item = self.table.item(row, 0) row_path = self.row_path(row)
if item is not None and item.text() == normalized: item = self.table.item(row, FILE_COLUMN)
if row_path is not None and str(row_path) == normalized and item is not None:
data = item.data(SUMMARY_ROLE) data = item.data(SUMMARY_ROLE)
if isinstance(data, ThreeMfSummary): if isinstance(data, ThreeMfSummary):
return data return data
@@ -712,19 +1054,19 @@ class MainWindow(QMainWindow):
def update_output_preview(self) -> None: def update_output_preview(self) -> None:
jobs = self.collect_jobs() jobs = self.collect_jobs()
if not jobs: if not jobs:
self.output_preview_label.setText("Output path preview: -") self.output_preview_label.setText("-")
return return
try: try:
if self.individual_batch_check.isChecked(): if self.individual_batch_check.isChecked():
first_path = resolve_output_path([jobs[0]], self.output_naming_options(), self.summary_for_path) first_path = resolve_output_path([jobs[0]], self.output_naming_options(), self.summary_for_path)
self.output_preview_label.setText( self.output_preview_label.setText(
f"Individual batch preview: {first_path} | {len(jobs)} output file(s)" f"{first_path} | {len(jobs)} output file(s)"
) )
else: else:
output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path) output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path)
self.output_preview_label.setText(f"Output path preview: {output_path}") self.output_preview_label.setText(str(output_path))
except Exception as exc: except Exception as exc:
self.output_preview_label.setText(f"Output path preview: {exc}") self.output_preview_label.setText(str(exc))
def build_options_for_output(self, output_path: Path) -> BuildOptions: def build_options_for_output(self, output_path: Path) -> BuildOptions:
swap_gcode_path = self.swap_gcode_combo.currentData() swap_gcode_path = self.swap_gcode_combo.currentData()
@@ -740,6 +1082,7 @@ class MainWindow(QMainWindow):
swap_after_final=self.swap_final_check.isChecked(), swap_after_final=self.swap_final_check.isChecked(),
metadata_mode=self.metadata_combo.currentData(), metadata_mode=self.metadata_combo.currentData(),
apply_gcode_patches=self.patch_check.isChecked(), apply_gcode_patches=self.patch_check.isChecked(),
zip_compress_level=int(self.zip_level_combo.currentData() or DEFAULT_ZIP_COMPRESS_LEVEL),
) )
def log_build_result(self, result: Any) -> None: def log_build_result(self, result: Any) -> None:
@@ -749,6 +1092,9 @@ class MainWindow(QMainWindow):
self.log.append(f"Estimated source filament: {format_filament(result.total_weight_grams)}") self.log.append(f"Estimated source filament: {format_filament(result.total_weight_grams)}")
self.log.append(f"G-code MD5: {result.gcode_md5}") self.log.append(f"G-code MD5: {result.gcode_md5}")
def show_success_toast(self, message: str) -> None:
self.success_toast.show_message(message)
def build_output(self) -> None: def build_output(self) -> None:
jobs = self.collect_jobs() jobs = self.collect_jobs()
if not jobs: if not jobs:
@@ -770,7 +1116,7 @@ class MainWindow(QMainWindow):
options = self.build_options_for_output(output_path) options = self.build_options_for_output(output_path)
result = build_packed_3mf(jobs, options) result = build_packed_3mf(jobs, options)
self.log_build_result(result) self.log_build_result(result)
QMessageBox.information(self, APP_NAME, "The packed 3MF file was created.") self.show_success_toast("The packed 3MF file was created.")
if self.clear_after_build_check.isChecked(): if self.clear_after_build_check.isChecked():
self.remove_all() self.remove_all()
@@ -778,6 +1124,7 @@ class MainWindow(QMainWindow):
used_paths: set[Path] = set() used_paths: set[Path] = set()
success_count = 0 success_count = 0
errors: list[str] = [] errors: list[str] = []
tasks: list[IndividualBuildTask] = []
for job in jobs: for job in jobs:
try: try:
output_path = make_unique_for_run( output_path = make_unique_for_run(
@@ -785,12 +1132,23 @@ class MainWindow(QMainWindow):
used_paths, used_paths,
) )
options = self.build_options_for_output(output_path) options = self.build_options_for_output(output_path)
result = build_packed_3mf([job], options)
self.log_build_result(result)
success_count += 1
except Exception as exc: except Exception as exc:
errors.append(f"{job.source_3mf.name}: {exc}") errors.append(f"{job.source_3mf.name}: {exc}")
self.log.append(f"Error building {job.source_3mf}: {exc}") self.log.append(f"Error building {job.source_3mf}: {exc}")
continue
tasks.append(IndividualBuildTask(job, options))
if tasks:
worker_count = individual_batch_worker_count(len(tasks))
self.log.append(f"Building {len(tasks)} individual output(s) with {worker_count} worker(s).")
batch_result = run_individual_batch_builds(tasks, max_workers=worker_count)
for result in batch_result.results:
self.log_build_result(result)
success_count = len(batch_result.results)
for failure in batch_result.failures:
errors.append(f"{failure.job.source_3mf.name}: {failure.error}")
self.log.append(f"Error building {failure.job.source_3mf}: {failure.error}")
if errors: if errors:
QMessageBox.warning( QMessageBox.warning(
self, self,
@@ -800,7 +1158,7 @@ class MainWindow(QMainWindow):
+ ("\n..." if len(errors) > 8 else ""), + ("\n..." if len(errors) > 8 else ""),
) )
return return
QMessageBox.information(self, APP_NAME, f"Created {success_count} packed 3MF file(s).") self.show_success_toast(f"Created {success_count} packed 3MF file(s).")
if self.clear_after_build_check.isChecked(): if self.clear_after_build_check.isChecked():
self.remove_all() self.remove_all()
@@ -822,6 +1180,7 @@ class MainWindow(QMainWindow):
def main() -> int: def main() -> int:
freeze_support()
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setFont(QFont("Segoe UI", 10)) app.setFont(QFont("Segoe UI", 10))
window = MainWindow() window = MainWindow()
+21
View File
@@ -54,6 +54,27 @@ def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]
return result return result
def read_model_settings_gcode_members(archive: zipfile.ZipFile) -> list[str]:
try:
data = archive.read("Metadata/model_settings.config")
except KeyError:
return []
try:
root = ET.fromstring(data.decode("utf-8-sig", errors="replace"))
except Exception:
return []
members: list[str] = []
for plate in root.findall("plate"):
for metadata in plate.findall("metadata"):
if metadata.attrib.get("key") != "gcode_file":
continue
value = metadata.attrib.get("value", "").strip().replace("\\", "/").lstrip("/")
if value and GCODE_MEMBER_RE.match(value):
members.append(value)
break
return members
def safe_float(value: str | None) -> float | None: def safe_float(value: str | None) -> float | None:
if value is None: if value is None:
return None return None
+2
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from typing import Literal from typing import Literal
DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound=========" DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound========="
DEFAULT_ZIP_COMPRESS_LEVEL = 7
MetadataMode = Literal["source", "sum"] MetadataMode = Literal["source", "sum"]
LineEnding = Literal["lf", "crlf"] LineEnding = Literal["lf", "crlf"]
@@ -45,6 +46,7 @@ class BuildOptions:
add_preview_label: bool = True add_preview_label: bool = True
apply_gcode_patches: bool = True apply_gcode_patches: bool = True
swap_gcode_dir: Path | None = None swap_gcode_dir: Path | None = None
zip_compress_level: int = DEFAULT_ZIP_COMPRESS_LEVEL
@dataclass @dataclass
+10
View File
@@ -9,6 +9,16 @@ PATCH_CONFIG_FILE_NAME = "gcode_patches.ini"
def program_root() -> Path: def program_root() -> Path:
compiled = globals().get("__compiled__")
if compiled is not None and getattr(compiled, "onefile", False):
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
if getattr(sys, "frozen", False): if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parents[1] return Path(__file__).resolve().parents[1]
+43
View File
@@ -0,0 +1,43 @@
@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%
echo.
echo Build done: %DIST%
exit /b 0
Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+1
View File
@@ -1,2 +1,3 @@
PySide6>=6.6 PySide6>=6.6
Pillow>=10.0 Pillow>=10.0
zlib-ng>=0.5
+128
View File
@@ -0,0 +1,128 @@
;===================================
; This workaround is kindly provided by 风信子号
;===================================
;start change plate
G28 Y
G1 Y262 F2000
M400 ; wait all motion done
M17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom
G91
G1 Z-35 F1200
G380 S2 Z95
G1 Z-25
G380 S2 Z90
G1 Z-25
G380 S2 Z90
G1 Z-30
G380 S2 Z95
G1 Z-5
G90
G4 P2000
G1 Y15 F800
G1 Y150 F1200
;This line controls how much distance the tail of the reel hangs. The smaller the value, the less distance it hangs.
G1 Y40 F1000
;The platform ensures that new plate can be added properly even when no plate are available.
;G1 Y260 F3000
G380 S2 Y264 F2000
;=================================================
;!!!!!! Z 轴下降,离开顶部
G91;
G1 Z-70 F1200
G90;
;
G1 Y200 F10000
G1 Y25 F4000
;Avoiding the use of the G380 command is to prevent failure in reaching the correct position.
G1 Y-1 F20000
;G380 S3 Y-1 F1200
G4 P500
G28 Y
;start Shake-triggered plate replacement
G1 Y1 F100
G1 Y0 F20000
G4 P500
G1 Y1.5 F100
G1 Y0 F20000
G4 P500
G1 Y2 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F10000
G4 P500
;G1 Y3.5 F100
;G1 Y0 F5000
;end Shake-triggered plate replacement
G1 Y235 F1200
G1 Y110 F5000
G1 Y200 F1200
;G1 Y266 F1500
G380 S2 Y264 F2000
;After retracting the build plate to the frontmost position, dwell for 1 second.
G4 P1000
G1 Y25 F4000
;G1 Y-2 F20000
G1 Y-2 F1000
G4 P500
;start Shake-triggered plate replacement
G1 Y1 F100
G1 Y0 F20000
G4 P500
G1 Y1.5 F100
G1 Y0 F20000
G4 P500
G1 Y2 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F20000
G4 P500
G1 Y2.5 F100
G1 Y0 F10000
G4 P500
;end Shake-triggered plate replacement
;move to front
G1 Y262 F1500
G1 Y120 F2000
;end change plate
G4 P3000
;===================================
; return Z to safe standby height after plate change
M400
G90
G1 Z30 F600
M400
+211
View File
@@ -0,0 +1,211 @@
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):
def test_write_output_preserves_configured_plate_member(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source_3mf = root / "plate2.3mf"
output_3mf = root / "out.3mf"
self.write_plate2_archive(source_3mf)
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"
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)
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:
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",
)
]
gcode_bytes = (b"G1 X123.456 Y789.012 E0.03456 ; repeated movement\n" * 20000)
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)
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:
self.assertEqual(normalized_zip_compress_level(None), 7)
self.assertEqual(normalized_zip_compress_level(0), 1)
self.assertEqual(normalized_zip_compress_level(10), 9)
def test_preview_composite_uses_first_nine_unique_input_files(self) -> None:
if Image is None:
self.skipTest("Pillow is not installed")
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] = []
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",
)
)
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
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)),
}
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:
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:
archive.writestr("Metadata/plate_2.png", b"png")
archive.writestr("Metadata/plate_2_small.png", b"png")
else:
import io
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()
+63
View File
@@ -0,0 +1,63 @@
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:
self.assertEqual(batch.individual_batch_worker_count(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)
with patch.object(batch.os, "cpu_count", return_value=32):
self.assertEqual(batch.individual_batch_worker_count(20), batch.INDIVIDUAL_BATCH_MAX_WORKERS)
with patch.object(batch.os, "cpu_count", return_value=None):
self.assertEqual(batch.individual_batch_worker_count(20), 1)
def test_serial_runner_keeps_success_order_and_collects_failures(self) -> None:
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:
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:
return batch.IndividualBuildTask(
PlateJob(Path(source_name), copies),
BuildOptions(swap_gcode=Path("swap.gcode"), output_3mf=Path(output_name)),
)
if __name__ == "__main__":
unittest.main()
+149
View File
@@ -0,0 +1,149 @@
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):
def test_repeated_copies_prepare_gcode_once_per_member(self) -> None:
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")
patch_config = GcodePatchConfig(
rules=(
GcodePatchRule(
name="test",
find="G0 Y254 F3000",
replace="G0 Y250 F3000 ;Patched",
),
)
)
patch_calls = 0
captured_gcode = ""
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:
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,
)
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)
self.assertEqual(patch_calls, 1)
self.assertEqual(captured_gcode.count("G0 Y250 F3000 ;Patched"), 3)
self.assertNotIn("G0 Y254 F3000", captured_gcode)
def test_repeated_copies_prepare_m73_template_once_per_member(self) -> None:
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):
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:
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,
swap_after_final=False,
line_ending="lf",
add_preview_label=False,
)
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)
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:
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()
+42
View File
@@ -0,0 +1,42 @@
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):
def test_template_matches_previous_regex_replacement_semantics(self) -> None:
gcode = (
"M73 P0 R10\n"
"; M73 P0 R20\n"
" M73 P0 R30\n"
"M73 P50 R5999 ; comment\n"
"G1 X1\n"
"M73 P100 R0\n"
)
def previous_apply(text: str, plate_number: int) -> str:
minute_offset = plate_number * 100 * 60
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:
gcode = "G1 X1\n; M73 P0 R10\n"
template = prepare_m73_offset_template(gcode)
self.assertEqual(template.apply(3), gcode)
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
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):
def test_app_title_uses_shared_version(self) -> None:
self.assertEqual(APP_TITLE, f"{APP_NAME} v{__version__}")
def test_cli_help_and_version_use_shared_version(self) -> None:
parser = create_parser()
self.assertIn(APP_TITLE, parser.format_help())
stdout = io.StringIO()
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()