042bebe9d7
- 默认模型改为 glm-5.1,API地址改为 coding 套餐端点
- 默认批次大小 20→5,增加 max_tokens=4096
- 优化 prompt 模板,明确要求返回 {"results": [...]} 格式
- 修复全批失败时 ai_inference_metadata 的 KeyError
- 输出目录从 <project>/output/ 统一到 <tool>/outputs/
- 新增 README.md(Pipeline流程、环境变量、CLI参数说明)
277 lines
8.3 KiB
Python
277 lines
8.3 KiB
Python
"""UE5 资产探索工具 — 共享工具模块。
|
||
|
||
提供环境变量读取、JSON I/O、UE5 编辑器等待、进度日志等基础功能。
|
||
所有分析脚本共享此模块。
|
||
|
||
环境变量:
|
||
AC_SCAN_ROOT — 要扫描的 UE5 Content 路径(默认 /Game/BlockingStarterPack)
|
||
AC_OUTPUT_DIR — JSON 输出目录(绝对路径,默认 <tool_dir>/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
|