Files
ChildHood/tools/asset-catalog/_common.py
T
starmen1265 9f6572600b feat(tools): 添加UE5资产探索脚本系统asset-catalog
- discover_assets: 枚举Content资产,按UE5类型分类,输出index.json
- analyze_meshes: 包围盒/锚点/bbox_faces(area+orientation)/Line Trace细节包围盒/规则Tags
- analyze_materials: 动态参数提取/base_color_hex自动转换/规则Tags
- analyze_blueprints: generated_class API Actor检测/组件提取/interaction推断Tags
- run_all编排器: 断点续跑/步骤控制/错误traceback/UE5 JSON序列化兼容
- probe脚本: UE5.7 API探测(TraceTypeQuery/DrawDebugTrace枚举发现)
2026-06-09 19:01:50 +08:00

285 lines
8.6 KiB
Python
Raw 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.
"""UE5 资产探索工具 — 共享工具模块。
提供环境变量读取、JSON I/O、UE5 编辑器等待、进度日志等基础功能。
所有分析脚本共享此模块。
环境变量:
AC_SCAN_ROOT — 要扫描的 UE5 Content 路径(默认 /Game/BlockingStarterPack)
AC_OUTPUT_DIR — JSON 输出目录(绝对路径,默认 <project>/output/asset-catalog/)
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:
# 默认放在项目 output/asset-catalog/ 下
# 尝试通过 unreal API 获取项目目录
if HAS_UNREAL:
try:
project_dir = Path(unreal.Paths.project_dir()).parent
p = project_dir / "output" / "asset-catalog"
except Exception:
p = Path("output") / "asset-catalog"
else:
p = Path("output") / "asset-catalog"
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