Files
ChildHood/tools/asset-catalog/analyze_meshes.py
T

644 lines
20 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 静态模型几何提取脚本。
对 index.json 中发现的每个 StaticMesh 提取:
- 整体包围盒(min, max, size)
- 锚点类型和 XY 偏移
- 网格复杂度(顶点数、三角形数)
- Socket 列表
- 材质槽位
- 包围盒 6 面候选
- 射线检测细节包围盒(spawn + Line Trace)
- AI 占位字段(key_surfaces, style_tags, scene_tags 等)
输出: output/meshes/<SubfolderName>.json,每个子目录一个文件
用法:
交互模式: exec(open(r"...\analyze_meshes.py").read())
命令行: 通过 run_all.py 调用
"""
import sys
import math
from pathlib import Path
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from _common import (
HAS_UNREAL, get_scan_root, get_output_dir, get_steps,
write_json, read_json, wait_for_editor, progress_log,
iso_now, safe_subfolder_name, classify_pivot, compute_bbox_faces,
)
TAG = "[MeshAnalysis]"
# ============================================================
# 自动 Tag 生成
# ============================================================
# (pattern, [style_tags])
_MESH_NAME_RULES = [
# 家具
(r"Table", ["furniture", "table"]),
(r"Chair|Armchair", ["furniture", "seating"]),
(r"Bed", ["furniture", "bed"]),
(r"Monitor|Laptop|Keyboard", ["furniture", "electronics"]),
(r"Broom|Radiator", ["furniture", "appliance"]),
# 建筑
(r"Wall", ["architecture", "wall"]),
(r"Floor", ["architecture", "floor"]),
(r"Door", ["architecture", "door"]),
(r"Window", ["architecture", "window"]),
(r"Stair", ["architecture", "stair"]),
(r"Beam|Ladder", ["architecture", "structure"]),
(r"Barrier|Fence", ["architecture", "barrier"]),
# 灯光
(r"Lamp|Light|Spot|Flash|Torch", ["lighting"]),
# 车辆
(r"\bCar\b|Truck|Bus|Boat|Plane|Metro|Trailer", ["vehicle"]),
# 工业
(r"Barrel|WoodenBox|WoodenCrate|Pallet|Container", ["industrial"]),
# 道路
(r"Road|Barricade|SpeedBump|Cone", ["outdoor", "road"]),
# 基础体
(r"\bCube\b|\bCylinder\b|\bSphere\b|\bCone\b|\bRamp\b|\bPipe\b|Quarter", ["primitive"]),
# 浴室
(r"Toilet|Urinal", ["bathroom"]),
# 装饰
(r"Book|Bottle|Mug|Plate|Can|Flask|Padlock|Bucket|Trash", ["decoration", "prop"]),
# 自然
(r"Tree|Foliage|Mount", ["vegetation"]),
# 武器
(r"Pistol|Sniper|Sword|Dagger|Target", ["weapon"]),
# 人物
(r"Human|Camera|Cage", ["character", "reference"]),
# 展示
(r"DisplayPanel", ["display"]),
]
# (subdirectory keyword, [scene_tags])
_MESH_SCENE_RULES = [
(r"Furnitures|Bedroom|Bathroom|Decorations", ["indoor", "home"]),
(r"Lights", ["indoor", "outdoor"]),
(r"Vehicles|Roads", ["outdoor", "street"]),
(r"Industrials|Pipes|Gears", ["outdoor", "industrial"]),
(r"SmallBuildings|BigBuildings", ["outdoor", "building"]),
(r"Architecture", ["architecture", "construction"]),
(r"Vegetations", ["outdoor", "nature"]),
(r"Primitives|Extra", ["utility"]),
(r"Weapons|Interactions", ["gameplay"]),
]
def _generate_mesh_tags(name: str, subfolder: str) -> tuple:
"""根据模型名和目录生成 style_tags 和 scene_tags。
Returns:
(style_tags, scene_tags)
"""
import re
style_tags = []
scene_tags = []
# 基于名称的 style tags
for pattern, tags in _MESH_NAME_RULES:
if re.search(pattern, name):
for t in tags:
if t not in style_tags:
style_tags.append(t)
# 基于目录的 scene tags
for pattern, tags in _MESH_SCENE_RULES:
if re.search(pattern, subfolder):
for t in tags:
if t not in scene_tags:
scene_tags.append(t)
# 如果没有任何 style tag,标记为 unknown
if not style_tags:
style_tags = ["unknown"]
if not scene_tags:
scene_tags = ["unclassified"]
return style_tags, scene_tags
# ============================================================
# 单个 Mesh 分析
# ============================================================
def _round3(v):
"""将浮点数或列表保留 3 位小数。"""
if isinstance(v, (list, tuple)):
return [round(x, 3) if isinstance(x, float) else x for x in v]
return round(v, 3) if isinstance(v, float) else v
def _try_get_complexity(mesh) -> dict:
"""尝试获取网格复杂度信息。"""
info = {"num_verts": None, "num_triangles": None}
try:
import unreal
# 尝试获取 LOD 数量
num_lod = mesh.get_num_lods()
info["num_lods"] = num_lod
except Exception:
pass
try:
# 尝试获取 section 信息
num_sections = mesh.get_num_sections(0)
info["num_sections"] = num_sections
except Exception:
pass
try:
# 尝试通过 render data 获取顶点数
render_data = mesh.get_render_data()
if render_data:
info["num_verts"] = render_data.num_vertices
info["num_triangles"] = render_data.num_triangles
except Exception:
pass
return info
def _try_get_sockets(mesh) -> list:
"""尝试获取 Socket 名称列表。"""
sockets = []
try:
import unreal
socket_names = mesh.find_socket_names() if hasattr(mesh, 'find_socket_names') else []
for name in socket_names:
try:
sock = mesh.find_socket(name)
if sock:
loc = sock.relative_location
rot = sock.relative_rotation
sockets.append({
"name": str(name),
"relative_location": _round3([loc.x, loc.y, loc.z]),
"relative_rotation": _round3([rot.pitch, rot.yaw, rot.roll]),
})
except Exception:
sockets.append({"name": str(name), "relative_location": None, "relative_rotation": None})
except Exception:
pass
return sockets
def _try_get_material_slots(mesh) -> list:
"""尝试获取材质槽位。"""
import unreal
slots = []
# 方法 1: get_material(i)
try:
num_mat = mesh.get_num_materials()
for i in range(num_mat):
slot_info = {"index": i, "slot_name": None, "material_name": None}
try:
mat = mesh.get_material(i)
if mat:
slot_info["material_name"] = mat.get_name()
except Exception:
pass
slots.append(slot_info)
except Exception:
pass
# 方法 2: get_editor_property("static_materials")
if not slots:
try:
static_mats = mesh.get_editor_property("static_materials")
if static_mats:
for i, sm in enumerate(static_mats):
slot_info = {"index": i, "slot_name": None, "material_name": None}
try:
slot_info["slot_name"] = sm.get_editor_property("material_slot_name")
mi = sm.get_editor_property("material_interface")
if mi:
slot_info["material_name"] = mi.get_name()
except Exception:
pass
slots.append(slot_info)
except Exception:
pass
# 方法 3: get_material_slot_names() — 至少获取槽位名
if not slots:
try:
slot_names = mesh.get_material_slot_names()
for i, name in enumerate(slot_names):
slots.append({"index": i, "slot_name": str(name), "material_name": None})
except Exception:
pass
# 方法 4: 用 section 数作为 fallback
if not slots:
try:
num_sections = mesh.get_num_sections(0)
for i in range(num_sections):
slots.append({"index": i, "slot_name": None, "material_name": None})
except Exception:
pass
return slots
def _analyze_single_mesh(asset_info: dict, subfolder: str = "") -> dict:
"""分析单个 StaticMesh,返回完整信息。"""
import unreal
ue_path = asset_info["ue_path"]
name = asset_info["name"]
result = {
"ue_path": ue_path,
"bounding_box": None,
"pivot": None,
"mesh_complexity": {},
"sockets": [],
"material_slots": [],
"detail_boxes": [],
"bbox_faces": [],
"analysis_notes": [],
# AI Pass 2 占位
"key_surfaces": [],
"detail_boxes_annotated": [],
"ground_sink_estimate": None,
"style_tags": [],
"scene_tags": [],
}
# 加载 mesh
try:
mesh = unreal.load_asset(ue_path)
except Exception as e:
result["analysis_notes"].append(f"加载失败: {e}")
return result
if mesh is None:
result["analysis_notes"].append("加载返回 None")
return result
# ---- 1. 包围盒 ----
try:
bb = mesh.get_bounding_box()
bmin = (bb.min.x, bb.min.y, bb.min.z)
bmax = (bb.max.x, bb.max.y, bb.max.z)
size = (bmax[0] - bmin[0], bmax[1] - bmin[1], bmax[2] - bmin[2])
result["bounding_box"] = {
"min": _round3(list(bmin)),
"max": _round3(list(bmax)),
"size": _round3(list(size)),
}
# ---- 2. 锚点 ----
result["pivot"] = classify_pivot(bmin, bmax)
# ---- 3. 包围盒面 ----
result["bbox_faces"] = compute_bbox_faces(bmin, bmax)
except Exception as e:
result["analysis_notes"].append(f"包围盒提取失败: {e}")
return result
# ---- 4. 网格复杂度 ----
result["mesh_complexity"] = _try_get_complexity(mesh)
# ---- 5. Sockets ----
result["sockets"] = _try_get_sockets(mesh)
# ---- 6. 材质槽位 ----
result["material_slots"] = _try_get_material_slots(mesh)
# ---- 7. 细节包围盒 ----
detail_boxes, trace_debug = _try_trace_detail_boxes(mesh, bmin, bmax)
if trace_debug:
result["analysis_notes"].extend(trace_debug)
if detail_boxes:
result["detail_boxes"] = detail_boxes
else:
# 降级: 仅整体包围盒
result["detail_boxes"] = [{
"label": "overall",
"min": _round3(list(bmin)),
"max": _round3(list(bmax)),
"size": _round3(list(size)),
"source": "overall",
}]
# ---- 8. 自动 Tags ----
style_tags, scene_tags = _generate_mesh_tags(name, subfolder)
result["style_tags"] = style_tags
result["scene_tags"] = scene_tags
return result
# ============================================================
# Line Trace 细节包围盒
# ============================================================
# UE5.7 Python correct enum names (discovered via probe)
_TRACE_CHANNEL = None
_DRAW_DEBUG = None
def _get_trace_enums():
"""Lazy-init: get the correct UE5.7 trace enums."""
global _TRACE_CHANNEL, _DRAW_DEBUG
if _TRACE_CHANNEL is not None:
return _TRACE_CHANNEL is not None
import unreal
try:
_TRACE_CHANNEL = unreal.TraceTypeQuery.TRACE_TYPE_QUERY1
_DRAW_DEBUG = unreal.DrawDebugTrace.NONE
return True
except Exception:
return False
def _try_trace_detail_boxes(mesh, bmin: tuple, bmax: tuple) -> tuple:
"""通过 Line Trace 获取细节包围盒。
Returns:
(detail_boxes, debug_notes)
"""
import unreal
detail_boxes = []
debug = []
size = (bmax[0] - bmin[0], bmax[1] - bmin[1], bmax[2] - bmin[2])
if max(size) < 10:
return [], ["skip_trace: model too small"]
# Check trace API availability
if not _get_trace_enums():
return [], ["trace_unavailable: enums not found"]
# ---- Spawn temporary Actor ----
try:
editor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
if not editor_subsystem:
return [], ["trace_fail: no subsystem"]
except Exception as e:
return [], [f"trace_fail: {e}"]
try:
actor = editor_subsystem.spawn_actor_from_object(mesh, unreal.Vector(0, 0, 0))
except Exception as e:
return [], [f"trace_fail: spawn {e}"]
if not actor:
return [], ["trace_fail: spawn None"]
# Set collision to BlockAll so traces can hit this mesh
smc = actor.get_component_by_class(unreal.StaticMeshComponent)
if smc:
try:
smc.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
smc.set_collision_profile_name("BlockAll")
except Exception as e:
debug.append(f"collision_set_warn: {e}")
try:
hit_points = _perform_line_traces(actor, bmin, bmax)
debug.append(f"trace_hits: {len(hit_points)}")
if len(hit_points) >= 4:
detail_boxes = _cluster_hit_points(hit_points, bmin, bmax)
if detail_boxes:
debug.append(f"trace_boxes: {len(detail_boxes)}")
finally:
try:
unreal.EditorLevelLibrary.destroy_actor(actor)
except Exception:
pass
return detail_boxes, debug
def _perform_line_traces(actor, bmin: tuple, bmax: tuple) -> list:
"""多方向 Line Trace 收集命中点。"""
import unreal
hit_points = []
mn_x, mn_y, mn_z = bmin
mx_x, mx_y, mx_z = bmax
sx, sy, sz = mx_x - mn_x, mx_y - mn_y, mx_z - mn_z
step = min(50, max(10, min(sx, sy) / 5))
margin = max(20, max(sx, sy, sz) * 0.1)
z_step = min(50, max(15, sz / 4))
trace_ch = _TRACE_CHANNEL
draw_dbg = _DRAW_DEBUG
def _trace(start_v, end_v):
try:
hit = unreal.SystemLibrary.line_trace_single(
actor, start_v, end_v,
trace_ch, False, [], draw_dbg,
)
if hit and hasattr(hit, 'impact_point'):
pt = hit.impact_point
hit_points.append((pt.x, pt.y, pt.z))
except Exception:
pass
try:
# Top-down (Z axis)
z = mn_z
while z <= mx_z + 0.1:
x = mn_x + step / 2
while x <= mx_x + 0.1:
y = mn_y + step / 2
while y <= mx_y + 0.1:
_trace(unreal.Vector(x, y, mx_z + margin),
unreal.Vector(x, y, mn_z - margin))
y += step
x += step
z += z_step
# Front (Y+)
x = mn_x + step / 2
while x <= mx_x + 0.1:
z = mn_z + step / 2
while z <= mx_z + 0.1:
_trace(unreal.Vector(x, mx_y + margin, z),
unreal.Vector(x, mn_y - margin, z))
z += step
x += step
# Right (X+)
y = mn_y + step / 2
while y <= mx_y + 0.1:
z = mn_z + step / 2
while z <= mx_z + 0.1:
_trace(unreal.Vector(mx_x + margin, y, z),
unreal.Vector(mn_x - margin, y, z))
z += step
y += step
except Exception:
pass
return hit_points
def _cluster_hit_points(hit_points: list, bmin: tuple, bmax: tuple) -> list:
"""将命中点按 Z 轴聚类为细节包围盒。"""
if not hit_points:
return []
sorted_pts = sorted(hit_points, key=lambda p: p[2])
z_slices = []
current_slice = [sorted_pts[0]]
z_gap = 10.0
for i in range(1, len(sorted_pts)):
if sorted_pts[i][2] - current_slice[-1][2] > z_gap:
z_slices.append(current_slice)
current_slice = [sorted_pts[i]]
else:
current_slice.append(sorted_pts[i])
z_slices.append(current_slice)
boxes = []
for idx, pts in enumerate(z_slices):
if len(pts) < 3:
continue
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
zs = [p[2] for p in pts]
bmn = [min(xs), min(ys), min(zs)]
bmx = [max(xs), max(ys), max(zs)]
bsz = [bmx[0]-bmn[0], bmx[1]-bmn[1], bmx[2]-bmn[2]]
if bsz[0] < 2 and bsz[1] < 2:
continue
boxes.append({
"label": f"part_{idx}",
"min": _round3(bmn), "max": _round3(bmx), "size": _round3(bsz),
"source": "trace", "hit_count": len(pts),
})
if len(boxes) > 8:
boxes.sort(key=lambda b: b["size"][0]*b["size"][1]*b["size"][2], reverse=True)
boxes = boxes[:8]
return boxes
# ============================================================
# 主流程
# ============================================================
def run(index_data: dict = None) -> dict:
"""执行模型分析。
Args:
index_data: 已加载的 index.json 数据,若为 None 则从文件读取
Returns:
统计信息 dict
"""
if not HAS_UNREAL:
print(f"{TAG} 错误: unreal 模块不可用")
return {"status": "error", "reason": "no unreal"}
output_dir = get_output_dir()
# 加载 index.json
if index_data is None:
index_path = output_dir / "index.json"
if not index_path.exists():
print(f"{TAG} 错误: index.json 不存在,请先运行 discover")
return {"status": "error", "reason": "no index.json"}
index_data = read_json(index_path)
meshes_by_subfolder = index_data.get("meshes", {})
if not meshes_by_subfolder:
print(f"{TAG} 无 StaticMesh 资产需要分析")
return {"status": "ok", "analyzed": 0}
# 创建输出目录
meshes_dir = output_dir / "meshes"
meshes_dir.mkdir(parents=True, exist_ok=True)
total_all = sum(len(assets) for assets in meshes_by_subfolder.values())
print(f"{TAG} 共 {total_all} 个 StaticMesh,分布在 {len(meshes_by_subfolder)} 个子目录")
analyzed = 0
failed = 0
global_idx = 0
for subfolder, assets in meshes_by_subfolder.items():
safe_name = safe_subfolder_name(subfolder)
output_file = meshes_dir / f"{safe_name}.json"
# 断点续跑: 跳过已完成的文件
if output_file.exists():
try:
existing = read_json(output_file)
if existing.get("completed"):
print(f"{TAG} 跳过已完成的: {safe_name}")
analyzed += existing.get("count", len(assets))
global_idx += len(assets)
continue
except Exception:
pass
print(f"{TAG} 分析子目录: {subfolder} ({len(assets)} 个模型)")
mesh_results = {}
subfolder_analyzed = 0
subfolder_failed = 0
for i, asset_info in enumerate(assets):
progress_log(i, len(assets), f"{asset_info['name']}")
result = _analyze_single_mesh(asset_info, subfolder)
mesh_results[asset_info["name"]] = result
if result.get("bounding_box") is not None:
subfolder_analyzed += 1
else:
subfolder_failed += 1
global_idx += 1
# 写入分组文件
group_data = {
"subfolder": subfolder,
"source_path": f"{get_scan_root()}/Meshes/{subfolder}" if subfolder else f"{get_scan_root()}/Meshes",
"count": len(assets),
"completed": True,
"meshes": mesh_results,
}
write_json(group_data, output_file)
analyzed += subfolder_analyzed
failed += subfolder_failed
print(f"{TAG} ✅ {safe_name}: {subfolder_analyzed} 成功, {subfolder_failed} 失败 → {output_file}")
print(f"{TAG} 全部完成: {analyzed} 成功, {failed} 失败 / {total_all} 总计")
return {
"status": "ok",
"total": total_all,
"analyzed": analyzed,
"failed": failed,
}
# ---- 入口 ----
if __name__ == "__main__":
if not wait_for_editor():
sys.exit(1)
result = run()
if result.get("status") != "ok":
sys.exit(1)