"""UE5 资产探索工具 — 共享工具模块。 提供环境变量读取、JSON I/O、UE5 编辑器等待、进度日志等基础功能。 所有分析脚本共享此模块。 环境变量: AC_SCAN_ROOT — 要扫描的 UE5 Content 路径(默认 /Game/BlockingStarterPack) AC_OUTPUT_DIR — JSON 输出目录(绝对路径,默认 /outputs/) AC_NO_QUIT — 设为 "1" 运行后不退出编辑器 AC_STEPS — 逗号分隔的步骤列表(默认 discover,mesh,material,blueprint) """ import json import os import sys import time from pathlib import Path # ---- Unreal import guard ---- try: import unreal HAS_UNREAL = True except ImportError: HAS_UNREAL = False # ---- 项目根目录 ---- _SCRIPT_DIR = Path(__file__).resolve().parent # ============================================================ # 环境变量读取 # ============================================================ def get_scan_root() -> str: """返回要扫描的 UE5 Content 路径。""" return os.environ.get("AC_SCAN_ROOT", "/Game/BlockingStarterPack") def get_output_dir() -> Path: """返回 JSON 输出目录(Path),自动创建。""" raw = os.environ.get("AC_OUTPUT_DIR", "") if raw: p = Path(raw) else: # 默认放在工具所在目录的 outputs/ 下 p = _SCRIPT_DIR / "outputs" p.mkdir(parents=True, exist_ok=True) return p def should_no_quit() -> bool: """运行完成后是否不退出编辑器。""" return os.environ.get("AC_NO_QUIT", "0") == "1" def get_steps() -> list: """返回要执行的步骤列表。""" raw = os.environ.get("AC_STEPS", "discover,mesh,material,blueprint") return [s.strip() for s in raw.split(",") if s.strip()] # ============================================================ # JSON I/O # ============================================================ class _UE5JsonEncoder(json.JSONEncoder): """JSON encoder that handles UE5 Python types (unreal.Name, etc.).""" def default(self, obj): try: return str(obj) except Exception: return super().default(obj) def write_json(data, filepath): """写入 JSON 文件,ensure_ascii=False, indent=2。 自动处理 UE5 Python 类型(unreal.Name 等)。 Args: data: 可序列化的 Python 对象 filepath: Path 或 str,绝对路径 """ p = Path(filepath) p.parent.mkdir(parents=True, exist_ok=True) with open(p, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2, cls=_UE5JsonEncoder) def read_json(filepath) -> dict: """读取 JSON 文件。 Args: filepath: Path 或 str Returns: 解析后的 dict """ with open(filepath, "r", encoding="utf-8") as f: return json.load(f) # ============================================================ # UE5 编辑器等待 # ============================================================ def wait_for_editor(timeout=90): """轮询等待 UE5 EditorActorSubsystem 可用。 Args: timeout: 最大等待秒数 Returns: True 如果编辑器就绪,False 如果超时 """ if not HAS_UNREAL: print("[AssetCatalog] 错误: unreal 模块不可用") return False print("[AssetCatalog] 等待编辑器初始化...") start = time.time() while time.time() - start < timeout: try: subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) if subsys is not None: elapsed = time.time() - start print(f"[AssetCatalog] 编辑器就绪 ({elapsed:.1f}s)") return True except Exception: pass time.sleep(1) print(f"[AssetCatalog] 错误: 编辑器初始化超时 ({timeout}s)") return False def quit_editor(): """退出 UE5 编辑器。""" if HAS_UNREAL: unreal.SystemLibrary.execute_console_command(None, "quit") # ============================================================ # 工具函数 # ============================================================ def setup_paths(): """将 tools/ 目录加入 sys.path(如果尚未加入)。""" tools_dir = str(_SCRIPT_DIR.parent) if tools_dir not in sys.path: sys.path.insert(0, tools_dir) ac_dir = str(_SCRIPT_DIR) if ac_dir not in sys.path: sys.path.insert(0, ac_dir) def progress_log(current: int, total: int, label: str = "", interval: int = 20): """每 interval 条打印一次进度。 Args: current: 当前索引(0-based) total: 总数 label: 附加标签 interval: 打印间隔 """ if current % interval == 0 or current == total - 1: pct = (current + 1) / total * 100 print(f"[AssetCatalog] 进度: {current + 1}/{total} ({pct:.0f}%) {label}") def safe_subfolder_name(subfolder: str) -> str: """将子目录路径转为安全的文件名。 例: "Architecture/Walls" -> "Architecture_Walls" "Blocking/Basics" -> "Blocking_Basics" "" -> "root" """ if not subfolder: return "root" return subfolder.replace("/", "_").replace("\\", "_") def iso_now() -> str: """返回当前时间的 ISO 格式字符串。""" import datetime return datetime.datetime.now().isoformat() def classify_pivot(bmin, bmax, tol=5.0): """从包围盒 min/max 推断锚点类型。 Args: bmin: (x, y, z) 最小值元组 bmax: (x, y, z) 最大值元组 tol: 判断容差(cm) Returns: dict: {"type": str, "offset_xy": [float, float]} type 为 CORNER / CENTER / CENTER_BOTTOM / EDGE_BOTTOM / OTHER """ mn_x, mn_y, mn_z = bmin mx_x, mx_y, mx_z = bmax size_x = mx_x - mn_x size_y = mx_y - mn_y size_z = mx_z - mn_z half_x, half_y, half_z = size_x / 2, size_y / 2, size_z / 2 # 判断逻辑 is_corner = (abs(mn_x) < tol and abs(mn_y) < tol and abs(mn_z) < tol) is_center = (abs(mn_x + half_x) < tol and abs(mn_y + half_y) < tol and abs(mn_z + half_z) < tol) is_center_bot = (abs(mn_x + half_x) < tol and abs(mn_y + half_y) < tol and abs(mn_z) < tol) # EDGE_BOTTOM: 一端在 0, 一端在 max, 底部在 0 is_edge_bot_x = (abs(mn_x) < tol and abs(mn_z) < tol and abs(mx_y - size_y) < tol) is_edge_bot_y = (abs(mn_y) < tol and abs(mn_z) < tol and abs(mx_x - size_x) < tol) offset_xy = [(mn_x + mx_x) / 2, (mn_y + mx_y) / 2] if is_center: pivot_type = "CENTER" elif is_center_bot: pivot_type = "CENTER_BOTTOM" elif is_corner: pivot_type = "CORNER" elif is_edge_bot_x or is_edge_bot_y: pivot_type = "EDGE_BOTTOM" else: pivot_type = "OTHER" return {"type": pivot_type, "offset_xy": offset_xy} def compute_bbox_faces(bmin, bmax): """根据包围盒 6 个面生成表面信息,含面积和方向标签。 Args: bmin: (x, y, z) 最小值 bmax: (x, y, z) 最大值 Returns: list of dict, 按面积降序排列 """ mn_x, mn_y, mn_z = bmin mx_x, mx_y, mx_z = bmax cx = (mn_x + mx_x) / 2 cy = (mn_y + mx_y) / 2 cz = (mn_z + mx_z) / 2 sx = mx_x - mn_x sy = mx_y - mn_y sz = mx_z - mn_z raw_faces = [ {"face": "top", "center": [cx, cy, mx_z], "normal": [0, 0, 1], "size": [sx, sy], "orientation": "horizontal_up"}, {"face": "bottom", "center": [cx, cy, mn_z], "normal": [0, 0, -1], "size": [sx, sy], "orientation": "horizontal_down"}, {"face": "front", "center": [cx, mx_y, cz], "normal": [0, 1, 0], "size": [sx, sz], "orientation": "vertical_y+"}, {"face": "back", "center": [cx, mn_y, cz], "normal": [0, -1, 0], "size": [sx, sz], "orientation": "vertical_y-"}, {"face": "left", "center": [mn_x, cy, cz], "normal": [-1, 0, 0], "size": [sy, sz], "orientation": "vertical_x-"}, {"face": "right", "center": [mx_x, cy, cz], "normal": [1, 0, 0], "size": [sy, sz], "orientation": "vertical_x+"}, ] # 计算面积和宽高比 for f in raw_faces: w, h = f["size"] f["area"] = round(abs(w * h), 1) f["aspect_ratio"] = round(max(w, h) / min(w, h), 2) if min(w, h) > 0 else 0 # 按面积降序排列 raw_faces.sort(key=lambda f: f["area"], reverse=True) return raw_faces