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:
@@ -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
|
||||
Reference in New Issue
Block a user