"""输出规划与文件命名模块。 提供换板打包任务的输出路径解析、文件命名规则、摘要汇总等功能。 支持基于模板的输出文件名生成以及同一批次内文件路径去重。 """ from __future__ import annotations import re from dataclasses import dataclass from datetime import datetime from pathlib import Path 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 weight_grams: float | None = None filament_used_m: float | None = None @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(), plate_count=int(data.get("plate_count") or 0), prediction_seconds=_optional_float(data.get("prediction_seconds")), weight_grams=_optional_float(data.get("weight_grams")), filament_used_m=_optional_float(data.get("filament_used_m")), filament_used_g=_optional_float(data.get("filament_used_g")), ) 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 prediction_found = False weight_total = 0.0 weight_found = False used_m_total = 0.0 used_m_found = False for job in jobs: summary = summary_resolver(job.source_3mf) copies = max(1, int(job.copies)) copy_count += copies plate_count += summary.plate_count * copies if summary.prediction_seconds is not None: prediction_total += summary.prediction_seconds * copies prediction_found = True if summary.weight_grams is not None: weight_total += summary.weight_grams * copies weight_found = True 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, prediction_seconds=prediction_total if prediction_found else None, weight_grams=weight_total if weight_found else None, filament_used_m=used_m_total if used_m_found else None, ) 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" def resolve_output_path( jobs: Sequence[PlateJob], naming: OutputNamingOptions, 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)) # 保持顺序的去重 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, plates=summary.plate_count, copies=summary.copy_count, date=timestamp.strftime("%Y%m%d"), time=timestamp.strftime("%H%M%S"), ) 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() output_dir = Path(output_dir_text) if output_dir_text else first_input.parent return output_dir / file_name 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) return path stem = path.stem suffix = path.suffix parent = path.parent index = 2 # 递增序号直到找到未使用的路径 while True: candidate = parent / f"{stem}_{index}{suffix}" candidate_key = candidate.resolve(strict=False) if candidate_key not in used_paths: used_paths.add(candidate_key) return candidate index += 1 def _optional_float(value: object) -> float | None: """安全地将任意对象转换为浮点数,失败时返回 None。 参数: value: 待转换的任意对象。 返回: float | None: 转换成功返回浮点数,否则返回 None。 """ if value is None: return None try: return float(value) except (TypeError, ValueError): return None