diff --git a/a1_swap_mod_packer/__init__.py b/a1_swap_mod_packer/__init__.py index d7dd811..9e50b54 100644 --- a/a1_swap_mod_packer/__init__.py +++ b/a1_swap_mod_packer/__init__.py @@ -1,3 +1,11 @@ +"""A1 Swap Mod Packer 主包入口模块。 + +定义应用程序的名称和版本信息,供其他模块引用。 +""" + +# 应用程序显示名称 APP_NAME = "A1 Swap Mod Packer" +# 语义化版本号 __version__ = "0.5.0" +# 窗口标题:应用名称 + 版本号 APP_TITLE = f"{APP_NAME} v{__version__}" diff --git a/a1_swap_mod_packer/archive.py b/a1_swap_mod_packer/archive.py index db79e34..2ce3448 100644 --- a/a1_swap_mod_packer/archive.py +++ b/a1_swap_mod_packer/archive.py @@ -1,16 +1,33 @@ +"""ZIP 归档文件中 G-code 成员的读取与解析。 + +本模块提供了从 .3mf 或 .zip 归档文件中列出 G-code 文件路径的功能, +主要用于识别 Metadata 目录下按 plate_<编号>.gcode 命名的换板 G-code 文件。""" + from __future__ import annotations import re import zipfile +# 匹配 Metadata 目录下 plate_<数字>.gcode 格式的文件名 GCODE_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$") +# 匹配对应的 MD5 校验文件 plate_<数字>.gcode.md5 MD5_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode\.md5$") def list_gcode_members(archive: zipfile.ZipFile) -> list[str]: + """列出归档文件中所有 plate G-code 成员,按板号升序排列。 + + 参数: + archive: 已打开的 ZIP 归档文件对象。 + + 返回: + 按 plate 编号从小到大排序的 G-code 文件名列表。 + """ members: list[tuple[int, str]] = [] for name in archive.namelist(): match = GCODE_MEMBER_RE.match(name) if match: + # 提取板号并存储 (板号, 文件名) 元组 members.append((int(match.group(1)), name)) + # 按板号升序排列后只返回文件名 return [name for _, name in sorted(members)] diff --git a/a1_swap_mod_packer/batch.py b/a1_swap_mod_packer/batch.py index ac85196..188e6c1 100644 --- a/a1_swap_mod_packer/batch.py +++ b/a1_swap_mod_packer/batch.py @@ -1,3 +1,9 @@ +"""批量构建模块:支持并行或串行地处理多个独立换板打包任务。 + +提供 dataclass 用于封装单个构建任务、失败信息和批量结果, +并包含工作进程数计算及并行/串行执行调度逻辑。 +""" + from __future__ import annotations import os @@ -8,39 +14,73 @@ from typing import Sequence from .builder import build_packed_3mf from .models import BuildOptions, BuildResult, PlateJob +# 独立批量构建的默认最大并行工作进程数 INDIVIDUAL_BATCH_MAX_WORKERS = 8 @dataclass(frozen=True) class IndividualBuildTask: + """单个独立构建任务,包含一个板作业和对应的构建选项。""" job: PlateJob + """要处理的板作业""" options: BuildOptions + """构建选项配置""" @dataclass(frozen=True) class IndividualBuildFailure: + """记录单个构建任务失败时的详细信息。""" index: int + """任务在原始列表中的索引""" job: PlateJob + """失败的板作业""" error: str + """错误描述信息""" @dataclass(frozen=True) class IndividualBatchBuildResult: + """一批独立构建任务的整体结果汇总。""" results: tuple[BuildResult, ...] + """成功完成的所有构建结果""" failures: tuple[IndividualBuildFailure, ...] + """所有失败的构建任务记录""" worker_count: int + """本次批量构建使用的并行工作进程数""" def individual_batch_worker_count(task_count: int, max_workers: int | None = None) -> int: + """根据任务数量计算合适的并行工作进程数。 + + 综合考虑用户指定的最大进程数、CPU 核心数和内置上限, + 返回一个合理的并行度。 + + Args: + task_count: 待执行的任务总数 + max_workers: 用户指定的最大并行工作进程数,为 None 时自动检测 + + Returns: + 应使用的并行工作进程数,任务数 <= 0 时返回 0 + """ if task_count <= 0: return 0 if max_workers is not None: + # 以用户指定值为上限,最少 1 个进程 return min(task_count, max(1, int(max_workers))) + # 自动检测:取任务数、CPU 核心数、内置上限三者中的最小值 detected_cpu_count = os.cpu_count() or 1 return min(task_count, max(1, detected_cpu_count), INDIVIDUAL_BATCH_MAX_WORKERS) def _build_individual_task(task: IndividualBuildTask) -> BuildResult: + """执行单个构建任务,调用核心构建函数。 + + Args: + task: 单个独立构建任务 + + Returns: + BuildResult: 构建结果 + """ return build_packed_3mf([task.job], task.options) @@ -48,12 +88,25 @@ def _run_individual_batch_serial( tasks: Sequence[IndividualBuildTask], worker_count: int, ) -> IndividualBatchBuildResult: + """串行方式执行一批独立构建任务。 + + 逐个执行任务,捕获每个任务的异常并记录为失败。 + + Args: + tasks: 要执行的任务序列 + worker_count: 工作进程数(此处仅用于结果记录) + + Returns: + IndividualBatchBuildResult: 包含成功和失败记录的批量结果 + """ results: list[BuildResult] = [] failures: list[IndividualBuildFailure] = [] for index, task in enumerate(tasks): try: + # 逐个同步执行构建 results.append(_build_individual_task(task)) except Exception as exc: + # 捕获任意异常,记录为失败项 failures.append(IndividualBuildFailure(index, task.job, str(exc))) return IndividualBatchBuildResult(tuple(results), tuple(failures), worker_count) @@ -62,29 +115,50 @@ def run_individual_batch_builds( tasks: Sequence[IndividualBuildTask], max_workers: int | None = None, ) -> IndividualBatchBuildResult: + """并行执行一批独立构建任务。 + + 当计算出的工作进程数 > 1 时,使用 ProcessPoolExecutor 并行处理; + 否则回退到串行执行。并行模式下按原始索引位置收集结果。 + + Args: + tasks: 要执行的任务序列 + max_workers: 最大并行工作进程数,为 None 时自动检测 + + Returns: + IndividualBatchBuildResult: 包含成功和失败记录的批量结果 + """ task_list = list(tasks) worker_count = individual_batch_worker_count(len(task_list), max_workers) + # 单进程或无任务时,使用串行路径 if worker_count <= 1: return _run_individual_batch_serial(task_list, worker_count) + # 预分配结果槽位,保证按原始顺序输出 ordered_results: list[BuildResult | None] = [None] * len(task_list) failures: list[IndividualBuildFailure] = [] + # 使用进程池并行执行 with ProcessPoolExecutor(max_workers=worker_count) as executor: + # 提交所有任务并记录 future 到索引的映射 future_to_index = { executor.submit(_build_individual_task, task): index for index, task in enumerate(task_list) } + # 按完成顺序收集结果 for future in as_completed(future_to_index): index = future_to_index[future] task = task_list[index] try: + # 获取结果,放入对应位置 ordered_results[index] = future.result() except Exception as exc: + # 捕获任务执行异常,记录为失败 failures.append(IndividualBuildFailure(index, task.job, str(exc))) + # 过滤掉未成功的位置(None),保留有效结果 results = tuple(result for result in ordered_results if result is not None) return IndividualBatchBuildResult( tuple(results), + # 按原始索引排序失败记录,便于阅读 tuple(sorted(failures, key=lambda item: item.index)), worker_count, ) diff --git a/a1_swap_mod_packer/builder.py b/a1_swap_mod_packer/builder.py index b5d706b..c7fa0e3 100644 --- a/a1_swap_mod_packer/builder.py +++ b/a1_swap_mod_packer/builder.py @@ -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( diff --git a/a1_swap_mod_packer/cli.py b/a1_swap_mod_packer/cli.py index ab1980f..419f801 100644 --- a/a1_swap_mod_packer/cli.py +++ b/a1_swap_mod_packer/cli.py @@ -1,3 +1,9 @@ +"""命令行接口模块:定义参数解析器和命令处理函数。 + +提供 `build`(构建打包 3MF)和 `list-swap-gcode`(列出换料 G-code 文件) +两个子命令,以及 `main` 入口函数。 +""" + from __future__ import annotations import argparse @@ -16,9 +22,22 @@ from .paths import default_patch_config_path, default_swap_gcode_dir def parse_item(values: list[str]) -> PlateJob: + """解析 --item 参数的键值对,生成一个 PlateJob 实例。 + + Args: + values: 包含两个字符串的列表 [路径, 份数] + + Returns: + PlateJob: 对应的板作业对象 + + Raises: + argparse.ArgumentTypeError: 参数格式或类型不合法时抛出 + """ + # --item 必须提供恰好两个值:路径和份数 if len(values) != 2: raise argparse.ArgumentTypeError("每个 --item 需要一个路径和一个份数。") path = Path(values[0]) + # 份数必须可以解析为整数 try: copies = int(values[1]) except ValueError as exc: @@ -27,13 +46,25 @@ def parse_item(values: list[str]) -> PlateJob: def build_command(args: argparse.Namespace) -> int: + """执行 'build' 子命令:收集输入、组装选项、调用构建。 + + Args: + args: 解析后的命令行命名空间 + + Returns: + int: 退出码,成功为 0 + """ jobs: list[PlateJob] = [] + # 处理 --item 参数(每个可独立指定份数) for item in args.item or []: jobs.append(parse_item(item)) + # 处理位置参数输入(共用 --copies 份数) for input_path in args.inputs or []: jobs.append(PlateJob(Path(input_path), args.copies)) + # 没有输入文件时直接报错退出 if not jobs: raise SystemExit("未提供输入 3MF 文件。") + # 根据 --no-bed-cooldown 决定是否写入床温指令 cool_bed_temp = None if args.no_bed_cooldown else args.cool_bed options = BuildOptions( swap_gcode=args.swap_gcode, @@ -49,7 +80,9 @@ def build_command(args: argparse.Namespace) -> int: swap_gcode_dir=Path(args.swap_gcode_dir) if args.swap_gcode_dir else None, zip_compress_level=args.zip_level, ) + # 调用核心构建函数 result = build_packed_3mf(jobs, options) + # 输出构建结果摘要 print(f"输出:{result.output_3mf}") print(f"板数:{result.plate_count}") print(f"G-code MD5:{result.gcode_md5}") @@ -61,8 +94,18 @@ def build_command(args: argparse.Namespace) -> int: def list_swap_gcode_command(args: argparse.Namespace) -> int: + """执行 'list-swap-gcode' 子命令:列出可用的换料 G-code 文件。 + + Args: + args: 解析后的命令行命名空间 + + Returns: + int: 退出码,成功为 0 + """ + # 解析目录:优先使用命令行参数,否则使用默认目录 directory = Path(args.swap_gcode_dir) if args.swap_gcode_dir else default_swap_gcode_dir() files = list_swap_gcode_files(directory) + # 目录为空时给出提示 if not files: print(f"在 {directory} 中未找到换料 G-code 文件") return 0 @@ -72,6 +115,15 @@ def list_swap_gcode_command(args: argparse.Namespace) -> int: def create_parser() -> argparse.ArgumentParser: + """创建并配置命令行参数解析器。 + + 包含两个子命令: + - build: 构建打包的 3MF 文件 + - list-swap-gcode: 列出换料 G-code 文件 + + Returns: + argparse.ArgumentParser: 配置完成的参数解析器 + """ parser = argparse.ArgumentParser( prog="a1-swap-mod-packer", description=f"{APP_TITLE} - 将重复的 Bambu A1 SwapMod 面板打包为一个 3MF 作业。", @@ -79,25 +131,34 @@ def create_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="version", version=f"{APP_NAME} {__version__}") subparsers = parser.add_subparsers(dest="command", required=True) + # ---- build 子命令 ---- build = subparsers.add_parser("build", help="构建一个打包的 3MF 文件。") + # 位置参数:输入文件路径(共用统一的份数) build.add_argument("inputs", nargs="*", help="输入 3MF 文件。使用 --copies 为所有文件设置相同的份数。") + # --item 参数:每个文件可独立指定份数 build.add_argument("--item", nargs=2, action="append", metavar=("PATH", "COPIES"), help="添加一个带有独立份数的输入 3MF 文件。可重复使用。") build.add_argument("-o", "--output", required=True, help="输出 3MF 路径。") + # 换料 G-code 相关 build.add_argument("--swap-gcode", required=True, help="换料 G-code 文件名,位于 swap_gcode 中,或显式文件路径。") build.add_argument("--swap-gcode-dir", default=None, help=f"模板目录。默认值:{default_swap_gcode_dir()}") + # 份数与温度 build.add_argument("--copies", type=int, default=1, help="位置参数输入文件的复制份数。") build.add_argument("--cool-bed", type=int, default=45, help="执行换料 G-code 前等待的床温。") build.add_argument("--no-bed-cooldown", action="store_true", help="不在换料代码前插入 M190。") + # 时间与编号 build.add_argument("--wait", type=int, default=45, help="板弹射后等待的秒数。") build.add_argument("--show-plate-number", action="store_true", help="每板号向 M73 R 值增加 100 小时。") + # 行为开关 build.add_argument("--no-swap-after-final", action="store_true", help="最后一块板后不执行换料 G-code。") build.add_argument("--metadata-mode", choices=("source", "sum"), default="source", help="如何写入 slice_info 的 prediction 和 weight。") build.add_argument("--line-ending", choices=("lf", "crlf"), default="crlf", help="生成 G-code 的换行符。") build.add_argument("--zip-level", type=int, choices=range(1, 10), default=DEFAULT_ZIP_COMPRESS_LEVEL, metavar="1-9", help="输出 3MF 的 zlib-ng Deflate 压缩级别。默认值:7。") build.add_argument("--no-preview-label", action="store_true", help="不重写预览图标签/合成图。") build.add_argument("--no-gcode-patches", action="store_true", help=f"不应用来自 {default_patch_config_path()} 的可编辑补丁。") + # 绑定命令处理函数 build.set_defaults(func=build_command) + # ---- list-swap-gcode 子命令 ---- list_cmd = subparsers.add_parser("list-swap-gcode", help="列出 swap_gcode 目录中的文件。") list_cmd.add_argument("--swap-gcode-dir", default=None, help=f"模板目录。默认值:{default_swap_gcode_dir()}") list_cmd.set_defaults(func=list_swap_gcode_command) @@ -105,11 +166,22 @@ def create_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: + """命令行入口函数:解析参数并分发到对应的子命令处理函数。 + + Args: + argv: 命令行参数列表,为 None 时使用 sys.argv + + Returns: + int: 程序退出码,0 表示成功,1 表示失败 + """ + # 构建解析器并解析参数 parser = create_parser() args = parser.parse_args(argv) try: + # 根据子命令调用绑定的处理函数 return int(args.func(args)) except Exception as exc: + # 异常信息输出到 stderr,返回非零退出码 print(f"错误:{exc}", file=sys.stderr) return 1 diff --git a/a1_swap_mod_packer/core.py b/a1_swap_mod_packer/core.py index 1452e2c..aa5756c 100644 --- a/a1_swap_mod_packer/core.py +++ b/a1_swap_mod_packer/core.py @@ -1,3 +1,9 @@ +"""核心公开 API 模块。 + +作为 a1_swap_mod_packer 包的统一入口,通过 __all__ 列表集中导出 +所有对外公开的符号(类、函数、常量),方便外部调用者从一个模块导入所有所需接口。 +""" + from __future__ import annotations from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members @@ -57,6 +63,7 @@ from .planning import ( three_mf_summary_from_mapping, ) +# 公开 API:集中管理所有对外的符号导出 __all__ = [ "DEFAULT_INSERT_BEFORE_MARKER", "DEFAULT_OUTPUT_PATTERN", diff --git a/a1_swap_mod_packer/gcode.py b/a1_swap_mod_packer/gcode.py index 3f1238c..16b84ef 100644 --- a/a1_swap_mod_packer/gcode.py +++ b/a1_swap_mod_packer/gcode.py @@ -1,3 +1,9 @@ +"""G-code 文件处理工具集。 + +本模块提供了 G-code 文本的读取、换行符标准化、时间/耗材格式化、 +M73 剩余时间偏移计算、换板代码块构建与插入等核心功能。 +所有对换板 G-code 的文本操作均通过本模块完成。""" + from __future__ import annotations import re @@ -7,14 +13,33 @@ from pathlib import Path from .models import DEFAULT_INSERT_BEFORE_MARKER, LineEnding from .paths import default_swap_gcode_dir +# 匹配 M73 剩余时间指令,捕获 P/R 参数中的分钟数值 +# 示例: M73 P10 R120 ; 表示当前进度 10%,剩余 120 分钟 M73_RE = re.compile(r"^(M73\s+P\d+\s+R)(\d+)(.*)$", re.MULTILINE) def normalize_newlines(text: str) -> str: + """将文本中的 CRLF 和 CR 统一转换为 LF 换行符。 + + 参数: + text: 待处理的原始文本。 + + 返回: + 使用 LF 换行的规范化文本。 + """ return text.replace("\r\n", "\n").replace("\r", "\n") def apply_line_ending(text: str, line_ending: LineEnding) -> bytes: + """将文本按指定的换行符风格编码为字节串。 + + 参数: + text: 待编码的文本。 + line_ending: 目标换行风格,"lf" 或 "crlf"。 + + 返回: + 使用指定换行符编码的 UTF-8 字节串。 + """ normalized = normalize_newlines(text) if line_ending == "crlf": normalized = normalized.replace("\n", "\r\n") @@ -22,10 +47,35 @@ def apply_line_ending(text: str, line_ending: LineEnding) -> bytes: def read_swap_gcode_file(path: Path) -> str: + """读取换板 G-code 文件内容,自动处理 BOM 头。 + + 参数: + path: G-code 文件路径。 + + 返回: + 文件的文本内容。 + """ return path.read_text(encoding="utf-8-sig", errors="replace") def resolve_swap_gcode_path(value: Path | str, swap_gcode_dir: Path | None = None) -> Path: + """将用户输入的文件名或路径解析为实际存在的换板 G-code 文件路径。 + + 解析策略(按优先级): + 1. 直接作为路径查找。 + 2. 在 swap_gcode_dir 目录下查找。 + 3. 无后缀名时依次尝试 .gcode, .nc, .ngc, .txt 后缀。 + + 参数: + value: 文件名字符串或路径。 + swap_gcode_dir: 默认搜索目录,为 None 时使用内置默认目录。 + + 返回: + 解析后的文件路径。 + + 异常: + FileNotFoundError: 无法在任何位置找到该文件。 + """ raw = Path(value) if raw.exists(): return raw @@ -33,6 +83,7 @@ def resolve_swap_gcode_path(value: Path | str, swap_gcode_dir: Path | None = Non candidate = search_dir / str(value) if candidate.exists(): return candidate + # 无后缀名时自动尝试常见 G-code 后缀 if raw.suffix: raise FileNotFoundError(f"Swap G-code file not found: {value}") for suffix in (".gcode", ".nc", ".ngc", ".txt"): @@ -43,14 +94,31 @@ def resolve_swap_gcode_path(value: Path | str, swap_gcode_dir: Path | None = Non def list_swap_gcode_files(directory: Path | None = None) -> list[Path]: + """列出指定目录下所有可用的换板 G-code 文件。 + + 参数: + directory: 要扫描的目录,为 None 时使用内置默认目录。 + + 返回: + 按名称排序的 G-code 文件路径列表。 + """ root = directory or default_swap_gcode_dir() if not root.exists(): return [] allowed = {".gcode", ".nc", ".ngc", ".txt"} + # 过滤掉隐藏文件(以 . 开头),只保留允许的后缀名 return sorted(path for path in root.iterdir() if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in allowed) def format_duration(seconds: float | None) -> str: + """将秒数格式化为易读的时间字符串。 + + 参数: + seconds: 秒数,可为 None。 + + 返回: + 格式化后的时间字符串,如 "2h 30m"、"5m 20s"、"45s",未知则返回 "Unknown"。 + """ if seconds is None: return "Unknown" total = max(0, int(round(seconds))) @@ -64,6 +132,15 @@ def format_duration(seconds: float | None) -> str: def format_filament(weight_grams: float | None, used_m: float | None = None) -> str: + """将耗材重量和长度格式化为易读字符串。 + + 参数: + weight_grams: 耗材重量(克),可为 None。 + used_m: 耗材使用长度(米),可为 None。 + + 返回: + 格式化后的耗材信息字符串,未知则返回 "Unknown"。 + """ if weight_grams is None and used_m is None: return "Unknown" parts: list[str] = [] @@ -76,10 +153,30 @@ def format_filament(weight_grams: float | None, used_m: float | None = None) -> @dataclass(frozen=True) class M73OffsetTemplate: + """M73 剩余时间偏移模板。 + + 用于在合并多板 G-code 时,将各板的 M73 剩余时间递增偏移, + 使合并后的 G-code 中每块板的剩余时间估算保持连续。 + + 属性: + segments: M73 指令之间的文本片段(含首尾)。 + base_remaining_minutes: 每个 M73 指令的原始剩余分钟数。 + """ segments: tuple[str, ...] base_remaining_minutes: tuple[int, ...] def apply(self, plate_number: int) -> str: + """根据板号生成偏移后的 M73 指令文本。 + + 偏移量 = plate_number * 100 * 60(单位:分钟), + 即每块板增加 100 小时的剩余时间偏移。 + + 参数: + plate_number: 板号(从 0 开始)。 + + 返回: + 应用偏移后的完整 G-code 文本片段。 + """ if not self.base_remaining_minutes: return self.segments[0] minute_offset = plate_number * 100 * 60 @@ -92,40 +189,88 @@ class M73OffsetTemplate: def prepare_m73_offset_template(gcode: str) -> M73OffsetTemplate: + """从 G-code 文本中提取 M73 剩余时间偏移模板。 + + 参数: + gcode: 原始换板 G-code 文本。 + + 返回: + 可用于多板偏移计算的 M73OffsetTemplate 实例。 + """ segments: list[str] = [] base_remaining_minutes: list[int] = [] last_index = 0 for match in M73_RE.finditer(gcode): + # 保存 M73 指令前的文本片段 segments.append(gcode[last_index : match.start(2)]) + # 提取原始剩余分钟数 base_remaining_minutes.append(int(match.group(2))) last_index = match.end(2) + # 保存最后一段尾部文本 segments.append(gcode[last_index:]) return M73OffsetTemplate(tuple(segments), tuple(base_remaining_minutes)) def apply_plate_number_offset(gcode: str, plate_number: int) -> str: + """对 G-code 应用板号偏移,更新所有 M73 剩余时间。 + + 参数: + gcode: 原始换板 G-code 文本。 + plate_number: 板号(从 0 开始)。 + + 返回: + 偏移后的 G-code 文本。 + """ return prepare_m73_offset_template(gcode).apply(plate_number) def build_swap_block(swap_gcode: str, cool_bed_temp: int | None, wait_seconds: int) -> str: + """构建换板代码块。 + + 由可选的热床降温 M190 指令、换板 G-code 主体和可选的暂停等待 G4 指令组成。 + + 参数: + swap_gcode: 换板 G-code 主体内容。 + cool_bed_temp: 目标热床温度(°C),为 None 时不插入降温指令。 + wait_seconds: 换板后等待秒数,0 时不插入等待指令。 + + 返回: + 完整的换板代码块字符串。 + """ parts: list[str] = [] if cool_bed_temp is not None: parts.append(f"M190 S{int(cool_bed_temp)}") parts.append(normalize_newlines(swap_gcode).rstrip("\n")) if wait_seconds > 0: parts.append(f"G4 P{int(wait_seconds) * 1000}") + # 过滤空字符串后拼接,末尾加换行 return "\n".join(part for part in parts if part) + "\n" def insert_swap_block(gcode: str, swap_block: str, insert_before_marker: str = DEFAULT_INSERT_BEFORE_MARKER) -> str: + """在原始 G-code 中的指定标记之前插入换板代码块。 + + 如果找不到标记,则将换板代码块追加到 G-code 末尾。 + + 参数: + gcode: 原始 G-code 文本。 + swap_block: 要插入的换板代码块。 + insert_before_marker: 插入位置标记,在该标记所在行之前插入。 + + 返回: + 插入换板代码块后的 G-code 文本。 + """ text = normalize_newlines(gcode) marker = insert_before_marker.strip() if marker: + # 查找标记行,支持换行后的标记或文件开头的标记 marker_index = text.find("\n" + marker) if marker_index == -1 and text.startswith(marker): marker_index = 0 if marker_index != -1: + # 在标记行之前插入换板代码块 prefix = text[: marker_index + (1 if marker_index > 0 else 0)] suffix = text[marker_index + (1 if marker_index > 0 else 0) :] return prefix + swap_block + suffix + # 未找到标记时追加到末尾 return text.rstrip("\n") + "\n" + swap_block diff --git a/a1_swap_mod_packer/gui.py b/a1_swap_mod_packer/gui.py index a69647e..8ceaeab 100644 --- a/a1_swap_mod_packer/gui.py +++ b/a1_swap_mod_packer/gui.py @@ -1,3 +1,15 @@ +""" +A1 SwapMod Packer 的 PySide6 图形用户界面模块。 + +提供完整的桌面 GUI,用于: +- 拖放/添加多个 .3mf 输入文件 +- 配置换料 G-code、打包选项、输出规则 +- 单个或批量构建打包的 3MF 文件 +- 显示板缩略图预览、合计统计和构建日志 + +主入口为 ``main()`` 函数,启动 QApplication 并显示 ``MainWindow``。 +""" + from __future__ import annotations import json @@ -10,6 +22,7 @@ from multiprocessing import freeze_support from pathlib import Path from typing import Any, Callable +# --- 平台适配:Windows 下使用 FreeType 字体引擎以避免字体渲染问题 --- if sys.platform.startswith("win") and "QT_QPA_PLATFORM" not in os.environ: os.environ["QT_QPA_PLATFORM"] = "windows:fontengine=freetype" @@ -71,19 +84,26 @@ from .planning import ( three_mf_summary_from_mapping, ) -SUMMARY_ROLE = Qt.UserRole + 100 -PATH_ROLE = Qt.UserRole + 101 +# --- 自定义 Qt 数据角色,用于在表格单元格中存储摘要和路径信息 --- +SUMMARY_ROLE = Qt.UserRole + 100 # 存储 ThreeMfSummary 数据的角色 +PATH_ROLE = Qt.UserRole + 101 # 存储文件绝对路径的角色 -ORDER_COLUMN = 0 -FILE_COLUMN = 1 -COPIES_COLUMN = 2 -TIME_COLUMN = 3 -FILAMENT_COLUMN = 4 +# --- 表格列索引常量 --- +ORDER_COLUMN = 0 # 顺序列(上移/下移按钮) +FILE_COLUMN = 1 # 文件名列 +COPIES_COLUMN = 2 # 份数列 +TIME_COLUMN = 3 # 预估时间列 +FILAMENT_COLUMN = 4 # 耗材用量列 +# --- 3MF 预览图匹配正则:Metadata/plate_N.png 或 Metadata/plate_N_small.png --- PREVIEW_IMAGE_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE) def preview_image_sort_key(member_name: str) -> tuple[int, int, str]: + """返回预览图排序键:(是否小图, 板号, 原始名称)。 + + 排序规则:大图优先于小图,板号升序,同号按名称排序。 + """ match = PREVIEW_IMAGE_RE.match(member_name) plate_number = int(match.group(1)) if match else 9999 is_small = 1 if member_name.lower().endswith("_small.png") else 0 @@ -91,6 +111,10 @@ def preview_image_sort_key(member_name: str) -> tuple[int, int, str]: def first_preview_image_member(member_names: list[str], gcode_member: str | None = None) -> str | None: + """从 3MF 存档成员列表中查找最佳预览图。 + + 优先匹配与指定 gcode_member 关联的预览图,其次匹配所有 plate_N.png。 + """ available_members = set(member_names) if gcode_member is not None: candidates = [name for name in preview_members_for_gcode_member(gcode_member) if name in available_members] @@ -109,10 +133,17 @@ def first_preview_image_member(member_names: list[str], gcode_member: str | None class SuccessToast(QLabel): - FADE_MS = 1000 - HOLD_MS = 5000 + """构建成功时的浮动提示条组件。 + + 提供淡入-停留-淡出动画效果,自动居中于父窗口底部。 + 不会拦截鼠标事件,点击可穿透到下层控件。 + """ + + FADE_MS = 1000 # 淡入/淡出动画时长(毫秒) + HOLD_MS = 5000 # 停留显示时长(毫秒) def __init__(self, parent: QWidget) -> None: + """初始化浮动提示条,创建透明度动画和停留计时器。""" super().__init__(parent) self.setAlignment(Qt.AlignCenter) self.setWordWrap(True) @@ -132,20 +163,25 @@ class SuccessToast(QLabel): ) self._opacity_effect = QGraphicsOpacityEffect(self) self.setGraphicsEffect(self._opacity_effect) + # 淡入动画 self._fade_in = QPropertyAnimation(self._opacity_effect, b"opacity", self) self._fade_in.setDuration(self.FADE_MS) self._fade_in.setEasingCurve(QEasingCurve.InOutQuad) + # 淡出动画 self._fade_out = QPropertyAnimation(self._opacity_effect, b"opacity", self) self._fade_out.setDuration(self.FADE_MS) self._fade_out.setEasingCurve(QEasingCurve.InOutQuad) self._fade_out.finished.connect(self.hide) + # 停留计时器 self._hold_timer = QTimer(self) self._hold_timer.setSingleShot(True) self._hold_timer.timeout.connect(self._start_fade_out) + # 监听父窗口大小变化,自动重新定位 parent.installEventFilter(self) self.hide() def show_message(self, message: str) -> None: + """显示指定消息文本,启动淡入-停留-淡出动画序列。""" self._hold_timer.stop() self._fade_in.stop() self._fade_out.stop() @@ -161,12 +197,14 @@ class SuccessToast(QLabel): self._hold_timer.start(self.FADE_MS + self.HOLD_MS) def eventFilter(self, watched: object, event: QEvent) -> bool: + """监听父窗口 Resize 事件,自动调整提示条尺寸和位置。""" if watched is self.parentWidget() and event.type() == QEvent.Resize and self.isVisible(): self._fit_to_parent() self._position() return super().eventFilter(watched, event) def _fit_to_parent(self) -> None: + """根据父窗口宽度自适应提示条的最大宽度。""" parent = self.parentWidget() if parent is None: return @@ -174,6 +212,7 @@ class SuccessToast(QLabel): self.adjustSize() def _position(self) -> None: + """将提示条定位到父窗口底部居中位置。""" parent = self.parentWidget() if parent is None: return @@ -183,6 +222,7 @@ class SuccessToast(QLabel): self.move(x, y) def _start_fade_out(self) -> None: + """启动淡出动画,从当前透明度渐变至完全透明。""" self._fade_out.stop() self._fade_out.setStartValue(self._opacity_effect.opacity()) self._fade_out.setEndValue(0.0) @@ -190,12 +230,24 @@ class SuccessToast(QLabel): class DropTableWidget(QTableWidget): + """支持拖放 .3mf 文件的表格控件。 + + 允许用户将 .3mf 文件或文件夹从文件管理器拖放到表格中。 + 支持按 Delete 键删除选中行。 + """ + def __init__( self, on_files_dropped: Callable[[list[Path]], None], on_delete_pressed: Callable[[], None], parent: QWidget | None = None, ) -> None: + """初始化拖放表格。 + + 参数: + on_files_dropped: 文件拖放完成时的回调,接收 Path 列表。 + on_delete_pressed: 按下 Delete 键时的回调。 + """ super().__init__(parent) self.on_files_dropped = on_files_dropped self.on_delete_pressed = on_delete_pressed @@ -205,6 +257,7 @@ class DropTableWidget(QTableWidget): self.setDragDropMode(QAbstractItemView.DropOnly) def keyPressEvent(self, event: QKeyEvent) -> None: + """处理键盘事件:Delete 键删除选中行。""" if event.key() == Qt.Key_Delete and self.selectedIndexes(): self.on_delete_pressed() event.accept() @@ -212,6 +265,7 @@ class DropTableWidget(QTableWidget): super().keyPressEvent(event) def eventFilter(self, watched: object, event: QEvent) -> bool: + """过滤视口拖放事件,仅接受包含 .3mf 文件的拖放操作。""" if watched is self.viewport(): if event.type() in {QEvent.DragEnter, QEvent.DragMove}: drag_event = event # type: ignore[assignment] @@ -228,18 +282,21 @@ class DropTableWidget(QTableWidget): return super().eventFilter(watched, event) def dragEnterEvent(self, event: QDragEnterEvent) -> None: + """拖入事件:仅接受包含 .3mf 文件的拖放。""" if self._has_3mf_urls(event): event.acceptProposedAction() return super().dragEnterEvent(event) def dragMoveEvent(self, event: QDragMoveEvent) -> None: + """拖移事件:仅接受包含 .3mf 文件的拖放。""" if self._has_3mf_urls(event): event.acceptProposedAction() return super().dragMoveEvent(event) def dropEvent(self, event: QDropEvent) -> None: + """释放事件:提取 .3mf 文件路径并触发回调。""" paths = self._paths_from_urls(event.mimeData().urls()) if paths: self.on_files_dropped(paths) @@ -248,11 +305,13 @@ class DropTableWidget(QTableWidget): super().dropEvent(event) def _has_3mf_urls(self, event: Any) -> bool: + """检查拖放事件是否包含至少一个 .3mf 文件 URL。""" if not event.mimeData().hasUrls(): return False return bool(self._paths_from_urls(event.mimeData().urls())) def _paths_from_urls(self, urls: list[QUrl]) -> list[Path]: + """从 URL 列表中提取 .3mf 文件路径,支持文件和文件夹。""" result: list[Path] = [] for url in urls: local_path = url.toLocalFile() @@ -267,15 +326,26 @@ class DropTableWidget(QTableWidget): class MainWindow(QMainWindow): + """应用程序主窗口。 + + 职责: + - 管理 3MF 输入文件列表(添加、移除、排序、份数调整) + - 提供打包选项配置界面 + - 显示缩略图预览和输出预览 + - 执行单个/批量构建并显示结果日志 + - 持久化用户设置到本地 JSON 文件 + """ + def __init__(self) -> None: + """初始化主窗口:创建 UI、加载设置、连接信号。""" super().__init__() self.setWindowTitle(APP_TITLE) self.resize(960, 720) self.setAcceptDrops(True) - self._updating_table = False - self._loading_settings = True + self._updating_table = False # 防重入标记:表格正在批量更新中 + self._loading_settings = True # 防重入标记:正在从设置恢复 UI 状态 self._settings = self.load_settings() - self._shared_growth_enabled = False + self._shared_growth_enabled = False # 窗口高度足够时,文件区和日志区共享扩展空间 self.build_ui() self.load_swap_gcode_to_combo() self.restore_settings_to_ui() @@ -284,7 +354,12 @@ class MainWindow(QMainWindow): self.update_total_summary() self.update_output_preview() + # ========================================================================= + # 设置存取 + # ========================================================================= + def load_settings(self) -> dict[str, Any]: + """从用户设置 JSON 文件加载设置,返回字典。""" path = user_settings_path() if path.exists(): try: @@ -295,20 +370,38 @@ class MainWindow(QMainWindow): return {} def save_settings(self) -> None: + """将当前设置字典写入用户设置 JSON 文件。""" path = user_settings_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(self._settings, indent=2), encoding="utf-8") + # ========================================================================= + # UI 构建 + # ========================================================================= + def build_ui(self) -> None: + """构建完整的主窗口用户界面。 + + 布局结构(自上而下): + 1. 文件输入区(表格 + 缩略图预览 + 操作按钮) + 2. 打包选项区(网格布局) + 3. 输出区(目录、文件名规则、预览) + 4. 日志区(只读文本框) + """ central = QWidget(self) root = QVBoxLayout(central) root.setSpacing(6) self.root_layout = root + # ===================================================================== + # --- 文件输入区 --- + # ===================================================================== file_group = QGroupBox("输入 3MF 文件") self.file_group = file_group file_layout = QVBoxLayout(file_group) file_body = QHBoxLayout() + + # --- 文件表格 --- table_layout = QVBoxLayout() self.table = DropTableWidget(self.add_paths, self.remove_selected) self.table.setColumnCount(5) @@ -329,10 +422,12 @@ class MainWindow(QMainWindow): self.table.itemSelectionChanged.connect(self.update_thumbnail_preview) table_layout.addWidget(self.table) + # --- 合计统计标签 --- self.total_summary_label = QLabel("合计:0 板 | 时间:未知 | 耗材:未知") table_layout.addWidget(self.total_summary_label) file_body.addLayout(table_layout, 1) + # --- 缩略图预览 --- preview_group = QGroupBox("选中文件的缩略图") preview_layout = QVBoxLayout(preview_group) self.thumbnail_label = QLabel("选择一个输入文件") @@ -356,6 +451,7 @@ class MainWindow(QMainWindow): file_body.addWidget(preview_group) file_layout.addLayout(file_body) + # --- 文件操作按钮栏 --- file_buttons = QHBoxLayout() add_button = QPushButton("添加 3MF") remove_button = QPushButton("移除") @@ -383,6 +479,9 @@ class MainWindow(QMainWindow): file_layout.addLayout(file_buttons) root.addWidget(file_group, 1) + # ===================================================================== + # --- 打包选项区 --- + # ===================================================================== options_group = QGroupBox("打包选项") grid = QGridLayout(options_group) grid.setColumnStretch(1, 1) @@ -391,6 +490,7 @@ class MainWindow(QMainWindow): grid.setVerticalSpacing(6) # ---- 控件创建 ---- + # 换料 G-code 选择 self.swap_gcode_combo = QComboBox() self.swap_gcode_combo.setMinimumWidth(260) self.swap_gcode_combo.setMaximumWidth(440) @@ -405,11 +505,13 @@ class MainWindow(QMainWindow): swap_gcode_row.addWidget(refresh_button) swap_gcode_row.addWidget(open_folder_button) + # 默认份数 self.default_copies_spin = QSpinBox() self.default_copies_spin.setRange(1, 9999) self.default_copies_spin.setValue(1) self.default_copies_spin.setFixedWidth(96) + # 热床降温 self.bed_cooldown_check = QCheckBox("等待热床降温") self.bed_cooldown_check.setChecked(True) self.cool_bed_spin = QSpinBox() @@ -421,18 +523,22 @@ class MainWindow(QMainWindow): bed_row.addWidget(QLabel("°C")) bed_row.addStretch(1) + # 弹射后等待时间 self.wait_spin = QSpinBox() self.wait_spin.setRange(0, 3600) self.wait_spin.setValue(45) self.wait_spin.setSuffix(" 秒") self.wait_spin.setFixedWidth(96) + # 剩余时间板号显示 self.show_plate_number_check = QCheckBox("在剩余时间的百位显示当前板号") self.show_plate_number_check.setChecked(True) + # 最后换料 self.swap_final_check = QCheckBox("最后一块板后也执行换料 G-code") self.swap_final_check.setChecked(True) + # G-code 补丁 self.patch_check = QCheckBox("应用可编辑的 G-code 补丁") self.patch_check.setToolTip("使用 gcode_patches.ini") self.patch_check.setChecked(True) @@ -443,11 +549,13 @@ class MainWindow(QMainWindow): patch_row.addWidget(open_patch_button) patch_row.addStretch(1) + # 3MF 元数据模式 self.metadata_combo = QComboBox() self.metadata_combo.addItem("保留原始预测和重量", "source") self.metadata_combo.addItem("累加预测和耗材用量", "sum") self.metadata_combo.setFixedWidth(260) + # ZIP 压缩级别 self.zip_level_combo = QComboBox() for level in range(1, 10): self.zip_level_combo.addItem(f"级别 {level}", level) @@ -455,11 +563,13 @@ class MainWindow(QMainWindow): self.zip_level_combo.setFixedWidth(120) self.zip_level_combo.setToolTip("输出 3MF 的 zlib-ng Deflate 压缩级别。") + # 独立批处理模式 self.individual_batch_check = QCheckBox("独立批处理模式") self.individual_batch_check.setToolTip( "将每行输入构建为独立的输出文件,使用该行的份数。" ) + # 构建后清空 / 跳过重复 self.clear_after_build_check = QCheckBox("构建成功后清空输入列表") self.clear_after_build_check.setChecked(False) self.skip_duplicates_check = QCheckBox("添加输入时跳过重复文件路径") @@ -469,44 +579,49 @@ class MainWindow(QMainWindow): input_handling_row.addWidget(self.clear_after_build_check) input_handling_row.addStretch(1) - # ---- 网格布局 ---- - # [换料 G-code | 跨全行] + # ---- 网格布局:4 列网格,每行两个选项组 ---- + # 第 0 行:[换料 G-code | 跨全行] grid.addWidget(QLabel("换料 G-code"), 0, 0) grid.addLayout(swap_gcode_row, 0, 1, 1, 3) - # 默认份数 | 热床降温 + # 第 1 行:默认份数 | 热床降温 grid.addWidget(QLabel("新输入的默认份数"), 1, 0) grid.addWidget(self.default_copies_spin, 1, 1) grid.addWidget(QLabel("热床降温"), 1, 2) grid.addLayout(bed_row, 1, 3) - # 弹射后等待 | 剩余时间板号 + # 第 2 行:弹射后等待 | 剩余时间板号 grid.addWidget(QLabel("弹射后等待时间"), 2, 0) grid.addWidget(self.wait_spin, 2, 1) grid.addWidget(QLabel("剩余时间板号"), 2, 2) grid.addWidget(self.show_plate_number_check, 2, 3) - # 最后换料 | G-code 补丁 + # 第 3 行:最后换料 | G-code 补丁 grid.addWidget(QLabel("最后换料"), 3, 0) grid.addWidget(self.swap_final_check, 3, 1) grid.addWidget(QLabel("G-code 补丁"), 3, 2) grid.addLayout(patch_row, 3, 3) - # 3MF 元数据 | ZIP 压缩 + # 第 4 行:3MF 元数据 | ZIP 压缩 grid.addWidget(QLabel("3MF 元数据"), 4, 0) grid.addWidget(self.metadata_combo, 4, 1) grid.addWidget(QLabel("ZIP 压缩"), 4, 2) grid.addWidget(self.zip_level_combo, 4, 3) - # 批处理模式 | 输入处理 + # 第 5 行:批处理模式 | 输入处理 grid.addWidget(QLabel("批处理模式"), 5, 0) grid.addWidget(self.individual_batch_check, 5, 1) grid.addWidget(QLabel("输入处理"), 5, 2) grid.addLayout(input_handling_row, 5, 3) root.addWidget(options_group) + # ===================================================================== + # --- 输出区 --- + # ===================================================================== output_group = QGroupBox("输出") output_layout = QFormLayout(output_group) + + # 输出目录 output_dir_row = QHBoxLayout() self.output_dir_edit = QLineEdit() self.output_dir_edit.setPlaceholderText("留空则写入输入文件所在目录") @@ -519,6 +634,7 @@ class MainWindow(QMainWindow): output_dir_row.addWidget(browse_output_dir_button) output_layout.addRow("输出目录", output_dir_row) + # 输出文件名规则 self.output_name_edit = QLineEdit(DEFAULT_OUTPUT_PATTERN) self.output_name_edit.setPlaceholderText("可使用 {source}、{sources}、{plates}、{copies}、{date}、{time} 等标记") self.output_name_edit.setMinimumWidth(280) @@ -532,10 +648,14 @@ class MainWindow(QMainWindow): filename_rule_row.addWidget(output_rule_help_button) output_layout.addRow("输出文件名规则", filename_rule_row) + # 输出预览 self.output_preview_label = QLabel("-") output_layout.addRow("预览", self.output_preview_label) root.addWidget(output_group) + # ===================================================================== + # --- 日志区 --- + # ===================================================================== self.log = QTextEdit() self.log.setReadOnly(True) self.log.setMinimumHeight(100) @@ -545,7 +665,16 @@ class MainWindow(QMainWindow): self.success_toast = SuccessToast(central) self.update_vertical_growth_policy() + # ========================================================================= + # 窗口事件 + # ========================================================================= + def update_vertical_growth_policy(self) -> None: + """根据窗口高度动态调整文件区和日志区的垂直扩展比例。 + + 窗口高度 >= 980px 时,文件区和日志区按 4:1 共享扩展空间; + 否则文件区独占扩展空间。 + """ if not hasattr(self, "root_layout") or not hasattr(self, "file_group") or not hasattr(self, "log"): return shared_growth = self.height() >= 980 @@ -556,11 +685,17 @@ class MainWindow(QMainWindow): self.root_layout.setStretchFactor(self.log, 1 if shared_growth else 0) def resizeEvent(self, event: QEvent) -> None: + """窗口大小变化时,更新垂直扩展策略并刷新缩略图。""" super().resizeEvent(event) self.update_vertical_growth_policy() self.update_thumbnail_preview() + # ========================================================================= + # 设置同步 + # ========================================================================= + def connect_option_signals(self) -> None: + """将所有选项控件的值变更信号连接到设置保存函数。""" self.swap_gcode_combo.currentIndexChanged.connect(self.save_current_settings) self.default_copies_spin.valueChanged.connect(self.save_current_settings) self.bed_cooldown_check.stateChanged.connect(self.save_current_settings) @@ -578,6 +713,7 @@ class MainWindow(QMainWindow): self.output_name_edit.textChanged.connect(self.on_output_rule_changed) def restore_settings_to_ui(self) -> None: + """从加载的设置字典恢复所有 UI 控件的状态。""" options = self._settings.get("packing_options", {}) if not isinstance(options, dict): return @@ -613,6 +749,7 @@ class MainWindow(QMainWindow): self.swap_gcode_combo.setCurrentIndex(index) def collect_current_settings(self) -> dict[str, Any]: + """从 UI 控件收集当前所有打包选项,返回设置字典。""" return { "swap_gcode": self.swap_gcode_combo.currentData() or "", "default_copies": self.default_copies_spin.value(), @@ -632,6 +769,7 @@ class MainWindow(QMainWindow): } def save_current_settings(self) -> None: + """将当前 UI 设置保存到本地 JSON 文件(加载阶段跳过)。""" if self._loading_settings: return self._settings["packing_options"] = self.collect_current_settings() @@ -639,10 +777,12 @@ class MainWindow(QMainWindow): self.save_settings() def on_output_rule_changed(self) -> None: + """输出规则变更时:更新输出预览并保存设置。""" self.update_output_preview() self.save_current_settings() def on_individual_batch_toggled(self, state: int) -> None: + """独立批处理模式切换时:显示说明对话框,更新预览并保存。""" self.update_output_preview() self.save_current_settings() if self._loading_settings: @@ -653,13 +793,14 @@ class MainWindow(QMainWindow): "独立批处理模式", "独立批处理模式将每一行输入作为独立的构建任务。\n\n" "示例:如果你添加了 20 个单板 3MF 文件并设置份数为 5," - "点击“构建 3MF”将创建 20 个独立的打包文件。" + "点击"构建 3MF"将创建 20 个独立的打包文件。" "每个输出文件仅包含该源文件重复 5 次的内容。\n\n" "这个功能适用于快速将大量独立的 3MF 作业批量转换为多份数的 " "SwapMod 包。它不会将所有输入文件合并到一个 3MF 中。", ) def show_output_rule_help(self) -> None: + """显示输出文件名规则的帮助对话框。""" QMessageBox.information( self, "输出文件名规则", @@ -675,7 +816,12 @@ class MainWindow(QMainWindow): "在独立批处理模式下,这些标记会针对每行输入单独计算。", ) + # ========================================================================= + # 换料 G-code 管理 + # ========================================================================= + def load_swap_gcode_to_combo(self) -> None: + """扫描换料 G-code 目录,填充下拉列表,恢复上次选择。""" current = self.swap_gcode_combo.currentData() or self._settings.get("last_swap_gcode") options = self._settings.get("packing_options", {}) if isinstance(options, dict): @@ -694,6 +840,7 @@ class MainWindow(QMainWindow): self.log.append(f"在 {default_swap_gcode_dir()} 中未找到换料 G-code 文件") def open_path(self, path: Path) -> None: + """跨平台打开文件或文件夹(Windows/Mac/Linux)。""" target = path if path.exists() else path.parent target.parent.mkdir(parents=True, exist_ok=True) if sys.platform.startswith("win"): @@ -704,22 +851,30 @@ class MainWindow(QMainWindow): subprocess.run(["xdg-open", str(target)], check=False) def open_swap_gcode_folder(self) -> None: + """在系统文件管理器中打开换料 G-code 目录。""" folder = default_swap_gcode_dir() folder.mkdir(parents=True, exist_ok=True) self.open_path(folder) def open_patch_config(self) -> None: + """打开 G-code 补丁配置文件。""" path = default_patch_config_path() if not path.exists(): QMessageBox.information(self, APP_NAME, f"补丁配置文件不存在:\n{path}") return self.open_path(path) + # ========================================================================= + # 文件输入管理(表格行操作) + # ========================================================================= + def add_files(self) -> None: + """通过文件对话框添加 3MF 文件到输入列表。""" files, _ = QFileDialog.getOpenFileNames(self, "添加 3MF 文件", "", "3MF 文件 (*.3mf);;所有文件 (*)") self.add_paths([Path(file_name) for file_name in files]) def create_order_widget(self, row: int) -> QWidget: + """创建行排序控件(上移/下移按钮 + 行号标签)。""" widget = QWidget() layout = QHBoxLayout(widget) layout.setContentsMargins(4, 0, 4, 0) @@ -751,11 +906,13 @@ class MainWindow(QMainWindow): return widget def update_order_controls(self) -> None: + """刷新所有行的排序控件和份数控件(行号、上下按钮状态)。""" for row in range(self.table.rowCount()): self.table.setCellWidget(row, ORDER_COLUMN, self.create_order_widget(row)) self.table.setCellWidget(row, COPIES_COLUMN, self.create_copies_spin(row, self.get_row_copies(row))) def create_copies_spin(self, row: int, copies: int) -> QSpinBox: + """创建份数微调框控件,绑定值变更信号。""" spin = QSpinBox() spin.setRange(1, 9999) spin.setFixedWidth(78) @@ -765,6 +922,7 @@ class MainWindow(QMainWindow): return spin def row_path(self, row: int) -> Path | None: + """获取指定行的 3MF 文件路径。""" item = self.table.item(row, FILE_COLUMN) if item is None: return None @@ -774,6 +932,7 @@ class MainWindow(QMainWindow): return Path(item.text()) def load_thumbnail_pixmap(self, path: Path) -> QPixmap | None: + """从 3MF 文件中加载预览缩略图。""" with zipfile.ZipFile(path, "r") as archive: gcode_members = list_gcode_members(archive) active_gcode_member = resolve_output_gcode_member(archive, gcode_members[0]) if gcode_members else None @@ -787,6 +946,7 @@ class MainWindow(QMainWindow): return pixmap def update_thumbnail_preview(self) -> None: + """更新缩略图预览:从当前选中行的 3MF 文件中提取预览图并显示。""" if not hasattr(self, "thumbnail_label"): return row = self.selected_row() @@ -812,6 +972,11 @@ class MainWindow(QMainWindow): self.thumbnail_label.setToolTip(str(path)) def add_paths(self, paths: list[Path]) -> None: + """批量添加 3MF 文件路径到输入表格。 + + 支持文件和文件夹(递归添加顶层 .3mf 文件)。 + 根据"跳过重复"设置过滤已存在的文件。 + """ expanded: list[Path] = [] for path in paths: if path.is_dir(): @@ -843,6 +1008,7 @@ class MainWindow(QMainWindow): self.update_output_preview() def add_file_row(self, file_name: str, copies: int) -> bool: + """向表格添加单行:读取 3MF 摘要,创建表格行并填充数据。""" path = Path(file_name) try: summary = read_3mf_summary(path) @@ -879,6 +1045,7 @@ class MainWindow(QMainWindow): return True def summary_to_dict(self, summary: ThreeMfSummary) -> dict[str, Any]: + """将 ThreeMfSummary 对象转换为可序列化的字典(用于 QTableWidgetItem 数据角色)。""" return { "source_3mf": str(summary.source_3mf), "plate_count": summary.plate_count, @@ -889,6 +1056,7 @@ class MainWindow(QMainWindow): } def base_summary_tooltip(self, summary: ThreeMfSummary) -> str: + """生成 3MF 文件的基本摘要信息(用于工具提示)。""" return ( f"基础板数:{summary.plate_count}\n" f"基础时间:{format_duration(summary.prediction_seconds)}\n" @@ -896,6 +1064,7 @@ class MainWindow(QMainWindow): ) def get_row_copies(self, row: int) -> int: + """获取指定行的份数值。""" widget = self.table.cellWidget(row, COPIES_COLUMN) if isinstance(widget, QSpinBox): return max(1, int(widget.value())) @@ -908,6 +1077,7 @@ class MainWindow(QMainWindow): return 1 def set_row_copies(self, row: int, copies: int) -> None: + """设置指定行的份数值(通过微调框或文本)。""" value = max(1, int(copies)) widget = self.table.cellWidget(row, COPIES_COLUMN) if isinstance(widget, QSpinBox): @@ -918,6 +1088,7 @@ class MainWindow(QMainWindow): item.setText(str(value)) def on_row_copies_changed(self, row: int, copies: int) -> None: + """份数变更时:刷新该行统计、总合计和输出预览。""" if self._updating_table: return self._updating_table = True @@ -929,6 +1100,7 @@ class MainWindow(QMainWindow): self.update_output_preview() def update_row_stats(self, row: int) -> None: + """根据当前份数重新计算并更新该行的时间列和耗材列。""" path_item = self.table.item(row, FILE_COLUMN) if path_item is None: return @@ -947,6 +1119,7 @@ class MainWindow(QMainWindow): self.table.item(row, FILAMENT_COLUMN).setText(format_filament(total_weight, total_used_m)) def on_table_item_changed(self, item: QTableWidgetItem) -> None: + """表格单元格内容变更事件:份数列手动编辑时同步更新统计。""" if self._updating_table: return if item.column() == COPIES_COLUMN: @@ -961,13 +1134,16 @@ class MainWindow(QMainWindow): self.update_output_preview() def selected_rows(self) -> list[int]: + """返回当前选中行的索引列表(去重、升序)。""" return sorted({index.row() for index in self.table.selectedIndexes()}) def selected_row(self) -> int | None: + """返回当前选中的第一行索引,无选中时返回 None。""" rows = self.selected_rows() return rows[0] if rows else None def remove_selected(self) -> None: + """删除选中的表格行,并智能选择下一行。""" rows = sorted(self.selected_rows(), reverse=True) next_row = min(rows) if rows else None for row in rows: @@ -980,6 +1156,7 @@ class MainWindow(QMainWindow): self.update_thumbnail_preview() def remove_all(self) -> None: + """清空所有输入行。""" self.table.setRowCount(0) self.update_order_controls() self.update_total_summary() @@ -987,12 +1164,14 @@ class MainWindow(QMainWindow): self.update_thumbnail_preview() def move_selected(self, delta: int) -> None: + """移动选中行(delta 为正则下移,为负则上移)。""" row = self.selected_row() if row is None: return self.move_row(row, delta) def move_row(self, row: int, delta: int) -> None: + """将指定行移动 delta 个位置,保持份数和其他数据不变。""" new_row = row + delta if new_row < 0 or new_row >= self.table.rowCount(): return @@ -1019,6 +1198,7 @@ class MainWindow(QMainWindow): self.update_thumbnail_preview() def apply_default_copies_to_selected(self) -> None: + """将默认份数应用到所有选中行。""" rows = self.selected_rows() if not rows: return @@ -1032,7 +1212,12 @@ class MainWindow(QMainWindow): self.update_total_summary() self.update_output_preview() + # ========================================================================= + # 输出配置 + # ========================================================================= + def choose_output_dir(self) -> None: + """通过文件夹选择对话框设置输出目录。""" start_dir = self.output_dir_edit.text().strip() first_path = self.row_path(0) if self.table.rowCount() > 0 else None if not start_dir and first_path is not None: @@ -1042,6 +1227,7 @@ class MainWindow(QMainWindow): self.output_dir_edit.setText(directory) def collect_jobs(self) -> list[PlateJob]: + """从表格中收集所有 PlateJob(文件路径 + 份数)。""" jobs: list[PlateJob] = [] for row in range(self.table.rowCount()): path = self.row_path(row) @@ -1051,6 +1237,7 @@ class MainWindow(QMainWindow): return jobs def summary_for_path(self, path: Path) -> ThreeMfSummary: + """根据文件路径获取 ThreeMfSummary(优先从缓存表格数据中读取)。""" normalized = str(path) for row in range(self.table.rowCount()): row_path = self.row_path(row) @@ -1064,15 +1251,18 @@ class MainWindow(QMainWindow): return read_3mf_summary(path) def output_naming_options(self) -> OutputNamingOptions: + """从 UI 控件收集输出命名选项。""" return OutputNamingOptions( output_directory=self.output_dir_edit.text().strip(), filename_rule=self.output_name_edit.text().strip() or DEFAULT_OUTPUT_PATTERN, ) def current_total_summary(self) -> OutputSummary: + """计算当前所有作业的合计摘要(板数、时间、耗材)。""" return summarize_jobs_for_output(self.collect_jobs(), self.summary_for_path) def update_total_summary(self) -> None: + """刷新底部合计统计标签。""" summary = self.current_total_summary() self.total_summary_label.setText( "合计:" @@ -1082,6 +1272,11 @@ class MainWindow(QMainWindow): ) def update_output_preview(self) -> None: + """刷新输出文件路径预览标签。 + + 合并模式下显示单个输出路径; + 独立批处理模式下显示"第一个输出路径 | N 个输出文件"。 + """ jobs = self.collect_jobs() if not jobs: self.output_preview_label.setText("-") @@ -1098,7 +1293,12 @@ class MainWindow(QMainWindow): except Exception as exc: self.output_preview_label.setText(str(exc)) + # ========================================================================= + # 构建流程 + # ========================================================================= + def build_options_for_output(self, output_path: Path) -> BuildOptions: + """根据当前 UI 选项构建 BuildOptions 对象。""" swap_gcode_path = self.swap_gcode_combo.currentData() if not swap_gcode_path: raise ValueError("请在 swap_gcode 文件夹中放入至少一个换料 G-code 文件并选择它。") @@ -1116,6 +1316,7 @@ class MainWindow(QMainWindow): ) def log_build_result(self, result: Any) -> None: + """将单次构建结果信息写入日志。""" self.log.append(f"输出:{result.output_3mf}") self.log.append(f"板数:{result.plate_count}") self.log.append(f"预估源文件时间:{format_duration(result.total_prediction_seconds)}") @@ -1123,9 +1324,11 @@ class MainWindow(QMainWindow): self.log.append(f"G-code MD5:{result.gcode_md5}") def show_success_toast(self, message: str) -> None: + """显示构建成功的浮动提示条。""" self.success_toast.show_message(message) def build_output(self) -> None: + """主构建入口:根据批处理模式分发到合并构建或独立构建。""" jobs = self.collect_jobs() if not jobs: QMessageBox.warning(self, APP_NAME, "请至少添加一个 3MF 文件。") @@ -1142,6 +1345,7 @@ class MainWindow(QMainWindow): return def build_combined_output(self, jobs: list[PlateJob]) -> None: + """合并构建模式:将所有输入文件打包到单个输出 3MF 中。""" output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path) options = self.build_options_for_output(output_path) result = build_packed_3mf(jobs, options) @@ -1151,6 +1355,7 @@ class MainWindow(QMainWindow): self.remove_all() def build_individual_outputs(self, jobs: list[PlateJob]) -> None: + """独立批处理模式:每行输入构建一个独立的输出 3MF,支持并行处理。""" used_paths: set[Path] = set() success_count = 0 errors: list[str] = [] @@ -1192,24 +1397,32 @@ class MainWindow(QMainWindow): if self.clear_after_build_check.isChecked(): self.remove_all() + # ========================================================================= + # 窗口级拖放事件 + # ========================================================================= + def dragEnterEvent(self, event: QDragEnterEvent) -> None: + """主窗口拖入事件:接受包含文件 URL 的拖放。""" if event.mimeData().hasUrls(): event.acceptProposedAction() return super().dragEnterEvent(event) def dropEvent(self, event: QDropEvent) -> None: + """主窗口释放事件:将拖放的文件路径添加到输入列表。""" urls: list[QUrl] = event.mimeData().urls() paths = [Path(url.toLocalFile()) for url in urls if url.toLocalFile()] self.add_paths(paths) event.acceptProposedAction() def closeEvent(self, event: Any) -> None: + """窗口关闭前保存当前设置。""" self.save_current_settings() super().closeEvent(event) def main() -> int: + """应用程序入口:初始化 QApplication,创建并显示主窗口,进入事件循环。""" freeze_support() app = QApplication(sys.argv) app.setFont(QFont("Segoe UI", 10)) diff --git a/a1_swap_mod_packer/metadata.py b/a1_swap_mod_packer/metadata.py index ec2145a..6bb6f95 100644 --- a/a1_swap_mod_packer/metadata.py +++ b/a1_swap_mod_packer/metadata.py @@ -1,3 +1,9 @@ +"""3MF 文件元数据读取与处理模块。 + +提供从 3MF 归档文件中提取切片信息、耗材数据、模型设置等元数据的功能, +以及基于元数据生成构建摘要(时间预估、耗材用量等)的辅助函数。 +""" + from __future__ import annotations import zipfile @@ -10,6 +16,18 @@ from .models import BuildOptions, BuildSummary, PlateJob, PlateSource, ThreeMfSu def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]]: + """读取 3MF 归档中各打印板的切片元数据。 + + 从 Metadata/slice_info.config 中解析每个 元素内的 键值对, + 按盘索引号 index 组织为字典。 + + 参数: + archive: 已打开的 3MF 压缩文件对象。 + + 返回: + dict[int, dict[str, str]]: plate_index -> {metadata_key: metadata_value}, + 若配置文件不存在则返回空字典。 + """ try: data = archive.read("Metadata/slice_info.config") except KeyError: @@ -23,6 +41,7 @@ def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, s value = metadata.attrib.get("value") if key is not None and value is not None: plate_data[key] = value + # 使用 index 字段确定盘编号,缺失或无效时自动递增 index_text = plate_data.get("index") try: index = int(index_text) if index_text else len(result) + 1 @@ -33,6 +52,18 @@ def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, s def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]]: + """读取 3MF 归档中各打印板的耗材(filament)元数据。 + + 从 Metadata/slice_info.config 中提取每个 元素的属性, + 按盘索引号组织。 + + 参数: + archive: 已打开的 3MF 压缩文件对象。 + + 返回: + dict[int, dict[str, str]]: plate_index -> {filament_attr: value}, + 若配置文件不存在则返回空字典。 + """ try: data = archive.read("Metadata/slice_info.config") except KeyError: @@ -41,6 +72,7 @@ def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str] result: dict[int, dict[str, str]] = {} for plate in root.findall("plate"): plate_index = 1 + # 先获取当前盘的 index 编号 for metadata in plate.findall("metadata"): if metadata.attrib.get("key") == "index": try: @@ -55,6 +87,17 @@ def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str] def read_model_settings_gcode_members(archive: zipfile.ZipFile) -> list[str]: + """读取模型设置中配置的 G-code 文件成员名称列表。 + + 从 Metadata/model_settings.config 中提取所有 plate 的 gcode_file 元数据值, + 用于确定需要处理的 G-code 归档成员。 + + 参数: + archive: 已打开的 3MF 压缩文件对象。 + + 返回: + list[str]: 符合 G-code 命名规范的成员路径列表,若配置不存在则返回空列表。 + """ try: data = archive.read("Metadata/model_settings.config") except KeyError: @@ -68,6 +111,7 @@ def read_model_settings_gcode_members(archive: zipfile.ZipFile) -> list[str]: for metadata in plate.findall("metadata"): if metadata.attrib.get("key") != "gcode_file": continue + # 规范化路径分隔符并去掉前导斜杠 value = metadata.attrib.get("value", "").strip().replace("\\", "/").lstrip("/") if value and GCODE_MEMBER_RE.match(value): members.append(value) @@ -76,6 +120,14 @@ def read_model_settings_gcode_members(archive: zipfile.ZipFile) -> list[str]: def safe_float(value: str | None) -> float | None: + """安全地将字符串转换为浮点数,失败时返回 None。 + + 参数: + value: 待转换的字符串或 None。 + + 返回: + float | None: 转换成功返回浮点数,否则返回 None。 + """ if value is None: return None try: @@ -85,6 +137,14 @@ def safe_float(value: str | None) -> float | None: def _sum_optional(values: Iterable[float | None]) -> float | None: + """对一组可选浮点值求和,全为 None 时返回 None。 + + 参数: + values: 包含 float 或 None 的可迭代对象。 + + 返回: + float | None: 有效值的总和,若无任何有效值则返回 None。 + """ total = 0.0 found = False for value in values: @@ -95,6 +155,19 @@ def _sum_optional(values: Iterable[float | None]) -> float | None: def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary: + """读取单个 3MF 文件的完整摘要信息。 + + 汇总文件内所有打印板的预估时间、重量、耗材用量等数据。 + + 参数: + source_3mf: 3MF 源文件路径。 + + 返回: + ThreeMfSummary: 包含盘数量、预估秒数、重量克数、耗材米数和克数的摘要对象。 + + 抛出: + ValueError: 若文件中未找到任何 G-code 成员。 + """ with zipfile.ZipFile(source_3mf, "r") as archive: members = list_gcode_members(archive) if not members: @@ -111,13 +184,16 @@ def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary: plate_index = int(match.group(1)) if match else 1 metadata = plate_metadata.get(plate_index, {}) filament = filament_metadata.get(plate_index, {}) + # 提取各项元数据值 prediction = safe_float(metadata.get("prediction")) weight = safe_float(metadata.get("weight")) used_m = safe_float(filament.get("used_m")) used_g = safe_float(filament.get("used_g")) predictions.append(prediction) + # 重量优先取 weight,若无则回退到 used_g weights.append(weight if weight is not None else used_g) used_m_values.append(used_m) + # 耗材克数优先取 used_g,若无则回退到 weight used_g_values.append(used_g if used_g is not None else weight) return ThreeMfSummary( @@ -131,6 +207,17 @@ def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary: def multiply_summary(summary: ThreeMfSummary, copies: int) -> BuildSummary: + """将单个 3MF 摘要按指定份数倍增,生成构建摘要。 + + 用于计算同一文件多次打印时的累计耗时和耗材用量。 + + 参数: + summary: 单个 3MF 文件的摘要对象。 + copies: 打印份数(至少为 1)。 + + 返回: + BuildSummary: 倍增后的构建摘要。 + """ multiplier = max(1, int(copies)) return BuildSummary( plate_count=summary.plate_count * multiplier, @@ -142,6 +229,16 @@ def multiply_summary(summary: ThreeMfSummary, copies: int) -> BuildSummary: def summarize_jobs(jobs: Iterable[PlateJob]) -> BuildSummary: + """汇总所有换板作业的构建摘要。 + + 遍历所有 PlateJob,依次读取各自的 3MF 摘要并按份数倍增后累加。 + + 参数: + jobs: PlateJob 对象的可迭代集合。 + + 返回: + BuildSummary: 所有作业合并后的构建摘要。 + """ plate_count = 0 predictions: list[float | None] = [] weights: list[float | None] = [] @@ -164,12 +261,26 @@ def summarize_jobs(jobs: Iterable[PlateJob]) -> BuildSummary: def update_first_slice_info(base_data: bytes, sources: list[PlateSource], options: BuildOptions) -> bytes: + """更新打包后 3MF 中首个 slice_info.config 的元数据。 + + 根据 metadata_mode 选项决定是保留源数据还是累加所有来源的预估时间和耗材用量。 + + 参数: + base_data: 原始 slice_info.config 的字节数据。 + sources: 所有 PlateSource 的列表。 + options: 构建选项(含 metadata_mode 字段)。 + + 返回: + bytes: 更新后的 slice_info.config 字节数据。 + """ if options.metadata_mode == "source": + # 源模式:保持原始数据不变 return base_data try: root = ET.fromstring(base_data.decode("utf-8-sig", errors="replace")) except Exception: return base_data + # 累加所有来源的预估时间和耗材用量 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) total_used_m = sum(value for value in (source.filament_used_m for source in sources) if value is not None) @@ -177,6 +288,7 @@ def update_first_slice_info(base_data: bytes, sources: list[PlateSource], option first_plate = root.find("plate") if first_plate is None: return base_data + # 更新第一个 plate 的 metadata 和 filament 字段 for metadata in first_plate.findall("metadata"): key = metadata.attrib.get("key") if key == "prediction" and total_prediction > 0: diff --git a/a1_swap_mod_packer/models.py b/a1_swap_mod_packer/models.py index 9d7a7ca..58b7f45 100644 --- a/a1_swap_mod_packer/models.py +++ b/a1_swap_mod_packer/models.py @@ -1,69 +1,142 @@ +"""数据模型模块。 + +定义换板打包器所用的全部数据类、类型别名和常量,包括: +- 板件任务 (PlateJob) +- G-code 补丁规则与配置 (GcodePatchRule / GcodePatchConfig) +- 构建选项 (BuildOptions) +- 板件来源与摘要 (PlateSource / ThreeMfSummary / BuildSummary) +- 构建结果 (BuildResult) +""" + from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import Literal +# 默认插入标记:在此行之前插入换板 G-code DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound=========" +# 默认 ZIP 压缩级别 (0-9,7 为平衡选择) DEFAULT_ZIP_COMPRESS_LEVEL = 7 +# 元数据计算模式:取源文件数值还是累加求和 MetadataMode = Literal["source", "sum"] +# 换行符风格:LF (Unix) 或 CRLF (Windows) LineEnding = Literal["lf", "crlf"] @dataclass(frozen=True) class PlateJob: + """单个板件打包任务。 + + Attributes: + source_3mf: 源 3MF 文件路径。 + copies: 该板件的副本数量,默认为 1。 + """ source_3mf: Path copies: int = 1 @dataclass(frozen=True) class GcodePatchRule: + """单条 G-code 查找替换规则。 + + Attributes: + name: 规则名称,用于识别和日志。 + find: 要查找的目标字符串。 + replace: 替换后的字符串。 + flag: 正则标志(可选),如 "i" 表示忽略大小写。 + enabled: 是否启用该规则,默认启用。 + max_count: 最大替换次数,默认 1(仅替换首次出现)。 + """ name: str find: str replace: str - flag: str = "" - enabled: bool = True - max_count: int = 1 + flag: str = "" # 正则标志,例如 'i' 表示忽略大小写 + enabled: bool = True # 是否启用此规则 + max_count: int = 1 # 最多替换次数 @dataclass(frozen=True) class GcodePatchConfig: + """G-code 补丁配置,包含一组规则和插入标记。 + + Attributes: + rules: 补丁规则元组。 + insert_before_marker: 在此标记行之前插入换板 G-code。 + """ rules: tuple[GcodePatchRule, ...] insert_before_marker: str = DEFAULT_INSERT_BEFORE_MARKER @dataclass(frozen=True) class BuildOptions: + """构建打包选项。 + + Attributes: + swap_gcode: 换板 G-code 文件路径或内容字符串。 + output_3mf: 输出 3MF 文件的保存路径。 + cool_bed_temp: 冷却热床目标温度(摄氏度),None 表示不等待冷却。 + wait_after_eject_seconds: 推板后等待秒数。 + show_plate_number: 是否在屏幕上显示当前板件编号。 + swap_after_final: 最终板件完成后是否也执行换板。 + metadata_mode: 元数据模式,"source" 取源文件值,"sum" 累加求和。 + line_ending: 输出 G-code 的换行符风格。 + add_preview_label: 是否为板件添加预览标签注释。 + apply_gcode_patches: 是否应用 G-code 补丁规则。 + swap_gcode_dir: 换板 G-code 文件所在目录,默认自动检测。 + zip_compress_level: 3MF (ZIP) 压缩级别,默认 7。 + """ swap_gcode: Path | str output_3mf: Path - cool_bed_temp: int | None = 45 - wait_after_eject_seconds: int = 45 - show_plate_number: bool = True - swap_after_final: bool = True - metadata_mode: MetadataMode = "source" - line_ending: LineEnding = "crlf" - add_preview_label: bool = True - apply_gcode_patches: bool = True - swap_gcode_dir: Path | None = None - zip_compress_level: int = DEFAULT_ZIP_COMPRESS_LEVEL + cool_bed_temp: int | None = 45 # 热床冷却目标温度,None 跳过冷却 + wait_after_eject_seconds: int = 45 # 推板完成后等待时间 + show_plate_number: bool = True # 是否显示板件编号到屏幕 + swap_after_final: bool = True # 最后一块是否也换板 + metadata_mode: MetadataMode = "source" # 元数据计算模式 + line_ending: LineEnding = "crlf" # 输出换行符 + add_preview_label: bool = True # 是否添加预览注释 + apply_gcode_patches: bool = True # 是否启用 G-code 补丁 + swap_gcode_dir: Path | None = None # 换板 G-code 所在目录 + zip_compress_level: int = DEFAULT_ZIP_COMPRESS_LEVEL # ZIP 压缩级别 @dataclass class PlateSource: + """单板 G-code 来源数据。 + + Attributes: + source_3mf: 来源 3MF 文件。 + member_name: 3MF 内部成员名称,用于正确提取 G-code。 + gcode_text: 提取出的 G-code 文本内容。 + prediction_seconds: 预估打印时间(秒)。 + weight_grams: 预估耗材重量(克)。 + filament_used_m: 预估耗材用量(米)。 + filament_used_g: 预估耗材用量(克),部分切片软件以此字段为主。 + """ source_3mf: Path - member_name: str - gcode_text: str - prediction_seconds: float | None = None - weight_grams: float | None = None - filament_used_m: float | None = None - filament_used_g: float | None = None + member_name: str # 3MF 内的成员名,用于定位 G-code + gcode_text: str # 完整的 G-code 文本 + prediction_seconds: float | None = None # 预估打印时间(秒) + weight_grams: float | None = None # 预估耗材重量(克) + filament_used_m: float | None = None # 耗材用量(米) + filament_used_g: float | None = None # 耗材用量(克) @dataclass(frozen=True) class ThreeMfSummary: + """单个 3MF 文件的元数据摘要。 + + Attributes: + source_3mf: 来源 3MF 文件。 + plate_count: 该文件中包含的板件数量。 + prediction_seconds: 预估打印时间(秒)。 + weight_grams: 预估耗材重量(克)。 + filament_used_m: 预估耗材用量(米)。 + filament_used_g: 预估耗材用量(克)。 + """ source_3mf: Path - plate_count: int + plate_count: int # 板件数量 prediction_seconds: float | None = None weight_grams: float | None = None filament_used_m: float | None = None @@ -72,17 +145,35 @@ class ThreeMfSummary: @dataclass(frozen=True) class BuildSummary: + """总体构建摘要。 + + Attributes: + plate_count: 总板件数。 + total_prediction_seconds: 总预估打印时间(秒)。 + total_weight_grams: 总耗材重量(克)。 + total_filament_used_m: 总耗材用量(米)。 + total_filament_used_g: 总耗材用量(克)。 + """ plate_count: int - total_prediction_seconds: float | None - total_weight_grams: float | None - total_filament_used_m: float | None - total_filament_used_g: float | None + total_prediction_seconds: float | None # 总预估时间 + total_weight_grams: float | None # 总耗材重量 + total_filament_used_m: float | None # 总耗材长度 + total_filament_used_g: float | None # 总耗材质量 @dataclass class BuildResult: + """单次构建打包的最终结果。 + + Attributes: + output_3mf: 输出 3MF 文件路径。 + plate_count: 包含的板件总数。 + total_prediction_seconds: 总预估打印时间(秒)。 + total_weight_grams: 总耗材重量(克)。 + gcode_md5: 最终 G-code 内容的 MD5 哈希值,用于唯一性校验。 + """ output_3mf: Path plate_count: int total_prediction_seconds: float | None total_weight_grams: float | None - gcode_md5: str + gcode_md5: str # G-code MD5 哈希值 diff --git a/a1_swap_mod_packer/patches.py b/a1_swap_mod_packer/patches.py index 33e0c20..f76c9c6 100644 --- a/a1_swap_mod_packer/patches.py +++ b/a1_swap_mod_packer/patches.py @@ -1,3 +1,9 @@ +"""G-code 补丁配置解析与应用。 + +本模块提供了从 INI 配置文件读取 G-code 修改规则的功能, +支持两种配置节格式:新版 [patch.<名称>] 节和旧版 [Gcode] 节。 +补丁规则可用于在打包前对换板 G-code 进行查找替换等文本修改。""" + from __future__ import annotations import configparser @@ -9,24 +15,53 @@ from .paths import default_patch_config_path def parse_bool(value: str | None, default: bool = True) -> bool: + """将 INI 配置中的字符串值解析为布尔值。 + + 支持的值: "1", "true", "yes", "on", "enabled"(不区分大小写)。 + + 参数: + value: 配置字符串值,可为 None。 + default: value 为 None 时的默认返回值。 + + 返回: + 解析后的布尔值。 + """ if value is None: return default return value.strip().lower() in {"1", "true", "yes", "on", "enabled"} def parse_patch_config(path: Path | None = None) -> GcodePatchConfig: + """从 INI 配置文件解析 G-code 补丁配置。 + + 支持两种配置格式: + 1. 新版 [patch.<规则名>] 节,每个节定义一组查找/替换规则。 + 2. 旧版 [Gcode] 节,通过 Postion<键> / Postion<键>_edit / Postion<键>_flag + 三联键定义规则,FinishFlag 定义插入位置标记。 + + 参数: + path: 配置文件路径,为 None 时使用默认路径。 + + 返回: + 包含所有补丁规则和插入标记的 GcodePatchConfig 实例。 + """ config_path = path or default_patch_config_path() if not config_path.exists(): return GcodePatchConfig(rules=()) + + # configparser 配置: 禁用插值,保持键名大小写 parser = configparser.ConfigParser(interpolation=None, strict=False) parser.optionxform = str parser.read(config_path, encoding="utf-8-sig") + # 读取 [swap] 节中的插入标记配置 insert_before_marker = DEFAULT_INSERT_BEFORE_MARKER if parser.has_section("swap"): insert_before_marker = parser.get("swap", "insert_before_marker", fallback=insert_before_marker).strip().strip('"') rules: list[GcodePatchRule] = [] + + # 解析新版 [patch.<名称>] 格式的补丁规则 for section in parser.sections(): if section.lower().startswith("patch."): enabled = parse_bool(parser.get(section, "enabled", fallback="true"), True) @@ -38,11 +73,14 @@ def parse_patch_config(path: Path | None = None) -> GcodePatchConfig: max_count = max(0, int(max_count_text)) except ValueError: max_count = 1 + # 仅当 find 非空且规则启用时才添加 if find and enabled: rules.append(GcodePatchRule(section.split(".", 1)[1], find, replace, flag, enabled, max_count)) + # 解析旧版 [Gcode] 格式的补丁规则(兼容旧配置文件) if parser.has_section("Gcode"): gcode = parser["Gcode"] + # 找出所有 Postion<键>_flag 键,提取基准键名 base_names = sorted( key[:-5] for key in gcode.keys() @@ -54,33 +92,64 @@ def parse_patch_config(path: Path | None = None) -> GcodePatchConfig: flag = gcode.get(f"{base}_flag", "").strip().strip('"') if find and replace: rules.append(GcodePatchRule(base, find, replace, flag, True, 1)) + # FinishFlag 作为插入位置标记 marker = gcode.get("FinishFlag", "").strip().strip('"') if marker: insert_before_marker = marker + return GcodePatchConfig(tuple(rules), insert_before_marker) def apply_patch_rule(text: str, rule: GcodePatchRule) -> str: + """对 G-code 文本应用单条补丁规则。 + + 查找替换逻辑: + - 如果规则未启用、查找文本为空或 max_count 为 0,则原样返回。 + - 如果指定了 flag 标记行,则只在该标记行之后开始查找替换。 + - 每次替换后计数器递增,达到 max_count 后停止。 + + 参数: + text: 待处理的 G-code 文本。 + rule: 要应用的补丁规则。 + + 返回: + 应用规则后的 G-code 文本。 + """ if not rule.enabled or not rule.find or rule.max_count == 0: return text + lines = normalize_newlines(text).split("\n") start_index = 0 + + # 如果指定了 flag 标记,找到标记行后从下一行开始查找替换 if rule.flag: for index, line in enumerate(lines): if line.strip() == rule.flag or rule.flag in line: start_index = index + 1 break + replacements = 0 + # 在指定范围内查找并替换匹配行 for index in range(start_index, len(lines)): if lines[index].strip() == rule.find or lines[index] == rule.find: lines[index] = rule.replace replacements += 1 if replacements >= rule.max_count: break + return "\n".join(lines) def apply_gcode_patches(text: str, config: GcodePatchConfig) -> str: + """对 G-code 文本依次应用配置中的所有补丁规则。 + + 参数: + text: 待处理的 G-code 文本。 + config: 包含补丁规则列表的配置对象。 + + 返回: + 应用所有规则后的 G-code 文本。 + """ patched = normalize_newlines(text) for rule in config.rules: patched = apply_patch_rule(patched, rule) diff --git a/a1_swap_mod_packer/paths.py b/a1_swap_mod_packer/paths.py index 6847c98..2b6490a 100644 --- a/a1_swap_mod_packer/paths.py +++ b/a1_swap_mod_packer/paths.py @@ -1,36 +1,78 @@ +"""路径管理模块。 + +提供程序根目录和关键资源路径的计算逻辑,处理以下场景: +- 正常 Python 运行(通过 __file__ 定位) +- PyInstaller/Nuitka 单文件打包(通过 __compiled__ 信息定位) +- PyInstaller 冻结目录模式(通过 sys.frozen / sys.executable 定位) +""" + from __future__ import annotations import sys from pathlib import Path +# 应用程序包目录名 APP_DIR_NAME = "a1_swap_mod_packer" +# 换板 G-code 文件的默认子目录名 SWAP_GCODE_DIR_NAME = "swap_gcode" +# G-code 补丁配置文件名 PATCH_CONFIG_FILE_NAME = "gcode_patches.ini" def program_root() -> Path: + """返回程序根目录的绝对路径。 + + 根据运行环境自动选择定位策略: + - Nuitka 编译的单文件:通过 __compiled__.containing_dir 获取。 + - Nuitka 但无 containing_dir:通过 __compiled__.original_argv0 推算。 + - PyInstaller 冻结模式:通过 sys.executable 所在目录获取。 + - 正常 Python 运行:基于本模块文件路径向上两级推算。 + + Returns: + Path: 程序根目录的绝对路径。 + """ + # Nuitka 编译标记,单文件模式下需特殊处理 compiled = globals().get("__compiled__") if compiled is not None and getattr(compiled, "onefile", False): + # Nuitka 提供的包含目录 containing_dir = getattr(compiled, "containing_dir", None) if containing_dir: return Path(containing_dir).resolve() + # 回退:通过原始可执行文件路径推算 original_argv0 = getattr(compiled, "original_argv0", None) if original_argv0: return Path(original_argv0).resolve().parent + # PyInstaller 冻结模式 if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent + # 正常 Python 解释器运行:向上两级回到项目根目录 return Path(__file__).resolve().parents[1] def default_swap_gcode_dir() -> Path: + """返回换板 G-code 文件的默认存放目录。 + + Returns: + Path: 程序根目录下的 swap_gcode 子目录。 + """ return program_root() / SWAP_GCODE_DIR_NAME def default_patch_config_path() -> Path: + """返回 G-code 补丁配置文件的默认路径。 + + Returns: + Path: 程序根目录下的 gcode_patches.ini 文件路径。 + """ return program_root() / PATCH_CONFIG_FILE_NAME def user_settings_path() -> Path: + """返回用户设置文件的路径。 + + Returns: + Path: 程序根目录下的 settings.json 文件路径。 + """ return program_root() / "settings.json" diff --git a/a1_swap_mod_packer/planning.py b/a1_swap_mod_packer/planning.py index 2e838e2..883d988 100644 --- a/a1_swap_mod_packer/planning.py +++ b/a1_swap_mod_packer/planning.py @@ -1,3 +1,9 @@ +"""输出规划与文件命名模块。 + +提供换板打包任务的输出路径解析、文件命名规则、摘要汇总等功能。 +支持基于模板的输出文件名生成以及同一批次内文件路径去重。 +""" + from __future__ import annotations import re @@ -9,13 +15,24 @@ from typing import Callable, Mapping, Sequence from .metadata import read_3mf_summary from .models import PlateJob, ThreeMfSummary +# 默认输出文件名模板,{plates} 为总盘数,{sources} 为来源标识 DEFAULT_OUTPUT_PATTERN = "{plates} Plates - {sources}.3mf" +# 摘要解析器类型别名,接受 Path 返回 ThreeMfSummary SummaryResolver = Callable[[Path], ThreeMfSummary] @dataclass(frozen=True) class OutputSummary: + """输出摘要数据类,汇总一次换板打包的整体统计信息。 + + 属性: + plate_count: 总盘数(含份数倍增)。 + copy_count: 总份数。 + prediction_seconds: 预估总耗时(秒),不可用时为 None。 + weight_grams: 总重量(克),不可用时为 None。 + filament_used_m: 耗材总长度(米),不可用时为 None。 + """ plate_count: int copy_count: int prediction_seconds: float | None = None @@ -25,16 +42,37 @@ class OutputSummary: @dataclass(frozen=True) class OutputNamingOptions: + """输出命名选项,控制生成文件的目录和命名规则。 + + 属性: + output_directory: 输出目录路径,None 表示使用第一个输入文件所在目录。 + filename_rule: 输出文件名模板字符串。 + """ output_directory: Path | str | None = None filename_rule: str = DEFAULT_OUTPUT_PATTERN class SafeFormatDict(dict): + """安全的格式化字典。 + + 当 str.format_map 请求不存在的键时,返回原始占位符而非抛出 KeyError, + 保证模板中未提供的占位符被原样保留在输出中。 + """ def __missing__(self, key: str) -> str: return "{" + key + "}" def three_mf_summary_from_mapping(data: Mapping[str, object]) -> ThreeMfSummary: + """从类字典的映射对象构建 ThreeMfSummary。 + + 用于将外部传入的配置数据(如 GUI 表格行)转换为标准摘要对象。 + + 参数: + data: 包含摘要字段的映射对象(键名与 ThreeMfSummary 字段名对应)。 + + 返回: + ThreeMfSummary: 解析后的摘要对象,缺失字段使用默认值。 + """ source_value = data.get("source_3mf") return ThreeMfSummary( source_3mf=Path(str(source_value)) if source_value is not None else Path(), @@ -50,6 +88,18 @@ def summarize_jobs_for_output( jobs: Sequence[PlateJob], summary_resolver: SummaryResolver = read_3mf_summary, ) -> OutputSummary: + """将一组换板作业汇总为 OutputSummary。 + + 分别计算总盘数、总份数,以及各项可选的累计统计值。 + 支持自定义的摘要解析器,默认为 read_3mf_summary。 + + 参数: + jobs: PlateJob 序列。 + summary_resolver: 用于获取单个 3MF 摘要的可调用对象。 + + 返回: + OutputSummary: 汇总后的输出摘要。 + """ plate_count = 0 copy_count = 0 prediction_total = 0.0 @@ -72,6 +122,7 @@ def summarize_jobs_for_output( if summary.filament_used_m is not None: used_m_total += summary.filament_used_m * copies used_m_found = True + # 仅当至少有一个有效值时才会设置对应字段 return OutputSummary( plate_count=plate_count, copy_count=copy_count, @@ -82,7 +133,20 @@ def summarize_jobs_for_output( def sanitize_filename(file_name: str) -> str: + """清理文件名中的非法字符,确保生成合法的文件路径。 + + 将 Windows/Unix 中不允许出现在文件名中的字符替换为下划线, + 并去掉末尾的点和空格。 + + 参数: + file_name: 原始文件名。 + + 返回: + str: 清理后的安全文件名,至少返回 "packed.3mf"。 + """ + # 将非法字符替换为下划线 sanitized = re.sub(r"[\\/:*?\"<>|]+", "_", file_name).strip() + # 去除末尾的点号和空格(Windows 禁止) sanitized = sanitized.rstrip(". ") return sanitized or "packed.3mf" @@ -93,15 +157,35 @@ def resolve_output_path( summary_resolver: SummaryResolver = read_3mf_summary, now: datetime | None = None, ) -> Path: + """根据作业列表和命名选项解析最终输出文件路径。 + + 使用模板变量(source, sources, plates, copies, date, time)替换 + filename_rule 中的占位符,生成输出文件名。 + + 参数: + jobs: PlateJob 序列。 + naming: 输出命名选项。 + summary_resolver: 摘要解析器,默认 read_3mf_summary。 + now: 当前时间,None 时使用系统当前时间。 + + 返回: + Path: 解析后的输出文件完整路径。 + + 抛出: + ValueError: 若 jobs 为空。 + """ if not jobs: raise ValueError("No input 3MF file was provided.") summary = summarize_jobs_for_output(jobs, summary_resolver) first_input = jobs[0].source_3mf + # 收集所有输入文件的主文件名(不含扩展名) stems = [job.source_3mf.stem for job in jobs] - unique_stems = list(dict.fromkeys(stems)) + unique_stems = list(dict.fromkeys(stems)) # 保持顺序的去重 source_token = first_input.stem + # 当有多个不同来源时,生成 "xxx_and_N_more" 格式的标记 sources_token = source_token if len(unique_stems) == 1 else f"{source_token}_and_{len(unique_stems) - 1}_more" timestamp = now or datetime.now() + # 构建模板变量字典 tokens = SafeFormatDict( source=source_token, sources=sources_token, @@ -112,6 +196,7 @@ def resolve_output_path( ) pattern = naming.filename_rule.strip() or DEFAULT_OUTPUT_PATTERN file_name = sanitize_filename(pattern.format_map(tokens)) + # 确保文件扩展名为 .3mf if not file_name.lower().endswith(".3mf"): file_name += ".3mf" output_dir_text = "" if naming.output_directory is None else str(naming.output_directory).strip() @@ -120,6 +205,17 @@ def resolve_output_path( def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path: + """为单次运行确保输出路径唯一,通过追加递增序号避免冲突。 + + 若目标路径已被使用,则在文件名后添加 _2、_3 等后缀直至唯一。 + + 参数: + path: 期望的输出路径。 + used_paths: 已使用路径的集合(会原地修改)。 + + 返回: + Path: 去重后的唯一路径。 + """ resolved_key = path.resolve(strict=False) if resolved_key not in used_paths: used_paths.add(resolved_key) @@ -128,6 +224,7 @@ def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path: suffix = path.suffix parent = path.parent index = 2 + # 递增序号直到找到未使用的路径 while True: candidate = parent / f"{stem}_{index}{suffix}" candidate_key = candidate.resolve(strict=False) @@ -138,6 +235,14 @@ def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path: def _optional_float(value: object) -> float | None: + """安全地将任意对象转换为浮点数,失败时返回 None。 + + 参数: + value: 待转换的任意对象。 + + 返回: + float | None: 转换成功返回浮点数,否则返回 None。 + """ if value is None: return None try: diff --git a/tests/test_active_plate_member.py b/tests/test_active_plate_member.py index 4ef4697..26c1466 100644 --- a/tests/test_active_plate_member.py +++ b/tests/test_active_plate_member.py @@ -1,3 +1,10 @@ +"""测试活动打印板成员(Active Plate Member)的 3MF 输出行为。 + +验证内容: +- write_output_3mf 正确保留配置中指定的打印板 G-code 成员 +- ZIP 压缩级别参数生效并正确裁剪 +- 预览图拼合时仅使用前 9 个唯一输入文件 +""" from __future__ import annotations import hashlib @@ -23,13 +30,17 @@ except Exception: # pragma: no cover class ActivePlateMemberTest(unittest.TestCase): + """测试写入输出 3MF 时活动打印板成员的正确保留与压缩行为。""" + def test_write_output_preserves_configured_plate_member(self) -> None: + """验证 write_output_3mf 保留配置中指定打印板对应的 G-code 成员。""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) source_3mf = root / "plate2.3mf" output_3mf = root / "out.3mf" - self.write_plate2_archive(source_3mf) + self.write_plate2_archive(source_3mf) # 创建含 plate_2 的测试 3MF + # 打开源归档,校验 plate_2 被正确识别 with zipfile.ZipFile(source_3mf, "r") as archive: self.assertEqual(read_model_settings_gcode_members(archive), ["Metadata/plate_2.gcode"]) self.assertEqual(resolve_output_gcode_member(archive, "Metadata/plate_2.gcode"), "Metadata/plate_2.gcode") @@ -38,7 +49,7 @@ class ActivePlateMemberTest(unittest.TestCase): {"Metadata/plate_2.png", "Metadata/plate_2_small.png"}, ) - gcode_bytes = b"G1 X1\n" + gcode_bytes = b"G1 X1\n" # 替换用的 G-code 内容 sources = [ PlateSource( source_3mf=source_3mf, @@ -52,8 +63,9 @@ class ActivePlateMemberTest(unittest.TestCase): add_preview_label=False, apply_gcode_patches=False, ) - md5 = write_output_3mf(source_3mf, output_3mf, gcode_bytes, sources, options) + md5 = write_output_3mf(source_3mf, output_3mf, gcode_bytes, sources, options) # 执行打包输出 + # 校验输出归档中仅包含 plate_2 的 G-code,且 MD5 正确 with zipfile.ZipFile(output_3mf, "r") as archive: names = set(archive.namelist()) self.assertIn("Metadata/plate_2.gcode", names) @@ -64,6 +76,7 @@ class ActivePlateMemberTest(unittest.TestCase): self.assertEqual(md5, hashlib.md5(gcode_bytes).hexdigest()) def test_write_output_honors_zip_compression_level(self) -> None: + """验证 ZIP 压缩级别参数生效:压缩级别越高,压缩后文件越小。""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) source_3mf = root / "plate2.3mf" @@ -77,8 +90,10 @@ class ActivePlateMemberTest(unittest.TestCase): gcode_text="G1 X0\n", ) ] + # 大量重复 G-code 以便观察压缩效果 gcode_bytes = (b"G1 X123.456 Y789.012 E0.03456 ; repeated movement\n" * 20000) + # 分别以压缩级别 1 和 7 输出 for output_3mf, zip_level in ((level_1_output, 1), (level_7_output, 7)): options = BuildOptions( swap_gcode="", @@ -89,6 +104,7 @@ class ActivePlateMemberTest(unittest.TestCase): ) write_output_3mf(source_3mf, output_3mf, gcode_bytes, sources, options) + # 比较两个输出文件的压缩后大小:级别 7 应更小 with zipfile.ZipFile(level_1_output, "r") as level_1_archive, zipfile.ZipFile(level_7_output, "r") as level_7_archive: level_1_info = level_1_archive.getinfo("Metadata/plate_2.gcode") level_7_info = level_7_archive.getinfo("Metadata/plate_2.gcode") @@ -97,14 +113,17 @@ class ActivePlateMemberTest(unittest.TestCase): self.assertLess(level_7_info.compress_size, level_1_info.compress_size) def test_zip_compression_level_is_clamped(self) -> None: - self.assertEqual(normalized_zip_compress_level(None), 7) - self.assertEqual(normalized_zip_compress_level(0), 1) - self.assertEqual(normalized_zip_compress_level(10), 9) + """验证压缩级别参数被正确裁剪到合法范围 [1, 9]。""" + self.assertEqual(normalized_zip_compress_level(None), 7) # 默认值 → 7 + self.assertEqual(normalized_zip_compress_level(0), 1) # 低于下限 → 1 + self.assertEqual(normalized_zip_compress_level(10), 9) # 超出上限 → 9 def test_preview_composite_uses_first_nine_unique_input_files(self) -> None: + """验证预览图拼合仅使用前 9 个不重复的输入文件,第 10 个被排除。""" if Image is None: self.skipTest("Pillow is not installed") + # 10 种不同颜色,用于验证拼合结果 colors = [ (255, 0, 0, 255), (0, 0, 255, 255), @@ -121,6 +140,7 @@ class ActivePlateMemberTest(unittest.TestCase): with tempfile.TemporaryDirectory() as directory: root = Path(directory) sources: list[PlateSource] = [] + # 为每种颜色创建一个独立的 3MF 源文件 for index, color in enumerate(colors): source_3mf = root / f"source_{index}.3mf" self.write_plate2_archive(source_3mf, image_color=color, image_size=300) @@ -132,6 +152,7 @@ class ActivePlateMemberTest(unittest.TestCase): ) ) + # 验证 select_preview_sources 返回恰好 9 个源 selected = select_preview_sources(sources) self.assertEqual(len(selected), 9) self.assertEqual(selected[-1].source_3mf.name, "source_8.3mf") @@ -140,16 +161,18 @@ class ActivePlateMemberTest(unittest.TestCase): options = BuildOptions( swap_gcode="", output_3mf=output_3mf, - add_preview_label=True, + add_preview_label=True, # 启用预览标签生成 apply_gcode_patches=False, ) write_output_3mf(sources[0].source_3mf, output_3mf, b"G1 X1\n", sources, options) + # 读取拼合后的预览图 with zipfile.ZipFile(output_3mf, "r") as archive: image_bytes = archive.read("Metadata/plate_2.png") import io + # 在拼合图像的 9 个采样点处检测颜色 image = Image.open(io.BytesIO(image_bytes)).convert("RGBA") sampled_colors = { image.getpixel((50, 50)), @@ -162,11 +185,19 @@ class ActivePlateMemberTest(unittest.TestCase): image.getpixel((150, 250)), image.getpixel((250, 250)), } + # 前 9 种颜色全部出现,第 10 种不出现 self.assertTrue(set(colors[:9]).issubset(sampled_colors)) self.assertNotIn(colors[9], sampled_colors) @staticmethod def write_plate2_archive(path: Path, image_color: tuple[int, int, int, int] | None = None, image_size: int = 128) -> None: + """创建仅含 plate_2 有效 G-code 成员及其预览图的 3MF 测试归档。 + + Args: + path: 输出 3MF 文件路径 + image_color: 预览图填充颜色(RGBA),None 则写入占位字节 + image_size: 预览图尺寸(正方形) + """ model_settings = """ @@ -192,11 +223,13 @@ class ActivePlateMemberTest(unittest.TestCase): archive.writestr("Metadata/plate_2.gcode", "G1 X0\n") archive.writestr("Metadata/plate_2.gcode.md5", "old") if image_color is None or Image is None: + # 无 Pillow 或不指定颜色 → 写入占位字节 archive.writestr("Metadata/plate_2.png", b"png") archive.writestr("Metadata/plate_2_small.png", b"png") else: import io + # 生成纯色 PNG 预览图及其缩略图 image = Image.new("RGBA", (image_size, image_size), image_color) output = io.BytesIO() image.save(output, format="PNG") diff --git a/tests/test_batch.py b/tests/test_batch.py index 3675cbc..61e71b8 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -1,3 +1,9 @@ +"""测试独立批量构建(Individual Batch Build)的任务调度与工作器计数。 + +验证内容: +- 工作器数量根据任务数和 CPU 核心数合理计算 +- 串行执行器保持成功任务顺序,同时收集失败任务信息 +""" from __future__ import annotations import unittest @@ -9,16 +15,23 @@ from a1_swap_mod_packer.models import BuildOptions, BuildResult, PlateJob class IndividualBatchBuildTest(unittest.TestCase): + """测试独立批量构建的工作器计数和运行器行为。""" + def test_worker_count_uses_task_count_and_caps_cpu_count(self) -> None: - self.assertEqual(batch.individual_batch_worker_count(0), 0) - self.assertEqual(batch.individual_batch_worker_count(3, max_workers=99), 3) - self.assertEqual(batch.individual_batch_worker_count(3, max_workers=0), 1) + """验证工作器数量 = min(任务数, CPU 核心数),且受上下限约束。""" + self.assertEqual(batch.individual_batch_worker_count(0), 0) # 无任务 → 0 个工作器 + self.assertEqual(batch.individual_batch_worker_count(3, max_workers=99), 3) # 任务数 < 上限 → 按任务数 + self.assertEqual(batch.individual_batch_worker_count(3, max_workers=0), 1) # 上限为 0 → 退化为 1 with patch.object(batch.os, "cpu_count", return_value=32): + # 任务数 20,CPU 32 核,上限 = INDIVIDUAL_BATCH_MAX_WORKERS self.assertEqual(batch.individual_batch_worker_count(20), batch.INDIVIDUAL_BATCH_MAX_WORKERS) with patch.object(batch.os, "cpu_count", return_value=None): + # cpu_count 返回 None → 回退为 1 self.assertEqual(batch.individual_batch_worker_count(20), 1) def test_serial_runner_keeps_success_order_and_collects_failures(self) -> None: + """验证串行执行器按提交顺序返回成功结果,并收集失败任务。""" + # 准备 3 个任务,其中第 2 个计划失败 tasks = [ self.task("one.3mf", "one.out.3mf", copies=2), self.task("fail.3mf", "fail.out.3mf", copies=3), @@ -26,6 +39,7 @@ class IndividualBatchBuildTest(unittest.TestCase): ] def fake_build(jobs: list[PlateJob], options: BuildOptions) -> BuildResult: + """模拟构建:fail.3mf 触发 ValueError,其余正常返回。""" job = jobs[0] if job.source_3mf.name == "fail.3mf": raise ValueError("planned failure") @@ -37,15 +51,18 @@ class IndividualBatchBuildTest(unittest.TestCase): gcode_md5=job.source_3mf.stem, ) + # 使用单工作器串行运行,验证顺序与失败收集 with patch.object(batch, "build_packed_3mf", side_effect=fake_build): result = batch.run_individual_batch_builds(tasks, max_workers=1) self.assertEqual(result.worker_count, 1) + # 成功结果按原始顺序排列,跳过失败项 self.assertEqual( [item.output_3mf.name for item in result.results], ["one.out.3mf", "two.out.3mf"], ) self.assertEqual([item.plate_count for item in result.results], [2, 4]) + # 失败详情:索引、任务源文件名、错误信息 self.assertEqual(len(result.failures), 1) self.assertEqual(result.failures[0].index, 1) self.assertEqual(result.failures[0].job.source_3mf.name, "fail.3mf") @@ -53,6 +70,16 @@ class IndividualBatchBuildTest(unittest.TestCase): @staticmethod def task(source_name: str, output_name: str, copies: int) -> batch.IndividualBuildTask: + """构造独立构建任务辅助方法。 + + Args: + source_name: 源 3MF 文件名 + output_name: 输出 3MF 文件名 + copies: 打印板份数 + + Returns: + 包装好的 IndividualBuildTask + """ return batch.IndividualBuildTask( PlateJob(Path(source_name), copies), BuildOptions(swap_gcode=Path("swap.gcode"), output_3mf=Path(output_name)), diff --git a/tests/test_gcode_prepare_cache.py b/tests/test_gcode_prepare_cache.py index 3a1b462..e84addf 100644 --- a/tests/test_gcode_prepare_cache.py +++ b/tests/test_gcode_prepare_cache.py @@ -1,3 +1,9 @@ +"""测试 G-code 准备阶段缓存行为,确保重复副本不会重复执行预处理。 + +验证内容: +- 同一 G-code 成员多次复制时,补丁只应用一次 +- 同一 G-code 成员多次复制时,M73 偏移模板只计算一次 +""" from __future__ import annotations import hashlib @@ -12,15 +18,19 @@ from a1_swap_mod_packer.models import BuildOptions, GcodePatchConfig, GcodePatch class GcodePrepareCacheTest(unittest.TestCase): + """测试 G-code 准备阶段的缓存:补丁和 M73 模板应每成员仅执行一次。""" + def test_repeated_copies_prepare_gcode_once_per_member(self) -> None: + """验证同一 G-code 成员多次复制时,补丁仅在首次准备时应用一次。""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) source_3mf = root / "source.3mf" output_3mf = root / "out.3mf" swap_gcode = root / "swap.gcode" - self.write_archive(source_3mf) + self.write_archive(source_3mf) # 创建测试用 3MF 归档 swap_gcode.write_text("; swap\nG1 X5\n", encoding="utf-8") + # 配置一条简单的替换规则 patch_config = GcodePatchConfig( rules=( GcodePatchRule( @@ -30,10 +40,11 @@ class GcodePrepareCacheTest(unittest.TestCase): ), ) ) - patch_calls = 0 - captured_gcode = "" + patch_calls = 0 # 记录补丁被调用次数 + captured_gcode = "" # 捕获最终 G-code 内容 def count_patch_calls(text: str, config: GcodePatchConfig) -> str: + """模拟补丁应用并累计调用次数。""" nonlocal patch_calls patch_calls += 1 return text.replace("G0 Y254 F3000", "G0 Y250 F3000 ;Patched", 1) @@ -45,6 +56,7 @@ class GcodePrepareCacheTest(unittest.TestCase): sources: list[builder.PlateSource], options: BuildOptions, ) -> str: + """截获最终写入的 G-code 字节以便断言。""" nonlocal captured_gcode captured_gcode = gcode_bytes.decode("utf-8") return hashlib.md5(gcode_bytes).hexdigest() @@ -59,6 +71,7 @@ class GcodePrepareCacheTest(unittest.TestCase): add_preview_label=False, ) + # 构建 3 份副本,验证补丁仅调用 1 次但 3 份均被替换 with patch.object(builder, "parse_patch_config", return_value=patch_config), patch.object( builder, "apply_gcode_patches", @@ -66,12 +79,13 @@ class GcodePrepareCacheTest(unittest.TestCase): ), patch.object(builder, "write_output_3mf", side_effect=capture_gcode): result = builder.build_packed_3mf([PlateJob(source_3mf, 3)], options) - self.assertEqual(result.plate_count, 3) - self.assertEqual(patch_calls, 1) - self.assertEqual(captured_gcode.count("G0 Y250 F3000 ;Patched"), 3) - self.assertNotIn("G0 Y254 F3000", captured_gcode) + self.assertEqual(result.plate_count, 3) # 3 份副本 + self.assertEqual(patch_calls, 1) # 补丁仅调用 1 次(缓存生效) + self.assertEqual(captured_gcode.count("G0 Y250 F3000 ;Patched"), 3) # 但 3 份都已替换 + self.assertNotIn("G0 Y254 F3000", captured_gcode) # 原始行已不存在 def test_repeated_copies_prepare_m73_template_once_per_member(self) -> None: + """验证同一 G-code 成员多次复制时,M73 偏移模板仅计算一次。""" with tempfile.TemporaryDirectory() as directory: root = Path(directory) source_3mf = root / "source.3mf" @@ -80,11 +94,12 @@ class GcodePrepareCacheTest(unittest.TestCase): self.write_archive(source_3mf) swap_gcode.write_text("; swap\nG1 X5\n", encoding="utf-8") - m73_template_calls = 0 + m73_template_calls = 0 # 记录模板准备调用次数 captured_gcode = "" - original_prepare_m73 = builder.prepare_m73_offset_template + original_prepare_m73 = builder.prepare_m73_offset_template # 保存原始函数引用 def count_m73_template_calls(text: str): + """模拟 M73 模板准备并累计调用次数。""" nonlocal m73_template_calls m73_template_calls += 1 return original_prepare_m73(text) @@ -96,6 +111,7 @@ class GcodePrepareCacheTest(unittest.TestCase): sources: list[builder.PlateSource], options: BuildOptions, ) -> str: + """截获最终写入的 G-code 字节以便断言。""" nonlocal captured_gcode captured_gcode = gcode_bytes.decode("utf-8") return hashlib.md5(gcode_bytes).hexdigest() @@ -104,12 +120,13 @@ class GcodePrepareCacheTest(unittest.TestCase): swap_gcode=swap_gcode, output_3mf=output_3mf, apply_gcode_patches=False, - show_plate_number=True, + show_plate_number=True, # 启用板号显示,触发 M73 偏移 swap_after_final=False, line_ending="lf", add_preview_label=False, ) + # 构建 3 份副本,验证模板仅准备 1 次,但每份各自偏移 with ( patch.object(builder, "prepare_m73_offset_template", side_effect=count_m73_template_calls), patch.object( @@ -121,13 +138,15 @@ class GcodePrepareCacheTest(unittest.TestCase): result = builder.build_packed_3mf([PlateJob(source_3mf, 3)], options) self.assertEqual(result.plate_count, 3) - self.assertEqual(m73_template_calls, 1) + self.assertEqual(m73_template_calls, 1) # 模板仅准备 1 次 + # 3 份副本的 M73 剩余时间已分别偏移 self.assertIn("M73 P0 R6010", captured_gcode) self.assertIn("M73 P0 R12010", captured_gcode) self.assertIn("M73 P0 R18010", captured_gcode) @staticmethod def write_archive(path: Path) -> None: + """创建包含带 M73 指令和待替换行的 plate_1 G-code 的 3MF 测试归档。""" slice_info = """ diff --git a/tests/test_m73_offset_template.py b/tests/test_m73_offset_template.py index b7da359..1402222 100644 --- a/tests/test_m73_offset_template.py +++ b/tests/test_m73_offset_template.py @@ -1,3 +1,9 @@ +"""测试 M73 偏移模板(M73 Offset Template)的生成与应用。 + +验证内容: +- 模板应用结果与先前正则替换实现的语义一致 +- 不含 M73 指令的 G-code 返回原始文本不做修改 +""" from __future__ import annotations import unittest @@ -6,18 +12,22 @@ from a1_swap_mod_packer.gcode import M73_RE, apply_plate_number_offset, prepare_ class M73OffsetTemplateTest(unittest.TestCase): + """测试 M73 剩余时间偏移模板的正确性。""" + def test_template_matches_previous_regex_replacement_semantics(self) -> None: + """验证新模板方式生成的偏移结果与旧正则替换方式完全一致。""" gcode = ( "M73 P0 R10\n" - "; M73 P0 R20\n" - " M73 P0 R30\n" - "M73 P50 R5999 ; comment\n" - "G1 X1\n" - "M73 P100 R0\n" + "; M73 P0 R20\n" # 被注释的 M73 行不应匹配 + " M73 P0 R30\n" # 带前导空格的 M73 行 + "M73 P50 R5999 ; comment\n" # 带注释的 M73 行 + "G1 X1\n" # 非 M73 行 + "M73 P100 R0\n" # 剩余时间为 0 的 M73 行 ) def previous_apply(text: str, plate_number: int) -> str: - minute_offset = plate_number * 100 * 60 + """旧版正则替换实现:对每个匹配的 M73 行增加时间偏移。""" + minute_offset = plate_number * 100 * 60 # 每板偏移 100 分钟 def replace(match) -> str: prefix = match.group(1) @@ -27,13 +37,15 @@ class M73OffsetTemplateTest(unittest.TestCase): return M73_RE.sub(replace, text) + # 生成模板并与旧实现逐板对比 template = prepare_m73_offset_template(gcode) for plate_number in (1, 2, 10): self.assertEqual(template.apply(plate_number), previous_apply(gcode, plate_number)) self.assertEqual(apply_plate_number_offset(gcode, plate_number), previous_apply(gcode, plate_number)) def test_template_without_m73_matches_returns_original_text(self) -> None: - gcode = "G1 X1\n; M73 P0 R10\n" + """验证不含 M73 指令的 G-code 应用模板后保持原样。""" + gcode = "G1 X1\n; M73 P0 R10\n" # M73 在注释中,不应匹配 template = prepare_m73_offset_template(gcode) self.assertEqual(template.apply(3), gcode) diff --git a/tests/test_version.py b/tests/test_version.py index 0332fce..bf62993 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,3 +1,9 @@ +"""测试应用程序版本号的显示一致性。 + +验证内容: +- APP_TITLE 使用统一的 __version__ 构建 +- CLI 的 --help 和 --version 输出均使用共享的版本号 +""" from __future__ import annotations import contextlib @@ -9,19 +15,25 @@ from a1_swap_mod_packer.cli import create_parser class VersionDisplayTest(unittest.TestCase): + """测试版本信息在应用标题和 CLI 中的一致性。""" + def test_app_title_uses_shared_version(self) -> None: + """验证 APP_TITLE 由 APP_NAME 与 __version__ 拼接而成。""" self.assertEqual(APP_TITLE, f"{APP_NAME} v{__version__}") def test_cli_help_and_version_use_shared_version(self) -> None: + """验证 CLI 帮助信息和 --version 输出中均包含统一版本号。""" parser = create_parser() + # 帮助信息应包含 APP_TITLE self.assertIn(APP_TITLE, parser.format_help()) stdout = io.StringIO() + # --version 参数触发 SystemExit(0) 并打印版本信息 with self.assertRaises(SystemExit) as caught, contextlib.redirect_stdout(stdout): parser.parse_args(["--version"]) - self.assertEqual(caught.exception.code, 0) - self.assertEqual(stdout.getvalue().strip(), f"{APP_NAME} {__version__}") + self.assertEqual(caught.exception.code, 0) # 正常退出码 + self.assertEqual(stdout.getvalue().strip(), f"{APP_NAME} {__version__}") # 版本信息匹配 if __name__ == "__main__":