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

305 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""3MF 文件元数据读取与处理模块。
提供从 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 中解析每个 <plate> 元素内的 <metadata> 键值对,
按盘索引号 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 中提取每个 <plate> 下 <filament> 元素的属性,
按盘索引号组织。
参数:
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)