4ae33cbe53
- 每个文件补充模块级 docstring、类/函数 docstring、关键逻辑行内注释 - 注释风格:"中文说明" 或 # 中文说明 - 代码结构和逻辑完全不变
829 lines
31 KiB
Python
829 lines
31 KiB
Python
"""换板 3MF 构建引擎 - 多板 G-code 合并与打包核心模块.
|
||
|
||
本模块是整个项目的核心引擎,负责将多个 3MF 切片文件中的 G-code
|
||
合并为一份带自动换板逻辑的打包 3MF 文件。主要功能包括:
|
||
|
||
1. 从源 3MF 文件中提取板层 G-code 及元数据(打印时间、耗材用量等)
|
||
2. 在板层之间插入换板 G-code 块(自动清空热床、暂停等待取件)
|
||
3. 应用自定义 G-code 补丁(M73 进度偏移、行尾格式转换等)
|
||
4. 生成多板拼接预览图(每板小缩略图拼成网格,并叠加板数标签)
|
||
5. 输出最终的 3MF 文件(使用 zlib-ng 加快 ZIP 压缩)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import os
|
||
import re
|
||
import shutil
|
||
import tempfile
|
||
import zipfile
|
||
from contextlib import contextmanager
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members
|
||
from .gcode import (
|
||
M73OffsetTemplate,
|
||
apply_line_ending,
|
||
build_swap_block,
|
||
insert_swap_block,
|
||
normalize_newlines,
|
||
prepare_m73_offset_template,
|
||
read_swap_gcode_file,
|
||
resolve_swap_gcode_path,
|
||
)
|
||
from .metadata import (
|
||
read_filament_metadata,
|
||
read_model_settings_gcode_members,
|
||
read_slice_plate_metadata,
|
||
safe_float,
|
||
update_first_slice_info,
|
||
)
|
||
from .models import DEFAULT_ZIP_COMPRESS_LEVEL, BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource
|
||
from .patches import apply_gcode_patches, parse_patch_config
|
||
|
||
# PIL(Python Imaging Library)为可选依赖,用于生成预览图标签和缩略图拼接
|
||
# 若不可用则降级跳过图片处理
|
||
try:
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
except Exception: # pragma: no cover
|
||
Image = None
|
||
ImageDraw = None
|
||
ImageFont = None
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 模块级常量
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# 匹配 Metadata/plate_N.png 或 Metadata/plate_N_small.png 的正则
|
||
PREVIEW_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)
|
||
# 合成预览图中最多展示的源文件数量(防止网格过于密集)
|
||
MAX_COMPOSITE_PREVIEW_INPUTS = 9
|
||
# ZIP 压缩等级合法范围
|
||
MIN_ZIP_COMPRESS_LEVEL = 1
|
||
MAX_ZIP_COMPRESS_LEVEL = 9
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 数据结构
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@dataclass(frozen=True)
|
||
class _PreparedPlateGcode:
|
||
"""预处理后的板层 G-code 数据(内部使用,不可变)。
|
||
|
||
属性:
|
||
text: 规范化并可选打过 G-code 补丁的 G-code 文本。
|
||
m73_template: 若启用板号显示,则为 M73 偏移模板,否则为 None。
|
||
"""
|
||
text: str
|
||
m73_template: M73OffsetTemplate | None = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 工具函数
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def normalized_zip_compress_level(level: int | None) -> int:
|
||
"""将 ZIP 压缩等级规范化为合法范围内的整数值。
|
||
|
||
输入:
|
||
level: 用户指定的压缩等级(0-9 或 None)。
|
||
|
||
输出:
|
||
夹逼在 [MIN_ZIP_COMPRESS_LEVEL, MAX_ZIP_COMPRESS_LEVEL] 之间的整数。
|
||
若 level 为 None 或类型错误,则使用默认值 DEFAULT_ZIP_COMPRESS_LEVEL。
|
||
"""
|
||
try:
|
||
value = int(level if level is not None else DEFAULT_ZIP_COMPRESS_LEVEL)
|
||
except (TypeError, ValueError):
|
||
value = DEFAULT_ZIP_COMPRESS_LEVEL
|
||
return min(MAX_ZIP_COMPRESS_LEVEL, max(MIN_ZIP_COMPRESS_LEVEL, value))
|
||
|
||
|
||
def load_zlib_ng_module() -> object:
|
||
"""加载 zlib-ng 模块以加速 ZIP 压缩。
|
||
|
||
输入:
|
||
无。
|
||
|
||
输出:
|
||
zlib_ng 模块对象。
|
||
|
||
异常:
|
||
RuntimeError: 若 zlib-ng 未安装。
|
||
"""
|
||
try:
|
||
from zlib_ng import zlib_ng
|
||
except ImportError as exc: # pragma: no cover - exercised when dependency is missing
|
||
raise RuntimeError("zlib-ng is required for 3MF ZIP compression. Install it with: pip install zlib-ng") from exc
|
||
return zlib_ng
|
||
|
||
|
||
@contextmanager
|
||
def use_zlib_ng_for_zipfile() -> object:
|
||
"""上下文管理器:用 zlib-ng 替换 zipfile 模块内的默认 zlib 实现。
|
||
|
||
在 with 块内,所有 zipfile 操作自动使用 zlib-ng 压缩算法,
|
||
退出时自动恢复原始 zlib。
|
||
|
||
输入:
|
||
无。
|
||
|
||
输出:
|
||
yield 的返回值(通常不使用)。
|
||
"""
|
||
original_zlib = zipfile.zlib
|
||
zipfile.zlib = load_zlib_ng_module()
|
||
try:
|
||
yield
|
||
finally:
|
||
zipfile.zlib = original_zlib
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 板源加载与展开
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def load_plate_sources(job: PlateJob) -> list[PlateSource]:
|
||
"""从单个 PlateJob 的源 3MF 文件中加载所有板层的 G-code 及元数据。
|
||
|
||
输入:
|
||
job: 包含 source_3mf 路径和 copies 数量的 PlateJob 对象。
|
||
|
||
输出:
|
||
该 3MF 中每个 G-code 成员对应的 PlateSource 列表,每个 PlateSource
|
||
携带规范化的 G-code 文本、预测打印时间、耗材重量/长度等信息。
|
||
|
||
异常:
|
||
ValueError: 若 copies < 1 或未找到任何 G-code 成员。
|
||
"""
|
||
if job.copies < 1:
|
||
raise ValueError(f"Invalid copy count for {job.source_3mf}: {job.copies}")
|
||
with zipfile.ZipFile(job.source_3mf, "r") as archive:
|
||
members = list_gcode_members(archive)
|
||
if not members:
|
||
raise ValueError(f"No Metadata/plate_N.gcode member was found in {job.source_3mf}")
|
||
# 读取板层切片元数据(预测时间、重量等)
|
||
plate_metadata = read_slice_plate_metadata(archive)
|
||
# 读取耗材用量元数据
|
||
filament_metadata = read_filament_metadata(archive)
|
||
sources: list[PlateSource] = []
|
||
for member in members:
|
||
match = GCODE_MEMBER_RE.match(member)
|
||
plate_index = int(match.group(1)) if match else 1
|
||
raw = archive.read(member)
|
||
text = raw.decode("utf-8-sig", errors="replace")
|
||
metadata = plate_metadata.get(plate_index, {})
|
||
filament = filament_metadata.get(plate_index, {})
|
||
# 重量优先取 metadata.weight,其次取 filament.used_g
|
||
weight = safe_float(metadata.get("weight"))
|
||
used_g = safe_float(filament.get("used_g"))
|
||
sources.append(
|
||
PlateSource(
|
||
source_3mf=job.source_3mf,
|
||
member_name=member,
|
||
gcode_text=text,
|
||
prediction_seconds=safe_float(metadata.get("prediction")),
|
||
weight_grams=weight if weight is not None else used_g,
|
||
filament_used_m=safe_float(filament.get("used_m")),
|
||
filament_used_g=used_g if used_g is not None else weight,
|
||
)
|
||
)
|
||
return sources
|
||
|
||
|
||
def expand_jobs(jobs: list[PlateJob]) -> list[PlateSource]:
|
||
"""展开 Job 列表,按 copies 数量复制每个板层为多个 PlateSource。
|
||
|
||
例如,若某 PlateJob 有 2 个 copies 且包含 3 个板层,
|
||
则展开后产生 2 * 3 = 6 个 PlateSource。
|
||
|
||
输入:
|
||
jobs: PlateJob 列表,每个可指定 copies > 0。
|
||
|
||
输出:
|
||
按顺序展开后的 PlateSource 列表。
|
||
|
||
异常:
|
||
ValueError: 若展开后为空(没有任何输入板)。
|
||
"""
|
||
expanded: list[PlateSource] = []
|
||
for job in jobs:
|
||
sources = load_plate_sources(job)
|
||
for _ in range(job.copies):
|
||
expanded.extend(sources)
|
||
if not expanded:
|
||
raise ValueError("No input plates were provided.")
|
||
return expanded
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# G-code 加工流水线
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def process_plate_gcode(
|
||
source: PlateSource,
|
||
plate_number: int,
|
||
total_plates: int,
|
||
options: BuildOptions,
|
||
swap_gcode_text: str,
|
||
patch_config: GcodePatchConfig,
|
||
) -> str:
|
||
"""完整的 G-code 加工流水线:预处理 + 换板注入。
|
||
|
||
输入:
|
||
source: 板层数据源(含 G-code 文本)。
|
||
plate_number: 当前板号(从 1 开始)。
|
||
total_plates: 总板数。
|
||
options: 构建选项(控制板号显示、换板行为、行尾格式等)。
|
||
swap_gcode_text: 换板 G-code 模板文本。
|
||
patch_config: G-code 补丁配置。
|
||
|
||
输出:
|
||
加工完成后的单板 G-code 文本字符串。
|
||
"""
|
||
prepared = _prepare_plate_gcode(source, options, patch_config)
|
||
return _process_prepared_plate_gcode(
|
||
prepared,
|
||
plate_number,
|
||
total_plates,
|
||
options,
|
||
swap_gcode_text,
|
||
patch_config,
|
||
)
|
||
|
||
|
||
def _prepare_plate_gcode(
|
||
source: PlateSource,
|
||
options: BuildOptions,
|
||
patch_config: GcodePatchConfig,
|
||
) -> _PreparedPlateGcode:
|
||
"""G-code 预处理阶段:规范化行尾、应用补丁、提取 M73 模板。
|
||
|
||
输入:
|
||
source: 板层数据源。
|
||
options: 构建选项。
|
||
patch_config: G-code 补丁配置。
|
||
|
||
输出:
|
||
预处理后的 _PreparedPlateGcode 对象。
|
||
"""
|
||
# 第一步:统一行尾为 \n(后续处理依赖一致的换行符)
|
||
text = normalize_newlines(source.gcode_text)
|
||
# 第二步:如启用,应用用户自定义 G-code 补丁规则
|
||
if options.apply_gcode_patches:
|
||
text = apply_gcode_patches(text, patch_config)
|
||
# 第三步:如启用板号显示,提取 M73 进度模板用于后续偏移计算
|
||
m73_template = prepare_m73_offset_template(text) if options.show_plate_number else None
|
||
return _PreparedPlateGcode(text, m73_template)
|
||
|
||
|
||
def _process_prepared_plate_gcode(
|
||
prepared: _PreparedPlateGcode,
|
||
plate_number: int,
|
||
total_plates: int,
|
||
options: BuildOptions,
|
||
swap_gcode_text: str,
|
||
patch_config: GcodePatchConfig,
|
||
) -> str:
|
||
"""G-code 后处理阶段:应用 M73 板号偏移、插入换板块。
|
||
|
||
输入:
|
||
prepared: 预处理后的板层 G-code。
|
||
plate_number: 当前板号(从 1 开始)。
|
||
total_plates: 总板数。
|
||
options: 构建选项。
|
||
swap_gcode_text: 换板 G-code 模板。
|
||
patch_config: G-code 补丁配置(含 insert_before_marker 标记位置)。
|
||
|
||
输出:
|
||
最终的单板 G-code 文本字符串。
|
||
"""
|
||
text = prepared.text
|
||
# 若启用板号显示且成功提取了 M73 模板,则将 M73 进度值偏移到正确范围
|
||
if options.show_plate_number and prepared.m73_template is not None:
|
||
text = prepared.m73_template.apply(plate_number)
|
||
# 判断是否需要在当前板后插入换板逻辑:
|
||
# - swap_after_final 为 True 时最后一板也换
|
||
# - 非最后一板始终需要换板
|
||
should_swap = options.swap_after_final or plate_number < total_plates
|
||
if should_swap:
|
||
# 构建换板 G-code 块(降温 + 等待取件)
|
||
swap_block = build_swap_block(swap_gcode_text, options.cool_bed_temp, options.wait_after_eject_seconds)
|
||
# 在指定标记位置插入换板块
|
||
text = insert_swap_block(text, swap_block, patch_config.insert_before_marker)
|
||
return text.rstrip("\n") + "\n"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 缓存机制
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _plate_gcode_cache_key(source: PlateSource) -> tuple[Path, str]:
|
||
"""生成板层 G-code 的缓存键。
|
||
|
||
同一个 3MF 文件中同一成员名的板层,其预处理结果(规范化 + 补丁 + M73 模板)
|
||
是完全相同的,因此可以安全缓存复用,避免重复处理。
|
||
|
||
输入:
|
||
source: 板层数据源。
|
||
|
||
输出:
|
||
由 (源文件绝对路径, 成员名) 组成的元组。
|
||
"""
|
||
return (source.source_3mf.resolve(strict=False), source.member_name)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 预览图处理
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def save_png_bytes(image: object) -> bytes:
|
||
"""将 PIL Image 对象编码为 PNG 字节流。
|
||
|
||
输入:
|
||
image: PIL Image 对象。
|
||
|
||
输出:
|
||
PNG 格式的字节数据。
|
||
"""
|
||
import io
|
||
|
||
output = io.BytesIO()
|
||
image.save(output, format="PNG")
|
||
return output.getvalue()
|
||
|
||
|
||
def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> bytes:
|
||
"""在预览图左下角叠加文字标签(如 "5 P" 表示 5 个板)。
|
||
|
||
自动适配系统可用字体(Windows Arial Bold / Linux DejaVu Sans Bold / macOS Arial Bold),
|
||
若全部缺失则回退到 PIL 默认字体。PIL 不可用时原样返回 PNG 字节。
|
||
|
||
输入:
|
||
png_bytes: 原始预览图 PNG 字节数据。
|
||
label: 要叠加的文字标签。
|
||
small: 是否为缩略图(True 时使用更小的字体)。
|
||
|
||
输出:
|
||
叠加文字后的 PNG 字节数据。
|
||
"""
|
||
if Image is None or ImageDraw is None:
|
||
return png_bytes
|
||
import io
|
||
|
||
try:
|
||
image = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||
except Exception:
|
||
return png_bytes
|
||
draw = ImageDraw.Draw(image)
|
||
# 根据图片尺寸和大小标志计算字体大小
|
||
size = max(16, image.width // (12 if small else 8))
|
||
font = None
|
||
# 按平台优先级尝试加载粗体系统字
|
||
for font_path in (
|
||
Path(os.environ.get("WINDIR", "")) / "Fonts" / "arialbd.ttf",
|
||
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
||
Path("/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
|
||
):
|
||
if font_path.exists() and ImageFont is not None:
|
||
try:
|
||
font = ImageFont.truetype(str(font_path), size)
|
||
break
|
||
except Exception:
|
||
font = None
|
||
# 所有系统字体都失败时,使用 PIL 内置默认字体
|
||
if font is None and ImageFont is not None:
|
||
font = ImageFont.load_default()
|
||
# 文字位置:左下角,留出边距
|
||
margin = max(8, image.width // 24)
|
||
text_bbox = draw.textbbox((0, 0), label, font=font)
|
||
x = margin
|
||
y = image.height - margin - (text_bbox[3] - text_bbox[1])
|
||
# 半透明绿色文字,保证在各种背景上都可见
|
||
draw.text((x, y), label, font=font, fill=(0, 255, 0, 255))
|
||
return save_png_bytes(image)
|
||
|
||
|
||
def preview_member_name_for_gcode_member(gcode_member: str, small: bool = False) -> str | None:
|
||
"""根据 G-code 成员名推导对应的预览图成员名。
|
||
|
||
例如 "Metadata/plate_3.gcode" -> "Metadata/plate_3.png" 或 "Metadata/plate_3_small.png"。
|
||
|
||
输入:
|
||
gcode_member: G-code 成员路径,如 "Metadata/plate_1.gcode"。
|
||
small: 是否请求缩略图版本。
|
||
|
||
输出:
|
||
对应的 PNG 成员路径,若不匹配正则则返回 None。
|
||
"""
|
||
match = GCODE_MEMBER_RE.match(gcode_member)
|
||
if not match:
|
||
return None
|
||
suffix = "_small" if small else ""
|
||
return f"Metadata/plate_{match.group(1)}{suffix}.png"
|
||
|
||
|
||
def preview_member_sort_key(member_name: str) -> tuple[int, int, str]:
|
||
"""生成预览图成员的排序键:先按是否为缩略图分组,再按板号排序。
|
||
|
||
输入:
|
||
member_name: ZIP 成员名称。
|
||
|
||
输出:
|
||
(is_small, plate_number, member_name.lower()) 排序元组。
|
||
"""
|
||
match = PREVIEW_MEMBER_RE.match(member_name)
|
||
plate_number = int(match.group(1)) if match else 9999
|
||
is_small = 1 if member_name.lower().endswith("_small.png") else 0
|
||
return (is_small, plate_number, member_name.lower())
|
||
|
||
|
||
def select_preview_sources(sources: list[PlateSource], max_inputs: int = MAX_COMPOSITE_PREVIEW_INPUTS) -> list[PlateSource]:
|
||
"""从板源列表中选取用于合成预览图的唯一板源(去重,限数量)。
|
||
|
||
同一个 3MF 文件只取一个代表,避免重复展示同一模型的缩略图。
|
||
|
||
输入:
|
||
sources: 全部板源列表(可能含重复)。
|
||
max_inputs: 最多选取的数量,默认 9。
|
||
|
||
输出:
|
||
去重并按原顺序截取后的 PlateSource 列表。
|
||
"""
|
||
selected: list[PlateSource] = []
|
||
seen_paths: set[Path] = set()
|
||
for source in sources:
|
||
key = source.source_3mf.resolve(strict=False)
|
||
if key in seen_paths:
|
||
continue
|
||
seen_paths.add(key)
|
||
selected.append(source)
|
||
if len(selected) >= max_inputs:
|
||
break
|
||
return selected
|
||
|
||
|
||
def source_preview_member(archive: zipfile.ZipFile, source: PlateSource, small: bool = False) -> str | None:
|
||
"""在 3MF 归档中查找与给定板源最匹配的预览图成员。
|
||
|
||
查找策略(按优先级递减):
|
||
1. 精确匹配:与 G-code 成员同板号的同名 PNG。
|
||
2. 备选尺寸:同板号但不同大小的 PNG(如请求小图但只有大图)。
|
||
3. 同类型回退:任意板号的同大小 PNG。
|
||
4. 全尺寸回退:任意板号的任意大小 PNG。
|
||
5. 通配回退:Metadata/ 下任意 PNG。
|
||
|
||
输入:
|
||
archive: 已打开的 3MF ZIP 归档。
|
||
source: 板层数据源。
|
||
small: 是否优先缩略图。
|
||
|
||
输出:
|
||
匹配的成员名,若完全找不到则返回 None。
|
||
"""
|
||
names = set(archive.namelist())
|
||
# 解析实际输出的 G-code 成员名(可能与原始成员不同)
|
||
active_member = resolve_output_gcode_member(archive, source.member_name)
|
||
# 策略1:精确匹配
|
||
preferred = preview_member_name_for_gcode_member(active_member, small)
|
||
if preferred in names:
|
||
return preferred
|
||
# 策略2:相同板号但不同尺寸
|
||
alternate = preview_member_name_for_gcode_member(active_member, not small)
|
||
if alternate in names:
|
||
return alternate
|
||
|
||
# 策略3:同大小类型的任意 PNG
|
||
candidates = [
|
||
name
|
||
for name in names
|
||
if PREVIEW_MEMBER_RE.match(name) and name.lower().endswith("_small.png") == small
|
||
]
|
||
if not candidates:
|
||
# 策略4:所有 plate_N.png
|
||
candidates = [name for name in names if PREVIEW_MEMBER_RE.match(name)]
|
||
if not candidates:
|
||
# 策略5:Metadata/ 下任意 PNG
|
||
candidates = [
|
||
name
|
||
for name in names
|
||
if name.lower().startswith("metadata/") and name.lower().endswith(".png")
|
||
]
|
||
if not candidates:
|
||
return None
|
||
# 按排序键取第一个(缩略图优先,板号升序)
|
||
return sorted(candidates, key=preview_member_sort_key)[0]
|
||
|
||
|
||
def read_source_preview_image(source: PlateSource, small: bool = False) -> object | None:
|
||
"""从源 3MF 文件中读取板层预览图(PIL Image 对象)。
|
||
|
||
输入:
|
||
source: 板层数据源。
|
||
small: True 时优先读取缩略图。
|
||
|
||
输出:
|
||
RGBA 模式的 PIL Image 对象,读取失败或 PIL 不可用时返回 None。
|
||
"""
|
||
if Image is None:
|
||
return None
|
||
import io
|
||
|
||
try:
|
||
with zipfile.ZipFile(source.source_3mf, "r") as archive:
|
||
member = source_preview_member(archive, source, small)
|
||
if member is None:
|
||
return None
|
||
data = archive.read(member)
|
||
return Image.open(io.BytesIO(data)).convert("RGBA")
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def preview_grid_dimensions(count: int) -> tuple[int, int]:
|
||
"""根据图片数量计算合成预览图的最佳网格布局 (列数, 行数)。
|
||
|
||
输入:
|
||
count: 要排列的图片数量。
|
||
|
||
输出:
|
||
(columns, rows) 元组。布局规则:
|
||
- 1 张 -> (1, 1)
|
||
- 2 张 -> (2, 1) 横向排列
|
||
- 3-4 张 -> (2, 2) 田字形
|
||
- 5+ 张 -> 最多 3 列,行数向上取整
|
||
"""
|
||
if count <= 1:
|
||
return (1, 1)
|
||
if count <= 2:
|
||
return (2, 1)
|
||
if count <= 4:
|
||
return (2, 2)
|
||
return (3, (count + 2) // 3)
|
||
|
||
|
||
def resize_image_to_fit(image: object, width: int, height: int) -> object:
|
||
"""将 PIL Image 等比缩放到指定宽高范围内(保持纵横比)。
|
||
|
||
输入:
|
||
image: PIL Image 对象。
|
||
width: 目标最大宽度。
|
||
height: 目标最大高度。
|
||
|
||
输出:
|
||
缩放后的 PIL Image 副本。
|
||
"""
|
||
resized = image.copy()
|
||
# 优先使用 LANCZOS 重采样(高质量),回退到 BICUBIC
|
||
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS", Image.BICUBIC)
|
||
resized.thumbnail((max(1, width), max(1, height)), resampling)
|
||
return resized
|
||
|
||
|
||
def compose_preview_image(png_bytes: bytes, sources: list[PlateSource], label: str, small: bool = False) -> bytes:
|
||
"""将多个板源的预览图合成为一张网格拼图并叠加文字标签。
|
||
|
||
工作流程:
|
||
1. 以原始预览图为画布基础(取尺寸)。
|
||
2. 从各源文件中读取缩略图(去重限数量)。
|
||
3. 按网格布局排列缩略图到画布上。
|
||
4. 叠加文字标签。
|
||
|
||
输入:
|
||
png_bytes: 原始预览图 PNG 字节数据(用于确定画布尺寸)。
|
||
sources: 所有板源列表(内部会自动去重限数量)。
|
||
label: 要叠加的文字标签。
|
||
small: 是否使用缩略图尺寸。
|
||
|
||
输出:
|
||
合成后的 PNG 字节数据。
|
||
"""
|
||
if Image is None:
|
||
return png_bytes
|
||
import io
|
||
|
||
try:
|
||
base_image = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||
except Exception:
|
||
return update_preview_label(png_bytes, label, small)
|
||
|
||
# 读取所有去重后板源的预览图
|
||
source_images = [
|
||
image
|
||
for image in (read_source_preview_image(source, small) for source in select_preview_sources(sources))
|
||
if image is not None
|
||
]
|
||
if not source_images:
|
||
# 无可用预览图,仅叠加标签
|
||
return update_preview_label(png_bytes, label, small)
|
||
|
||
# 创建透明画布,尺寸与原始预览图一致
|
||
width, height = base_image.size
|
||
canvas = Image.new("RGBA", (width, height), (255, 255, 255, 0))
|
||
columns, rows = preview_grid_dimensions(len(source_images))
|
||
# 逐张放置缩略图到网格对应位置,居中摆放
|
||
for index, image in enumerate(source_images):
|
||
column = index % columns
|
||
row = index // columns
|
||
left = width * column // columns
|
||
right = width * (column + 1) // columns
|
||
top = height * row // rows
|
||
bottom = height * (row + 1) // rows
|
||
fitted = resize_image_to_fit(image, right - left, bottom - top)
|
||
x = left + max(0, (right - left - fitted.width) // 2)
|
||
y = top + max(0, (bottom - top - fitted.height) // 2)
|
||
# 使用自身 alpha 通道粘贴,保持透明
|
||
canvas.paste(fitted, (x, y), fitted)
|
||
|
||
return update_preview_label(save_png_bytes(canvas), label, small)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 输出成员名解析
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def resolve_output_gcode_member(archive: zipfile.ZipFile, fallback_member: str) -> str:
|
||
"""解析最终输出 3MF 中应使用的 G-code 成员名。
|
||
|
||
Bambu Studio 的 model_settings.config 可能指定了 G-code 成员名,
|
||
若该成员实际存在则优先使用;否则回退到 fallback_member。
|
||
|
||
输入:
|
||
archive: 源 3MF ZIP 归档。
|
||
fallback_member: 回退成员名。
|
||
|
||
输出:
|
||
最终应使用的 G-code 成员名称。
|
||
"""
|
||
# 从 model_settings.config 读取已配置的 G-code 成员列表
|
||
configured_members = read_model_settings_gcode_members(archive)
|
||
if fallback_member in configured_members:
|
||
return fallback_member
|
||
existing_members = set(list_gcode_members(archive))
|
||
# 在已配置成员中查找实际存在的第一个
|
||
for member in configured_members:
|
||
if member in existing_members:
|
||
return member
|
||
# 都不存在则使用配置中的第一个成员名
|
||
if configured_members:
|
||
return configured_members[0]
|
||
return fallback_member
|
||
|
||
|
||
def preview_members_for_gcode_member(gcode_member: str) -> set[str]:
|
||
"""获取与给定 G-code 成员对应的所有预览图成员名集合。
|
||
|
||
输入:
|
||
gcode_member: G-code 成员路径。
|
||
|
||
输出:
|
||
包含普通预览图和缩略图成员名的集合,无法推导时返回空集。
|
||
"""
|
||
full_member = preview_member_name_for_gcode_member(gcode_member, small=False)
|
||
small_member = preview_member_name_for_gcode_member(gcode_member, small=True)
|
||
if full_member is None or small_member is None:
|
||
return set()
|
||
return {full_member, small_member}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ZIP 写入流程
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sources: list[PlateSource], options: BuildOptions) -> str:
|
||
"""将合并后的 G-code 写入新的 3MF 文件。
|
||
|
||
处理步骤:
|
||
1. 计算最终 G-code 的 MD5 校验和。
|
||
2. 基于源 3MF 创建新的 ZIP 归档(使用 zlib-ng 加速)。
|
||
3. 复制所有非 G-code 成员到新归档,对特定成员做处理:
|
||
- slice_info.config: 更新为多板信息。
|
||
- 预览图 PNG: 如启用标签,合成为多板拼接预览图。
|
||
4. 写入合并后的 G-code 及对应的 MD5 文件。
|
||
|
||
输入:
|
||
base_3mf: 源 3MF 文件路径。
|
||
output_3mf: 输出 3MF 文件路径。
|
||
gcode_bytes: 合并打包后的完整 G-code 字节数据。
|
||
sources: 所有板源列表。
|
||
options: 构建选项。
|
||
|
||
输出:
|
||
最终 G-code 的 MD5 十六进制字符串。
|
||
"""
|
||
# 计算 G-code 的 MD5 校验和
|
||
md5 = hashlib.md5(gcode_bytes).hexdigest()
|
||
compress_level = normalized_zip_compress_level(options.zip_compress_level)
|
||
# 使用 zlib-ng 替换 zipfile 的默认压缩器以加速写入
|
||
with use_zlib_ng_for_zipfile():
|
||
with zipfile.ZipFile(base_3mf, "r") as src, zipfile.ZipFile(
|
||
output_3mf,
|
||
"w",
|
||
compression=zipfile.ZIP_DEFLATED,
|
||
compresslevel=compress_level,
|
||
) as dst:
|
||
# 解析输出 G-code 成员名(可能与源文件不同)
|
||
gcode_member = resolve_output_gcode_member(src, sources[0].member_name)
|
||
# 找出对应的预览图成员,以便后续做标签叠加处理
|
||
preview_members = preview_members_for_gcode_member(gcode_member)
|
||
# 逐项复制源归档中的成员
|
||
for item in src.infolist():
|
||
name = item.filename
|
||
# 跳过旧的 G-code 和 MD5 成员(将被新的替换)
|
||
if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name):
|
||
continue
|
||
data = src.read(name)
|
||
# 更新切片信息配置,写入多板汇总数据
|
||
if name == "Metadata/slice_info.config":
|
||
data = update_first_slice_info(data, sources, options)
|
||
# 对预览图叠加板数标签
|
||
elif options.add_preview_label and name in preview_members:
|
||
data = compose_preview_image(data, sources, f"{len(sources)} P", small=name.endswith("_small.png"))
|
||
dst.writestr(item, data)
|
||
# 写入合并后的 G-code 及其 MD5 校验文件
|
||
dst.writestr(gcode_member, gcode_bytes)
|
||
dst.writestr(f"{gcode_member}.md5", md5)
|
||
return md5
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 顶层构建入口
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def build_packed_3mf(jobs: list[PlateJob], options: BuildOptions) -> BuildResult:
|
||
"""顶层入口:执行完整的换板 3MF 打包流程。
|
||
|
||
整体流程:
|
||
1. 展开 Job 列表为板源列表(处理 copies)。
|
||
2. 加载换板 G-code 模板。
|
||
3. 解析 G-code 补丁配置。
|
||
4. 对每个板源依次预处理 G-code(含缓存复用)并注入换板块。
|
||
5. 拼接所有板层 G-code。
|
||
6. 写入最终 3MF 文件(含预览图合成、元数据更新)。
|
||
|
||
输入:
|
||
jobs: PlateJob 列表,每个指定源 3MF 及 copies 数量。
|
||
options: 构建选项,控制所有行为开关。
|
||
|
||
输出:
|
||
BuildResult 对象,包含输出文件路径、总板数、预测时间和重量等汇总信息。
|
||
"""
|
||
# 第一步:展开并加载所有板源
|
||
sources = expand_jobs(jobs)
|
||
# 第二步:加载换板 G-code 模板
|
||
swap_gcode_path = resolve_swap_gcode_path(options.swap_gcode, options.swap_gcode_dir)
|
||
swap_gcode_text = read_swap_gcode_file(swap_gcode_path)
|
||
# 第三步:解析 G-code 补丁配置
|
||
patch_config = parse_patch_config() if options.apply_gcode_patches else GcodePatchConfig(rules=())
|
||
processed: list[str] = []
|
||
# 第四步:G-code 加工流水线(含预处理缓存)
|
||
# 同一源文件 + 同一成员名的板层只需预处理一次,后续复用缓存结果
|
||
prepared_gcode_cache: dict[tuple[Path, str], _PreparedPlateGcode] = {}
|
||
for plate_number, source in enumerate(sources, start=1):
|
||
cache_key = _plate_gcode_cache_key(source)
|
||
prepared_text = prepared_gcode_cache.get(cache_key)
|
||
if prepared_text is None:
|
||
# 缓存未命中:执行预处理(行尾规范化 + 补丁应用 + M73 模板提取)
|
||
prepared_text = _prepare_plate_gcode(source, options, patch_config)
|
||
prepared_gcode_cache[cache_key] = prepared_text
|
||
# 后处理:M73 板号偏移 + 换板块注入
|
||
processed.append(
|
||
_process_prepared_plate_gcode(
|
||
prepared_text,
|
||
plate_number,
|
||
len(sources),
|
||
options,
|
||
swap_gcode_text,
|
||
patch_config,
|
||
)
|
||
)
|
||
# 第五步:拼接所有板层 G-code
|
||
final_gcode = "\n".join(item.rstrip("\n") for item in processed) + "\n"
|
||
gcode_bytes = apply_line_ending(final_gcode, options.line_ending)
|
||
# 第六步:写入输出 3MF 文件(通过临时文件保证原子性)
|
||
output_3mf = options.output_3mf
|
||
output_3mf.parent.mkdir(parents=True, exist_ok=True)
|
||
with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf", dir=str(output_3mf.parent)) as temp_file:
|
||
temp_path = Path(temp_file.name)
|
||
try:
|
||
md5 = write_output_3mf(sources[0].source_3mf, temp_path, gcode_bytes, sources, options)
|
||
# 原子性替换:先在临时文件完成写入,再移动到目标路径
|
||
shutil.move(str(temp_path), str(output_3mf))
|
||
finally:
|
||
# 确保清理临时文件
|
||
if temp_path.exists():
|
||
temp_path.unlink(missing_ok=True)
|
||
# 汇总统计信息
|
||
total_prediction = sum(value for value in (source.prediction_seconds for source in sources) if value is not None)
|
||
total_weight = sum(value for value in (source.weight_grams for source in sources) if value is not None)
|
||
return BuildResult(
|
||
output_3mf=output_3mf,
|
||
plate_count=len(sources),
|
||
total_prediction_seconds=total_prediction if total_prediction > 0 else None,
|
||
total_weight_grams=total_weight if total_weight > 0 else None,
|
||
gcode_md5=md5,
|
||
)
|