"""3MF 文件元数据读取与处理模块。 提供从 3MF 归档文件中提取切片信息、耗材数据、模型设置等元数据的功能, 以及基于元数据生成构建摘要(时间预估、耗材用量等)的辅助函数。 """ from __future__ import annotations import zipfile from pathlib import Path from typing import Iterable from xml.etree import ElementTree as ET from .archive import GCODE_MEMBER_RE, list_gcode_members from .models import BuildOptions, BuildSummary, PlateJob, PlateSource, ThreeMfSummary 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: return {} root = ET.fromstring(data.decode("utf-8-sig", errors="replace")) result: dict[int, dict[str, str]] = {} for plate in root.findall("plate"): plate_data: dict[str, str] = {} for metadata in plate.findall("metadata"): key = metadata.attrib.get("key") 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 except ValueError: index = len(result) + 1 result[index] = plate_data return result 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: return {} root = ET.fromstring(data.decode("utf-8-sig", errors="replace")) 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: plate_index = int(metadata.attrib.get("value", "1")) except ValueError: plate_index = 1 break filament = plate.find("filament") if filament is not None: result[plate_index] = dict(filament.attrib) return result 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: return [] try: root = ET.fromstring(data.decode("utf-8-sig", errors="replace")) except Exception: return [] members: list[str] = [] for plate in root.findall("plate"): 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) break return members def safe_float(value: str | None) -> float | None: """安全地将字符串转换为浮点数,失败时返回 None。 参数: value: 待转换的字符串或 None。 返回: float | None: 转换成功返回浮点数,否则返回 None。 """ if value is None: return None try: return float(value) except ValueError: return 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: if value is not None: total += value found = True return total if found else 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: raise ValueError(f"No Metadata/plate_N.gcode member was found in {source_3mf}") plate_metadata = read_slice_plate_metadata(archive) filament_metadata = read_filament_metadata(archive) predictions: list[float | None] = [] weights: list[float | None] = [] used_m_values: list[float | None] = [] used_g_values: list[float | None] = [] for member in members: match = GCODE_MEMBER_RE.match(member) 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( source_3mf=source_3mf, plate_count=len(members), prediction_seconds=_sum_optional(predictions), weight_grams=_sum_optional(weights), filament_used_m=_sum_optional(used_m_values), filament_used_g=_sum_optional(used_g_values), ) 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, total_prediction_seconds=None if summary.prediction_seconds is None else summary.prediction_seconds * multiplier, total_weight_grams=None if summary.weight_grams is None else summary.weight_grams * multiplier, total_filament_used_m=None if summary.filament_used_m is None else summary.filament_used_m * multiplier, total_filament_used_g=None if summary.filament_used_g is None else summary.filament_used_g * multiplier, ) def summarize_jobs(jobs: Iterable[PlateJob]) -> BuildSummary: """汇总所有换板作业的构建摘要。 遍历所有 PlateJob,依次读取各自的 3MF 摘要并按份数倍增后累加。 参数: jobs: PlateJob 对象的可迭代集合。 返回: BuildSummary: 所有作业合并后的构建摘要。 """ plate_count = 0 predictions: list[float | None] = [] weights: list[float | None] = [] used_m_values: list[float | None] = [] used_g_values: list[float | None] = [] for job in jobs: summary = multiply_summary(read_3mf_summary(job.source_3mf), job.copies) plate_count += summary.plate_count predictions.append(summary.total_prediction_seconds) weights.append(summary.total_weight_grams) used_m_values.append(summary.total_filament_used_m) used_g_values.append(summary.total_filament_used_g) return BuildSummary( plate_count=plate_count, total_prediction_seconds=_sum_optional(predictions), total_weight_grams=_sum_optional(weights), total_filament_used_m=_sum_optional(used_m_values), total_filament_used_g=_sum_optional(used_g_values), ) 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) total_used_g = sum(value for value in (source.filament_used_g for source in sources) if value is not None) 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: metadata.set("value", str(int(round(total_prediction)))) elif key == "weight" and total_weight > 0: metadata.set("value", f"{total_weight:.2f}") filament = first_plate.find("filament") if filament is not None: if total_used_m > 0: filament.set("used_m", f"{total_used_m:.2f}") if total_used_g > 0: filament.set("used_g", f"{total_used_g:.2f}") return ET.tostring(root, encoding="utf-8", xml_declaration=True)