docs: 为全部13个源码模块和5个测试文件添加简体中文注释

- 每个文件补充模块级 docstring、类/函数 docstring、关键逻辑行内注释
- 注释风格:"中文说明" 或 # 中文说明
- 代码结构和逻辑完全不变
This commit is contained in:
2026-07-28 09:39:55 +08:00
parent b7a6f42189
commit 4ae33cbe53
18 changed files with 1528 additions and 76 deletions
+394
View File
@@ -1,3 +1,15 @@
"""换板 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
@@ -31,6 +43,8 @@ from .metadata import (
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
@@ -38,19 +52,49 @@ except Exception: # pragma: no cover
ImageDraw = None
ImageFont = None
# ---------------------------------------------------------------------------
# 模块级常量
# ---------------------------------------------------------------------------
# 匹配 Metadata/plate_N.png 或 Metadata/plate_N_small.png 的正则
PREVIEW_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)
# 合成预览图中最多展示的源文件数量(防止网格过于密集)
MAX_COMPOSITE_PREVIEW_INPUTS = 9
# ZIP 压缩等级合法范围
MIN_ZIP_COMPRESS_LEVEL = 1
MAX_ZIP_COMPRESS_LEVEL = 9
# ---------------------------------------------------------------------------
# 数据结构
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _PreparedPlateGcode:
"""预处理后的板层 G-code 数据(内部使用,不可变)。
属性:
text: 规范化并可选打过 G-code 补丁的 G-code 文本。
m73_template: 若启用板号显示,则为 M73 偏移模板,否则为 None。
"""
text: str
m73_template: M73OffsetTemplate | None = None
# ---------------------------------------------------------------------------
# 工具函数
# ---------------------------------------------------------------------------
def normalized_zip_compress_level(level: int | None) -> int:
"""将 ZIP 压缩等级规范化为合法范围内的整数值。
输入:
level: 用户指定的压缩等级(0-9 或 None)。
输出:
夹逼在 [MIN_ZIP_COMPRESS_LEVEL, MAX_ZIP_COMPRESS_LEVEL] 之间的整数。
若 level 为 None 或类型错误,则使用默认值 DEFAULT_ZIP_COMPRESS_LEVEL。
"""
try:
value = int(level if level is not None else DEFAULT_ZIP_COMPRESS_LEVEL)
except (TypeError, ValueError):
@@ -59,6 +103,17 @@ def normalized_zip_compress_level(level: int | None) -> int:
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
@@ -68,6 +123,17 @@ def load_zlib_ng_module() -> object:
@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:
@@ -76,14 +142,32 @@ def use_zlib_ng_for_zipfile() -> object:
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:
@@ -93,6 +177,7 @@ def load_plate_sources(job: PlateJob) -> list[PlateSource]:
text = raw.decode("utf-8-sig", errors="replace")
metadata = plate_metadata.get(plate_index, {})
filament = filament_metadata.get(plate_index, {})
# 重量优先取 metadata.weight,其次取 filament.used_g
weight = safe_float(metadata.get("weight"))
used_g = safe_float(filament.get("used_g"))
sources.append(
@@ -110,6 +195,20 @@ def load_plate_sources(job: PlateJob) -> list[PlateSource]:
def expand_jobs(jobs: list[PlateJob]) -> list[PlateSource]:
"""展开 Job 列表,按 copies 数量复制每个板层为多个 PlateSource。
例如,若某 PlateJob 有 2 个 copies 且包含 3 个板层,
则展开后产生 2 * 3 = 6 个 PlateSource。
输入:
jobs: PlateJob 列表,每个可指定 copies > 0。
输出:
按顺序展开后的 PlateSource 列表。
异常:
ValueError: 若展开后为空(没有任何输入板)。
"""
expanded: list[PlateSource] = []
for job in jobs:
sources = load_plate_sources(job)
@@ -120,6 +219,10 @@ def expand_jobs(jobs: list[PlateJob]) -> list[PlateSource]:
return expanded
# ---------------------------------------------------------------------------
# G-code 加工流水线
# ---------------------------------------------------------------------------
def process_plate_gcode(
source: PlateSource,
plate_number: int,
@@ -128,6 +231,19 @@ def process_plate_gcode(
swap_gcode_text: str,
patch_config: GcodePatchConfig,
) -> str:
"""完整的 G-code 加工流水线:预处理 + 换板注入。
输入:
source: 板层数据源(含 G-code 文本)。
plate_number: 当前板号(从 1 开始)。
total_plates: 总板数。
options: 构建选项(控制板号显示、换板行为、行尾格式等)。
swap_gcode_text: 换板 G-code 模板文本。
patch_config: G-code 补丁配置。
输出:
加工完成后的单板 G-code 文本字符串。
"""
prepared = _prepare_plate_gcode(source, options, patch_config)
return _process_prepared_plate_gcode(
prepared,
@@ -144,9 +260,22 @@ def _prepare_plate_gcode(
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)
@@ -159,21 +288,67 @@ def _process_prepared_plate_gcode(
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()
@@ -182,6 +357,19 @@ def save_png_bytes(image: object) -> bytes:
def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> bytes:
"""在预览图左下角叠加文字标签(如 "5 P" 表示 5 个板)。
自动适配系统可用字体(Windows Arial Bold / Linux DejaVu Sans Bold / macOS Arial Bold),
若全部缺失则回退到 PIL 默认字体。PIL 不可用时原样返回 PNG 字节。
输入:
png_bytes: 原始预览图 PNG 字节数据。
label: 要叠加的文字标签。
small: 是否为缩略图(True 时使用更小的字体)。
输出:
叠加文字后的 PNG 字节数据。
"""
if Image is None or ImageDraw is None:
return png_bytes
import io
@@ -191,8 +379,10 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b
except Exception:
return png_bytes
draw = ImageDraw.Draw(image)
# 根据图片尺寸和大小标志计算字体大小
size = max(16, image.width // (12 if small else 8))
font = None
# 按平台优先级尝试加载粗体系统字
for font_path in (
Path(os.environ.get("WINDIR", "")) / "Fonts" / "arialbd.ttf",
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
@@ -204,17 +394,31 @@ def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> b
break
except Exception:
font = None
# 所有系统字体都失败时,使用 PIL 内置默认字体
if font is None and ImageFont is not None:
font = ImageFont.load_default()
# 文字位置:左下角,留出边距
margin = max(8, image.width // 24)
text_bbox = draw.textbbox((0, 0), label, font=font)
x = margin
y = image.height - margin - (text_bbox[3] - text_bbox[1])
# 半透明绿色文字,保证在各种背景上都可见
draw.text((x, y), label, font=font, fill=(0, 255, 0, 255))
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
@@ -223,6 +427,14 @@ def preview_member_name_for_gcode_member(gcode_member: str, small: bool = False)
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
@@ -230,6 +442,17 @@ def preview_member_sort_key(member_name: str) -> tuple[int, int, str]:
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:
@@ -244,23 +467,46 @@ def select_preview_sources(sources: list[PlateSource], max_inputs: int = MAX_COM
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
@@ -268,10 +514,20 @@ def source_preview_member(archive: zipfile.ZipFile, source: PlateSource, small:
]
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
@@ -288,6 +544,18 @@ def read_source_preview_image(source: PlateSource, small: bool = False) -> objec
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:
@@ -298,13 +566,41 @@ def preview_grid_dimensions(count: int) -> tuple[int, int]:
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
@@ -314,17 +610,21 @@ def compose_preview_image(png_bytes: bytes, sources: list[PlateSource], label: s
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
@@ -335,25 +635,53 @@ def compose_preview_image(png_bytes: bytes, sources: list[PlateSource], label: s
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:
@@ -361,9 +689,35 @@ def preview_members_for_gcode_member(gcode_member: str) -> set[str]:
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,
@@ -371,36 +725,71 @@ def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sourc
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,
@@ -411,18 +800,23 @@ def build_packed_3mf(jobs: list[PlateJob], options: BuildOptions) -> BuildResult
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(