docs: 为全部13个源码模块和5个测试文件添加简体中文注释
- 每个文件补充模块级 docstring、类/函数 docstring、关键逻辑行内注释 - 注释风格:"中文说明" 或 # 中文说明 - 代码结构和逻辑完全不变
This commit is contained in:
@@ -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
|
||||
|
||||
# PIL(Python 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:
|
||||
# 策略5:Metadata/ 下任意 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(
|
||||
|
||||
Reference in New Issue
Block a user