Files
yw1573 4ae33cbe53 docs: 为全部13个源码模块和5个测试文件添加简体中文注释
- 每个文件补充模块级 docstring、类/函数 docstring、关键逻辑行内注释
- 注释风格:"中文说明" 或 # 中文说明
- 代码结构和逻辑完全不变
2026-07-28 09:39:55 +08:00

829 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""换板 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
# PILPython 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:
# 策略5Metadata/ 下任意 PNG
candidates = [
name
for name in names
if name.lower().startswith("metadata/") and name.lower().endswith(".png")
]
if not candidates:
return None
# 按排序键取第一个(缩略图优先,板号升序)
return sorted(candidates, key=preview_member_sort_key)[0]
def read_source_preview_image(source: PlateSource, small: bool = False) -> object | None:
"""从源 3MF 文件中读取板层预览图(PIL Image 对象)。
输入:
source: 板层数据源。
small: True 时优先读取缩略图。
输出:
RGBA 模式的 PIL Image 对象,读取失败或 PIL 不可用时返回 None。
"""
if Image is None:
return None
import io
try:
with zipfile.ZipFile(source.source_3mf, "r") as archive:
member = source_preview_member(archive, source, small)
if member is None:
return None
data = archive.read(member)
return Image.open(io.BytesIO(data)).convert("RGBA")
except Exception:
return None
def preview_grid_dimensions(count: int) -> tuple[int, int]:
"""根据图片数量计算合成预览图的最佳网格布局 (列数, 行数)。
输入:
count: 要排列的图片数量。
输出:
(columns, rows) 元组。布局规则:
- 1 张 -> (1, 1)
- 2 张 -> (2, 1) 横向排列
- 3-4 张 -> (2, 2) 田字形
- 5+ 张 -> 最多 3 列,行数向上取整
"""
if count <= 1:
return (1, 1)
if count <= 2:
return (2, 1)
if count <= 4:
return (2, 2)
return (3, (count + 2) // 3)
def resize_image_to_fit(image: object, width: int, height: int) -> object:
"""将 PIL Image 等比缩放到指定宽高范围内(保持纵横比)。
输入:
image: PIL Image 对象。
width: 目标最大宽度。
height: 目标最大高度。
输出:
缩放后的 PIL Image 副本。
"""
resized = image.copy()
# 优先使用 LANCZOS 重采样(高质量),回退到 BICUBIC
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS", Image.BICUBIC)
resized.thumbnail((max(1, width), max(1, height)), resampling)
return resized
def compose_preview_image(png_bytes: bytes, sources: list[PlateSource], label: str, small: bool = False) -> bytes:
"""将多个板源的预览图合成为一张网格拼图并叠加文字标签。
工作流程:
1. 以原始预览图为画布基础(取尺寸)。
2. 从各源文件中读取缩略图(去重限数量)。
3. 按网格布局排列缩略图到画布上。
4. 叠加文字标签。
输入:
png_bytes: 原始预览图 PNG 字节数据(用于确定画布尺寸)。
sources: 所有板源列表(内部会自动去重限数量)。
label: 要叠加的文字标签。
small: 是否使用缩略图尺寸。
输出:
合成后的 PNG 字节数据。
"""
if Image is None:
return png_bytes
import io
try:
base_image = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
except Exception:
return update_preview_label(png_bytes, label, small)
# 读取所有去重后板源的预览图
source_images = [
image
for image in (read_source_preview_image(source, small) for source in select_preview_sources(sources))
if image is not None
]
if not source_images:
# 无可用预览图,仅叠加标签
return update_preview_label(png_bytes, label, small)
# 创建透明画布,尺寸与原始预览图一致
width, height = base_image.size
canvas = Image.new("RGBA", (width, height), (255, 255, 255, 0))
columns, rows = preview_grid_dimensions(len(source_images))
# 逐张放置缩略图到网格对应位置,居中摆放
for index, image in enumerate(source_images):
column = index % columns
row = index // columns
left = width * column // columns
right = width * (column + 1) // columns
top = height * row // rows
bottom = height * (row + 1) // rows
fitted = resize_image_to_fit(image, right - left, bottom - top)
x = left + max(0, (right - left - fitted.width) // 2)
y = top + max(0, (bottom - top - fitted.height) // 2)
# 使用自身 alpha 通道粘贴,保持透明
canvas.paste(fitted, (x, y), fitted)
return update_preview_label(save_png_bytes(canvas), label, small)
# ---------------------------------------------------------------------------
# 输出成员名解析
# ---------------------------------------------------------------------------
def resolve_output_gcode_member(archive: zipfile.ZipFile, fallback_member: str) -> str:
"""解析最终输出 3MF 中应使用的 G-code 成员名。
Bambu Studio 的 model_settings.config 可能指定了 G-code 成员名,
若该成员实际存在则优先使用;否则回退到 fallback_member。
输入:
archive: 源 3MF ZIP 归档。
fallback_member: 回退成员名。
输出:
最终应使用的 G-code 成员名称。
"""
# 从 model_settings.config 读取已配置的 G-code 成员列表
configured_members = read_model_settings_gcode_members(archive)
if fallback_member in configured_members:
return fallback_member
existing_members = set(list_gcode_members(archive))
# 在已配置成员中查找实际存在的第一个
for member in configured_members:
if member in existing_members:
return member
# 都不存在则使用配置中的第一个成员名
if configured_members:
return configured_members[0]
return fallback_member
def preview_members_for_gcode_member(gcode_member: str) -> set[str]:
"""获取与给定 G-code 成员对应的所有预览图成员名集合。
输入:
gcode_member: G-code 成员路径。
输出:
包含普通预览图和缩略图成员名的集合,无法推导时返回空集。
"""
full_member = preview_member_name_for_gcode_member(gcode_member, small=False)
small_member = preview_member_name_for_gcode_member(gcode_member, small=True)
if full_member is None or small_member is None:
return set()
return {full_member, small_member}
# ---------------------------------------------------------------------------
# ZIP 写入流程
# ---------------------------------------------------------------------------
def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sources: list[PlateSource], options: BuildOptions) -> str:
"""将合并后的 G-code 写入新的 3MF 文件。
处理步骤:
1. 计算最终 G-code 的 MD5 校验和。
2. 基于源 3MF 创建新的 ZIP 归档(使用 zlib-ng 加速)。
3. 复制所有非 G-code 成员到新归档,对特定成员做处理:
- slice_info.config: 更新为多板信息。
- 预览图 PNG: 如启用标签,合成为多板拼接预览图。
4. 写入合并后的 G-code 及对应的 MD5 文件。
输入:
base_3mf: 源 3MF 文件路径。
output_3mf: 输出 3MF 文件路径。
gcode_bytes: 合并打包后的完整 G-code 字节数据。
sources: 所有板源列表。
options: 构建选项。
输出:
最终 G-code 的 MD5 十六进制字符串。
"""
# 计算 G-code 的 MD5 校验和
md5 = hashlib.md5(gcode_bytes).hexdigest()
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,
)