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枚举发现)
This commit is contained in:
2026-06-09 19:01:50 +08:00
parent 0646d8c349
commit 9f6572600b
12 changed files with 2600 additions and 0 deletions
+284
View File
@@ -0,0 +1,284 @@
"""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