From 9f6572600b644ef8c011c2847c4dc5c975f0e8fc Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Tue, 9 Jun 2026 19:01:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=E6=B7=BB=E5=8A=A0UE5=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E6=8E=A2=E7=B4=A2=E8=84=9A=E6=9C=AC=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?asset-catalog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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枚举发现) --- tools/asset-catalog/__init__.py | 0 tools/asset-catalog/_common.py | 284 +++++++++ tools/asset-catalog/analyze_blueprints.py | 488 ++++++++++++++++ tools/asset-catalog/analyze_materials.py | 348 +++++++++++ tools/asset-catalog/analyze_meshes.py | 634 +++++++++++++++++++++ tools/asset-catalog/discover_assets.py | 219 +++++++ tools/asset-catalog/probe_collision_api.py | 159 ++++++ tools/asset-catalog/probe_trace_v2.py | 102 ++++ tools/asset-catalog/run_all.bat | 61 ++ tools/asset-catalog/run_all.py | 213 +++++++ tools/asset-catalog/run_probe.bat | 46 ++ tools/asset-catalog/run_probe_v2.bat | 46 ++ 12 files changed, 2600 insertions(+) create mode 100644 tools/asset-catalog/__init__.py create mode 100644 tools/asset-catalog/_common.py create mode 100644 tools/asset-catalog/analyze_blueprints.py create mode 100644 tools/asset-catalog/analyze_materials.py create mode 100644 tools/asset-catalog/analyze_meshes.py create mode 100644 tools/asset-catalog/discover_assets.py create mode 100644 tools/asset-catalog/probe_collision_api.py create mode 100644 tools/asset-catalog/probe_trace_v2.py create mode 100644 tools/asset-catalog/run_all.bat create mode 100644 tools/asset-catalog/run_all.py create mode 100644 tools/asset-catalog/run_probe.bat create mode 100644 tools/asset-catalog/run_probe_v2.bat diff --git a/tools/asset-catalog/__init__.py b/tools/asset-catalog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/asset-catalog/_common.py b/tools/asset-catalog/_common.py new file mode 100644 index 0000000..33de6ce --- /dev/null +++ b/tools/asset-catalog/_common.py @@ -0,0 +1,284 @@ +"""UE5 资产探索工具 — 共享工具模块。 + +提供环境变量读取、JSON I/O、UE5 编辑器等待、进度日志等基础功能。 +所有分析脚本共享此模块。 + +环境变量: + AC_SCAN_ROOT — 要扫描的 UE5 Content 路径(默认 /Game/BlockingStarterPack) + AC_OUTPUT_DIR — JSON 输出目录(绝对路径,默认 /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 diff --git a/tools/asset-catalog/analyze_blueprints.py b/tools/asset-catalog/analyze_blueprints.py new file mode 100644 index 0000000..9769ab7 --- /dev/null +++ b/tools/asset-catalog/analyze_blueprints.py @@ -0,0 +1,488 @@ +"""UE5 蓝图结构提取脚本。 + +对 index.json 中发现的蓝图资产提取: +- 是否为 Actor 类型蓝图 +- 组件层次结构(类型、名称、相对变换) +- StaticMeshComponent 引用的模型路径 +- 触发区域组件(BoxComponent / SphereComponent) +- 非 Actor 类型资产列表(Curve、Sound、Struct 等) +- AI 占位字段(interaction_type_tags, mechanism_tags, description) + +输出: output/blueprints/.json,每个子目录一个文件 + +用法: + 交互模式: exec(open(r"...\analyze_blueprints.py").read()) + 命令行: 通过 run_all.py 调用 +""" + +import sys +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, + write_json, read_json, wait_for_editor, progress_log, + iso_now, safe_subfolder_name, +) + +TAG = "[BlueprintAnalysis]" + + +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 + + +# ============================================================ +# 自动 Tag 生成 +# ============================================================ + +import re + +_BP_NAME_RULES = [ + (r"Door", ["door", "interactive"]), + (r"Button|PushButton|FloorButton|Lever|Valve", ["button", "interactive"]), + (r"Lamp|CeilingLight|RoadLamp|RoadLight|FlamingTorch|BlockingLights", ["lighting"]), + (r"Decal|Zone|Circle|Octagon|Shape|Stripe|Arrow|Square", ["decal", "marker"]), + (r"Procedural|BarrierWall|StairPlane|SpeedBump|Ladder", ["procedural"]), + (r"Tooltip|DisplayPanel|CameraPreview", ["ui", "display"]), + (r"Human|Scale", ["reference"]), + (r"Spline", ["spline", "utility"]), +] + + +def _generate_blueprint_tags(name: str) -> list: + """根据蓝图名生成 tags。""" + tags = [] + for pattern, rule_tags in _BP_NAME_RULES: + if re.search(pattern, name): + for t in rule_tags: + if t not in tags: + tags.append(t) + if not tags: + tags = ["unclassified"] + return tags + + +# 已知的非 Actor 蓝图类型 +NON_ACTOR_TYPES = { + "CurveVector", "CurveLinearColor", "CurveFloat", + "SoundCue", "SoundWave", "SoundClass", + "UserDefinedStruct", "UserDefinedEnum", + "NiagaraSystem", "NiagaraEmitter", "NiagaraModule", + "Texture2D", "TextureCube", + "DataTable", + "StringTable", + "AnimSequence", "AnimMontage", "BlendSpace", + "WidgetBlueprint", +} + + +def _is_actor_blueprint(obj) -> bool: + """判断加载的对象是否为 Actor 类型的蓝图。 + + 纯 API 检测,多层回退: + 1. 尝试 obj.generated_class() → 检查父类链 + 2. 尝试 obj.get_editor_property("generated_class") → 检查父类链 + 3. 尝试 obj.get_class() 的父类链(对 Blueprint 资产通常到不了 Actor) + """ + import unreal + + # 方法 1 & 2: 通过 generated_class 属性获取生成的类 + gen_class = None + for getter in [ + lambda: obj.generated_class(), + lambda: obj.get_editor_property("generated_class"), + lambda: getattr(obj, "generated_class", None), + ]: + try: + gen_class = getter() + if gen_class: + break + except Exception: + continue + + if gen_class: + # 检查生成类的父类链是否到 Actor + if _check_class_is_actor(gen_class): + return True + + # 方法 3: 直接加载 _C 后缀的生成类 + try: + obj_path = obj.get_path_name() + # /Game/.../B_AutoDoor.B_AutoDoor → /Game/.../B_AutoDoor.B_AutoDoor_C + generated_path = obj_path.rsplit(".", 1) + if len(generated_path) == 2: + gen_path = generated_path[0] + "." + generated_path[1] + "_C" + gen_asset = unreal.load_asset(gen_path) + if gen_asset and _check_class_is_actor(gen_asset): + return True + except Exception: + pass + + # 方法 4: 对 obj.get_class() 做父类链检查(通常失败但值得尝试) + try: + obj_class = obj.get_class() + if _check_class_is_actor(obj_class): + return True + except Exception: + pass + + return False + + +def _check_class_is_actor(ue_class) -> bool: + """检查 UE 类的父类链中是否包含 Actor。""" + try: + current = ue_class + visited = set() + while current: + name = current.get_name() + if name in visited: + break + visited.add(name) + if name == "Actor": + return True + if name in ("Object", "Interface", "None", "Object"): + break + current = current.get_super_class() + except Exception: + pass + return False + + +def _extract_component_info(comp) -> dict: + """从组件提取信息。""" + import unreal + + comp_info = { + "type": comp.get_class().get_name(), + "name": "", + "relative_location": None, + "relative_rotation": None, + "relative_scale": None, + } + + try: + comp_info["name"] = comp.get_name() + except Exception: + pass + + try: + rel_loc = comp.get_editor_property("relative_location") + if rel_loc: + comp_info["relative_location"] = _round3([rel_loc.x, rel_loc.y, rel_loc.z]) + except Exception: + pass + + try: + rel_rot = comp.get_editor_property("relative_rotation") + if rel_rot: + comp_info["relative_rotation"] = _round3([rel_rot.pitch, rel_rot.yaw, rel_rot.roll]) + except Exception: + pass + + try: + rel_scale = comp.get_editor_property("relative_scale3d") or comp.get_editor_property("relative_scale") + if rel_scale: + comp_info["relative_scale"] = _round3([rel_scale.x, rel_scale.y, rel_scale.z]) + except Exception: + pass + + # StaticMeshComponent: 提取引用的模型 + if "StaticMeshComponent" in comp_info["type"] or "StaticMesh" in comp_info["type"]: + try: + sm = comp.get_static_mesh() + if sm: + comp_info["static_mesh_path"] = sm.get_path_name() + except Exception: + pass + # 尝试另一种方式 + if "static_mesh_path" not in comp_info: + try: + sm = comp.get_editor_property("static_mesh") + if sm: + comp_info["static_mesh_path"] = sm.get_path_name() + except Exception: + pass + + # SkeletalMeshComponent: 提取引用的骨骼模型 + if "SkeletalMeshComponent" in comp_info["type"] or "SkeletalMesh" in comp_info["type"]: + try: + skm = comp.get_editor_property("skeletal_mesh") + if skm: + comp_info["skeletal_mesh_path"] = skm.get_path_name() + except Exception: + pass + + return comp_info + + +def _analyze_single_blueprint(asset_info: dict) -> dict: + """分析单个蓝图资产。""" + import unreal + + ue_path = asset_info["ue_path"] + name = asset_info["name"] + ue_class_type = asset_info.get("ue_class_type", "Unknown") + + result = { + "ue_path": ue_path, + "ue_class_type": ue_class_type, + "is_actor_blueprint": False, + "components": [], + "analysis_notes": [], + # AI Pass 2 占位 + "interaction_type_tags": [], + "mechanism_tags": [], + "description": "", + } + + try: + obj = unreal.load_asset(ue_path) + except Exception as e: + result["analysis_notes"].append(f"加载失败: {e}") + return result + + if obj is None: + result["analysis_notes"].append("加载返回 None") + result["style_tags"] = _generate_blueprint_tags(name) + return result + + # 检查是否为已知非 Actor 类型 + actual_class = type(obj).__name__ + if actual_class in NON_ACTOR_TYPES: + result["analysis_notes"].append(f"非 Actor 类型: {actual_class}") + result["style_tags"] = _generate_blueprint_tags(name) + return result + + # 尝试判断是否为 Actor 蓝图 + is_actor = _is_actor_blueprint(obj) + result["is_actor_blueprint"] = is_actor + + if not is_actor: + result["analysis_notes"].append(f"非 Actor 蓝图: {actual_class}") + result["style_tags"] = _generate_blueprint_tags(name) + return result + + # ---- 提取组件 ---- + try: + # 优先通过 generated_class 获取 CDO + bp_class = None + for getter in [ + lambda: obj.generated_class(), + lambda: obj.get_editor_property("generated_class"), + lambda: obj.get_class(), + ]: + try: + bp_class = getter() + if bp_class: + break + except Exception: + continue + + if bp_class: + cdo = bp_class.get_default_object() + if cdo: + components = _extract_all_components(cdo) + result["components"] = components + result["component_count"] = len(components) + else: + result["analysis_notes"].append("CDO 获取返回 None") + else: + result["analysis_notes"].append("无法获取 Blueprint 类") + except Exception as e: + result["analysis_notes"].append(f"组件提取失败: {e}") + + # ---- 自动 Tags ---- + result["style_tags"] = _generate_blueprint_tags(name) + + # ---- 基于 Tags 推断 interaction_type_tags ---- + interaction_tags = [] + if result["components"]: + comp_types = [c.get("type", "") for c in result["components"]] + comp_names = [c.get("name", "") for c in result["components"]] + has_trigger = any("Box" in t or "Sphere" in t or "Capsule" in t or + "Trigger" in n for t, n in zip(comp_types, comp_names)) + has_timeline = any("Timeline" in n for n in comp_names) + has_smc = any("StaticMesh" in t for t in comp_types) + + if has_trigger and "door" in str(result["style_tags"]): + interaction_tags.extend(["proximity_trigger", "auto_door"]) + elif has_trigger and "button" in str(result["style_tags"]): + interaction_tags.extend(["press_interact", "button_trigger"]) + elif has_trigger: + interaction_tags.append("proximity_trigger") + if has_timeline: + interaction_tags.append("animated") + if has_smc and not has_trigger and "interactive" not in str(result["style_tags"]): + interaction_tags.append("static_prop") + result["interaction_type_tags"] = interaction_tags + + return result + + +def _extract_all_components(cdo) -> list: + """从 CDO 提取所有组件信息。""" + import unreal + + components = [] + + try: + # 方法 1: get_all_components (某些版本可用) + all_comps = cdo.get_all_components() + if all_comps: + for comp in all_comps: + comp_info = _extract_component_info(comp) + components.append(comp_info) + return components + except Exception: + pass + + try: + # 方法 2: 通过 BlueprintCreatedComponents 或实例化组件 + # 尝试获取组件列表 + comp_prop = cdo.get_editor_property("blueprint_created_components") + if comp_prop: + for item in comp_prop: + try: + comp = item.get_editor_property("component") + if comp: + comp_info = _extract_component_info(comp) + components.append(comp_info) + except Exception: + pass + except Exception: + pass + + try: + # 方法 3: 通过 InstanceComponents + inst_comps = cdo.get_editor_property("instance_components") + if inst_comps: + for comp in inst_comps: + comp_info = _extract_component_info(comp) + components.append(comp_info) + except Exception: + pass + + # 去重(某些方法可能重复获取) + seen = set() + unique = [] + for c in components: + key = (c.get("type", ""), c.get("name", "")) + if key not in seen: + seen.add(key) + unique.append(c) + + return unique + + +# ============================================================ +# 主流程 +# ============================================================ + +def run(index_data: dict = None) -> 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) + + blueprints_by_subfolder = index_data.get("blueprints", {}) + if not blueprints_by_subfolder: + print(f"{TAG} 无蓝图资产需要分析") + return {"status": "ok", "analyzed": 0} + + # 创建输出目录 + blueprints_dir = output_dir / "blueprints" + blueprints_dir.mkdir(parents=True, exist_ok=True) + + total_all = sum(len(assets) for assets in blueprints_by_subfolder.values()) + print(f"{TAG} 共 {total_all} 个蓝图资产,分布在 {len(blueprints_by_subfolder)} 个子目录") + + analyzed = 0 + skipped_non_actor = 0 + + for subfolder, assets in blueprints_by_subfolder.items(): + safe_name = safe_subfolder_name(subfolder) + output_file = blueprints_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 += len(existing.get("actor_blueprints", {})) + skipped_non_actor += len(existing.get("skipped_non_actor", [])) + continue + except Exception: + pass + + print(f"{TAG} 分析子目录: {subfolder} ({len(assets)} 个资产)") + actor_blueprints = {} + non_actor = [] + sub_analyzed = 0 + + for i, asset_info in enumerate(assets): + progress_log(i, len(assets), f"{asset_info['name']}") + + result = _analyze_single_blueprint(asset_info) + + if result.get("is_actor_blueprint"): + actor_blueprints[asset_info["name"]] = result + sub_analyzed += 1 + else: + non_actor.append({ + "name": asset_info["name"], + "ue_class_type": result.get("ue_class_type", asset_info.get("ue_class_type", "Unknown")), + "note": result.get("analysis_notes", []), + }) + + # 写入分组文件 + group_data = { + "subfolder": subfolder, + "source_path": f"{get_scan_root()}/Blueprints/{subfolder}" if subfolder else f"{get_scan_root()}/Blueprints", + "actor_blueprint_count": len(actor_blueprints), + "non_actor_count": len(non_actor), + "completed": True, + "actor_blueprints": actor_blueprints, + "skipped_non_actor": non_actor, + } + write_json(group_data, output_file) + + analyzed += sub_analyzed + skipped_non_actor += len(non_actor) + print(f"{TAG} ✅ {safe_name}: {sub_analyzed} Actor 蓝图, {len(non_actor)} 非 Actor → {output_file}") + + print(f"{TAG} 全部完成: {analyzed} Actor 蓝图, {skipped_non_actor} 非 Actor / {total_all} 总计") + + return { + "status": "ok", + "total": total_all, + "actor_blueprints": analyzed, + "skipped_non_actor": skipped_non_actor, + } + + +# ---- 入口 ---- +if __name__ == "__main__": + if not wait_for_editor(): + sys.exit(1) + result = run() + if result.get("status") != "ok": + sys.exit(1) diff --git a/tools/asset-catalog/analyze_materials.py b/tools/asset-catalog/analyze_materials.py new file mode 100644 index 0000000..674fbce --- /dev/null +++ b/tools/asset-catalog/analyze_materials.py @@ -0,0 +1,348 @@ +"""UE5 材质参数提取脚本。 + +对 index.json 中发现的每个材质资产提取: +- UE5 类型(MaterialInstanceConstant / Material) +- 父材质路径 +- 所有 scalar / vector 参数(动态发现,不硬编码参数名) +- AI 占位字段(base_color_hex, style_tags) + +输出: output/materials/.json,每个子目录一个文件 + +用法: + 交互模式: exec(open(r"...\analyze_materials.py").read()) + 命令行: 通过 run_all.py 调用 +""" + +import sys +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, + write_json, read_json, wait_for_editor, progress_log, + iso_now, safe_subfolder_name, +) + +TAG = "[MaterialAnalysis]" + + +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 _rgb_to_hex(r: float, g: float, b: float) -> str: + """将 0-1 范围的 RGB 转为 hex 字符串。""" + ri = max(0, min(255, round(r * 255))) + gi = max(0, min(255, round(g * 255))) + bi = max(0, min(255, round(b * 255))) + return f"#{ri:02X}{gi:02X}{bi:02X}" + + +# ============================================================ +# 自动 Tag 生成 +# ============================================================ + +import re + +_MATERIAL_NAME_RULES = [ + (r"DevRed$|DevOrange$|DevGray$|DevBrown$|DevWhite$|DevBlue$|DevGreen$|" + r"DevSkyBlue$|DevYellow$|DevFoliage$|DevPink$|DevBlack$|DevPurple$|DevLightGreen$", + ["basic_color"]), + (r"Glass", ["glass", "transparent"]), + (r"DevLight_", ["emissive", "light_color"]), + (r"Grid", ["grid", "debug"]), + (r"Mirror", ["mirror", "reflective"]), + (r"TPLA", ["tri_planar", "local_aligned"]), + (r"TPWA", ["tri_planar", "world_aligned"]), + (r"StainDecal", ["decal", "stain"]), + (r"DevArrow|DevTrigger", ["editor", "debug"]), + (r"Emissive|FullyBlack", ["special"]), + (r"DisplayPanel", ["display"]), +] + + +def _generate_material_tags(name: str) -> list: + """根据材质名生成 tags。""" + tags = [] + for pattern, rule_tags in _MATERIAL_NAME_RULES: + if re.search(pattern, name): + for t in rule_tags: + if t not in tags: + tags.append(t) + if not tags: + tags = ["unclassified"] + return tags + + +def _analyze_single_material(asset_info: dict) -> dict: + """分析单个材质资产。""" + import unreal + + ue_path = asset_info["ue_path"] + name = asset_info["name"] + ue_class_type = asset_info.get("ue_class_type", "Unknown") + + result = { + "ue_path": ue_path, + "ue_class_type": ue_class_type, + "parent_material": None, + "parameters": {"vector": [], "scalar": []}, + "analysis_notes": [], + # AI Pass 2 占位 + "base_color_hex": "", + "style_tags": [], + } + + try: + mat = unreal.load_asset(ue_path) + except Exception as e: + result["analysis_notes"].append(f"加载失败: {e}") + return result + + if mat is None: + result["analysis_notes"].append("加载返回 None") + return result + + # ---- 父材质 ---- + try: + parent = mat.get_editor_property("parent") + if parent: + result["parent_material"] = parent.get_path_name() + except Exception: + try: + base_mat = mat.get_base_material() + if base_mat: + result["parent_material"] = base_mat.get_path_name() + except Exception: + pass + + # ---- 参数提取 ---- + # MaterialInstanceConstant 有参数覆盖 + if ue_class_type == "MaterialInstanceConstant": + _extract_instance_params(mat, result) + else: + # 基础 Material: 尝试读取静态参数 + _extract_material_params(mat, result) + + # ---- 自动 Tags ---- + result["style_tags"] = _generate_material_tags(name) + + # ---- 自动 base_color_hex ---- + # 从 vector 参数中找名称含 "Color" 的参数 + for vp in result["parameters"]["vector"]: + if "color" in vp["name"].lower() and not result["base_color_hex"]: + val = vp["value"] + result["base_color_hex"] = _rgb_to_hex(val["r"], val["g"], val["b"]) + + return result + + +def _extract_instance_params(mat, result: dict): + """从 MaterialInstanceConstant 提取参数。""" + import unreal + + # 尝试方法 1: get_editor_property 获取参数列表 + try: + # scalar 参数 + scalar_params = mat.get_editor_property("scalar_parameter_values") + if scalar_params: + for sp in scalar_params: + try: + param_info = sp.get_editor_property("parameter_info") + param_value = sp.get_editor_property("parameter_value") + param_name = param_info.get_editor_property("name") if param_info else None + if param_name and param_value is not None: + result["parameters"]["scalar"].append({ + "name": str(param_name), + "value": round(float(param_value), 6), + }) + except Exception: + pass + except Exception: + pass + + try: + # vector 参数 + vector_params = mat.get_editor_property("vector_parameter_values") + if vector_params: + for vp in vector_params: + try: + param_info = vp.get_editor_property("parameter_info") + param_value = vp.get_editor_property("parameter_value") + param_name = param_info.get_editor_property("name") if param_info else None + if param_name and param_value is not None: + result["parameters"]["vector"].append({ + "name": str(param_name), + "value": { + "r": round(float(param_value.r), 4), + "g": round(float(param_value.g), 4), + "b": round(float(param_value.b), 4), + "a": round(float(param_value.a), 4), + }, + }) + except Exception: + pass + except Exception: + pass + + # 尝试方法 2: MaterialEditingLibrary + if not result["parameters"]["vector"] and not result["parameters"]["scalar"]: + _try_material_editing_lib(mat, result) + + +def _try_material_editing_lib(mat, result: dict): + """尝试通过 MaterialEditingLibrary 读取参数。""" + import unreal + + try: + # 获取 scalar 参数 + scalar_params = unreal.MaterialEditingLibrary.get_material_instance_scalar_parameter_values(mat) + if scalar_params: + for sp in scalar_params: + try: + name = sp.get_editor_property("parameter_name") if hasattr(sp, 'get_editor_property') else str(sp) + value = sp.get_editor_property("default_value") if hasattr(sp, 'get_editor_property') else None + if name and value is not None: + result["parameters"]["scalar"].append({ + "name": str(name), + "value": round(float(value), 6), + }) + except Exception: + pass + except Exception: + pass + + try: + # 获取 vector 参数 + vector_params = unreal.MaterialEditingLibrary.get_material_instance_vector_parameter_values(mat) + if vector_params: + for vp in vector_params: + try: + name = vp.get_editor_property("parameter_name") if hasattr(vp, 'get_editor_property') else str(vp) + value = vp.get_editor_property("default_value") if hasattr(vp, 'get_editor_property') else None + if name and value is not None: + result["parameters"]["vector"].append({ + "name": str(name), + "value": { + "r": round(float(value.r), 4), + "g": round(float(value.g), 4), + "b": round(float(value.b), 4), + "a": round(float(value.a), 4), + }, + }) + except Exception: + pass + except Exception: + pass + + +def _extract_material_params(mat, result: dict): + """从基础 Material 尝试提取参数。""" + # 基础 Material 的参数提取更困难 + # 标记为需要 AI 判断 + result["analysis_notes"].append("基础 Material,参数需从实例推断") + + +# ============================================================ +# 主流程 +# ============================================================ + +def run(index_data: dict = None) -> 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) + + materials_by_subfolder = index_data.get("materials", {}) + if not materials_by_subfolder: + print(f"{TAG} 无材质资产需要分析") + return {"status": "ok", "analyzed": 0} + + # 创建输出目录 + materials_dir = output_dir / "materials" + materials_dir.mkdir(parents=True, exist_ok=True) + + total_all = sum(len(assets) for assets in materials_by_subfolder.values()) + print(f"{TAG} 共 {total_all} 个材质,分布在 {len(materials_by_subfolder)} 个子目录") + + analyzed = 0 + failed = 0 + + for subfolder, assets in materials_by_subfolder.items(): + safe_name = safe_subfolder_name(subfolder) + output_file = materials_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)) + continue + except Exception: + pass + + print(f"{TAG} 分析子目录: {subfolder} ({len(assets)} 个材质)") + mat_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_material(asset_info) + mat_results[asset_info["name"]] = result + + if result.get("analysis_notes") and "加载失败" in str(result.get("analysis_notes")): + subfolder_failed += 1 + else: + subfolder_analyzed += 1 + + # 写入分组文件 + group_data = { + "subfolder": subfolder, + "source_path": f"{get_scan_root()}/Materials/{subfolder}" if subfolder else f"{get_scan_root()}/Materials", + "count": len(assets), + "completed": True, + "materials": mat_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) diff --git a/tools/asset-catalog/analyze_meshes.py b/tools/asset-catalog/analyze_meshes.py new file mode 100644 index 0000000..16d798b --- /dev/null +++ b/tools/asset-catalog/analyze_meshes.py @@ -0,0 +1,634 @@ +"""UE5 静态模型几何提取脚本。 + +对 index.json 中发现的每个 StaticMesh 提取: +- 整体包围盒(min, max, size) +- 锚点类型和 XY 偏移 +- 网格复杂度(顶点数、三角形数) +- Socket 列表 +- 材质槽位 +- 包围盒 6 面候选 +- 射线检测细节包围盒(spawn + Line Trace) +- AI 占位字段(key_surfaces, style_tags, scene_tags 等) + +输出: output/meshes/.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"] + + 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) diff --git a/tools/asset-catalog/discover_assets.py b/tools/asset-catalog/discover_assets.py new file mode 100644 index 0000000..8767ef7 --- /dev/null +++ b/tools/asset-catalog/discover_assets.py @@ -0,0 +1,219 @@ +"""UE5 资产发现脚本。 + +扫描指定 Content 路径下所有资产,记录名称、路径、UE5 类型。 +不做任何推断,仅记录 UE5 原始数据。 + +输出: output/index.json + +用法: + 交互模式: exec(open(r"...\discover_assets.py").read()) + 命令行: 通过 run_all.py 调用 +""" + +import sys +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, write_json, read_json, + wait_for_editor, progress_log, iso_now, safe_subfolder_name, +) + +TAG = "[Discover]" + +# 已知类型分类 +MESH_TYPES = {"StaticMesh"} +MATERIAL_TYPES = {"MaterialInstanceConstant", "Material"} +# 其余全部归入 others + + +def _get_subfolder(asset_path: str, scan_root: str) -> str: + """从资产完整路径中提取相对于 scan_root/Meshes|Materials|... 的子目录。 + + 例: + path = "/Game/BlockingStarterPack/Meshes/Architecture/Walls/SM_Wall_400x300.SM_Wall_400x300" + scan_root = "/Game/BlockingStarterPack" + → "Architecture/Walls" + """ + # 去掉末尾的 .XXX + base = asset_path.rsplit(".", 1)[0] + # 找到 scan_root 之后的路径 + if base.startswith(scan_root + "/"): + relative = base[len(scan_root) + 1:] + else: + relative = base + + # 去掉第一级目录和最后一级(资产名) + # Meshes/Architecture/Walls/SM_Wall_400x300 → Architecture/Walls + parts = relative.split("/") + if len(parts) > 2: + return "/".join(parts[1:-1]) # 去掉首段(Meshes 等)和末段(资产名) + return "" + + +def _get_asset_name(asset_path: str) -> str: + """从 UE 资产路径提取资产名。 + + 例: "/Game/.../SM_Wall_400x300.SM_Wall_400x300" → "SM_Wall_400x300" + """ + # 取最后一个 / 后、第一个 . 前 + last_part = asset_path.rsplit("/", 1)[-1] + return last_part.rsplit(".", 1)[0] + + +def _load_and_classify(asset_path: str) -> dict: + """加载资产并记录其 UE5 类型名。 + + Returns: + {"name": str, "ue_path": str, "ue_class_type": str} + 加载失败时 ue_class_type 为 "LOAD_FAILED" + """ + name = _get_asset_name(asset_path) + try: + import unreal + obj = unreal.load_asset(asset_path) + if obj is None: + return {"name": name, "ue_path": asset_path, "ue_class_type": "LOAD_FAILED"} + class_name = type(obj).__name__ + return {"name": name, "ue_path": asset_path, "ue_class_type": class_name} + except Exception as e: + return {"name": name, "ue_path": asset_path, "ue_class_type": f"ERROR:{e}"} + + +def run() -> dict: + """执行资产发现。 + + Returns: + 统计信息 dict + """ + if not HAS_UNREAL: + print(f"{TAG} 错误: unreal 模块不可用") + return {"status": "error", "reason": "no unreal"} + + import unreal + + scan_root = get_scan_root() + output_dir = get_output_dir() + + print(f"{TAG} 扫描根目录: {scan_root}") + print(f"{TAG} 输出目录: {output_dir}") + + # ---- 枚举所有资产 ---- + print(f"{TAG} 开始枚举资产...") + all_assets = [] + try: + asset_list = unreal.EditorAssetLibrary.list_assets(scan_root, recursive=True, include_folder=False) + all_assets = list(asset_list) + except Exception as e: + print(f"{TAG} 错误: list_assets 失败: {e}") + return {"status": "error", "reason": str(e)} + + total = len(all_assets) + print(f"{TAG} 发现 {total} 个资产") + + if total == 0: + print(f"{TAG} 未发现任何资产,退出") + return {"status": "ok", "total": 0} + + # ---- 分类 ---- + meshes = {} # subfolder -> [asset_info, ...] + materials = {} # subfolder -> [asset_info, ...] + others = {} # subfolder -> [asset_info, ...] + by_type = {} + + for i, asset_path in enumerate(all_assets): + progress_log(i, total, "资产分类") + + info = _load_and_classify(asset_path) + class_type = info["ue_class_type"] + subfolder = _get_subfolder(asset_path, scan_root) + + # 统计 + by_type[class_type] = by_type.get(class_type, 0) + 1 + + # 分类 + if class_type in MESH_TYPES: + meshes.setdefault(subfolder, []).append(info) + elif class_type in MATERIAL_TYPES: + materials.setdefault(subfolder, []).append(info) + else: + others.setdefault(subfolder, []).append(info) + + # ---- 蓝图归类 ---- + # 从 others 中提取蓝图相关(Blueprint 类型或目录含 Blueprint 的) + blueprints = {} + # UE5 的蓝图资产 class type 可能是各种名称 + # 我们把 ue_path 中包含 /Blueprints/ 的单独归类 + remaining_others = {} + for subfolder, assets in others.items(): + bp_assets = [] + other_assets = [] + for a in assets: + if "/Blueprints/" in a["ue_path"] or a["ue_class_type"] in { + "Blueprint", "BlueprintGeneratedClass", "WidgetBlueprint", + "AnimBlueprint", "BlueprintType", + }: + bp_assets.append(a) + else: + other_assets.append(a) + if bp_assets: + blueprints[subfolder] = bp_assets + if other_assets: + remaining_others[subfolder] = other_assets + + # ---- 写入 index.json ---- + index = { + "metadata": { + "generated_at": iso_now(), + "scan_root": scan_root, + "total_assets": total, + "by_type": by_type, + }, + "meshes": meshes, + "materials": materials, + "blueprints": blueprints, + "others": remaining_others, + } + + output_path = output_dir / "index.json" + write_json(index, output_path) + print(f"{TAG} ✅ 已写入: {output_path}") + + # ---- 统计 ---- + mesh_count = sum(len(v) for v in meshes.values()) + mat_count = sum(len(v) for v in materials.values()) + bp_count = sum(len(v) for v in blueprints.values()) + other_count = sum(len(v) for v in remaining_others.values()) + + stats = { + "status": "ok", + "total": total, + "meshes": mesh_count, + "materials": mat_count, + "blueprints": bp_count, + "others": other_count, + "mesh_subfolders": list(meshes.keys()), + "material_subfolders": list(materials.keys()), + "blueprint_subfolders": list(blueprints.keys()), + } + + print(f"{TAG} 统计:") + print(f"{TAG} StaticMesh: {mesh_count} ({len(meshes)} 子目录)") + print(f"{TAG} Materials: {mat_count} ({len(materials)} 子目录)") + print(f"{TAG} Blueprints: {bp_count} ({len(blueprints)} 子目录)") + print(f"{TAG} Others: {other_count}") + + return stats + + +# ---- 入口 ---- +if __name__ == "__main__": + if not wait_for_editor(): + sys.exit(1) + result = run() + if result.get("status") != "ok": + sys.exit(1) diff --git a/tools/asset-catalog/probe_collision_api.py b/tools/asset-catalog/probe_collision_api.py new file mode 100644 index 0000000..90cca58 --- /dev/null +++ b/tools/asset-catalog/probe_collision_api.py @@ -0,0 +1,159 @@ +"""UE5 Python API collision/trace 探测脚本。 + +测试哪些碰撞查询 API 在 UE5.7 Python 中可用。 + +用法: exec(open(r"...\probe_collision_api.py").read()) +""" + +import unreal + +print("=" * 60) +print("UE5 Collision API Probe") +print("=" * 60) + +# ---- 1. ETraceTypeQuery enum ---- +print("\n--- ETraceTypeQuery ---") +try: + val = unreal.ETraceTypeQuery.TRACE_TYPE_QUERY1 + print(f" ETraceTypeQuery.TRACE_TYPE_QUERY1 = {val}") +except Exception as e: + print(f" ETraceTypeQuery: FAIL - {e}") + +try: + val = unreal.TraceTypeQuery.TRACE_TYPE_QUERY1 + print(f" TraceTypeQuery.TRACE_TYPE_QUERY1 = {val}") +except Exception as e: + print(f" TraceTypeQuery: FAIL - {e}") + +# Try integer directly +print("\n--- line_trace_single with integer channel ---") +try: + # Spawn a test cube + mesh = unreal.load_asset("/Game/BlockingStarterPack/Meshes/Primitives/SM_Cube.SM_Cube") + if mesh: + subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) + actor = subsys.spawn_actor_from_object(mesh, unreal.Vector(0, 0, 0)) + if actor: + bb = mesh.get_bounding_box() + print(f" Cube bbox: min=({bb.min.x},{bb.min.y},{bb.min.z}) max=({bb.max.x},{bb.max.y},{bb.max.z})") + + # Try line_trace_single with int 0 + try: + hit = unreal.SystemLibrary.line_trace_single( + actor, + unreal.Vector(50, 50, 200), + unreal.Vector(50, 50, -200), + 0, # integer instead of enum + False, [], None, + ) + if hit: + print(f" line_trace_single(int=0): HIT at ({hit.impact_point.x},{hit.impact_point.y},{hit.impact_point.z})") + else: + print(f" line_trace_single(int=0): no hit") + except Exception as e: + print(f" line_trace_single(int=0): FAIL - {e}") + + # Try with None for draw_debug + try: + hit = unreal.SystemLibrary.line_trace_single( + actor, + unreal.Vector(50, 50, 200), + unreal.Vector(50, 50, -200), + 0, + False, [], + ) + if hit: + print(f" line_trace_single(6-arg): HIT at ({hit.impact_point.x},{hit.impact_point.y},{hit.impact_point.z})") + else: + print(f" line_trace_single(6-arg): no hit") + except Exception as e: + print(f" line_trace_single(6-arg): FAIL - {e}") + + # ---- 2. sphere_overlap_components ---- + print("\n--- sphere_overlap_components ---") + try: + result = unreal.SystemLibrary.sphere_overlap_components( + actor, + unreal.Vector(50, 50, 50), + 20.0, + [], # object types + unreal.StaticMeshComponent, + ) + print(f" sphere_overlap_components: found {len(result) if result else 0} components") + except Exception as e: + print(f" sphere_overlap_components: FAIL - {e}") + + # ---- 3. box_overlap_components ---- + print("\n--- box_overlap_components ---") + try: + result = unreal.SystemLibrary.box_overlap_components( + actor, + unreal.Vector(50, 50, 50), + unreal.Vector(10, 10, 10), + [], + unreal.StaticMeshComponent, + ) + print(f" box_overlap_components: found {len(result) if result else 0} components") + except Exception as e: + print(f" box_overlap_components: FAIL - {e}") + + # ---- 4. component-level queries ---- + print("\n--- Component-level queries ---") + try: + smc = actor.get_component_by_class(unreal.StaticMeshComponent) + if smc: + print(f" SMC found: {smc.get_name()}") + + # get_closest_point_on_collision + try: + cp = smc.get_closest_point_on_collision(unreal.Vector(0, 0, 200)) + if cp: + print(f" get_closest_point_on_collision(0,0,200): ({cp.x},{cp.y},{cp.z})") + else: + print(f" get_closest_point_on_collision: returned None") + except Exception as e: + print(f" get_closest_point_on_collision: FAIL - {e}") + + # get_bounding_box on component + try: + cbb = smc.get_bounding_box() + print(f" component.get_bounding_box: min=({cbb.min.x},{cbb.min.y},{cbb.min.z}) max=({cbb.max.x},{cbb.max.y},{cbb.max.z})") + except Exception as e: + print(f" component.get_bounding_box: FAIL - {e}") + + except Exception as e: + print(f" Component queries: FAIL - {e}") + + # Cleanup + unreal.EditorLevelLibrary.destroy_actor(actor) + +except Exception as e: + print(f" Setup error: {e}") + +# ---- 5. Check what SystemLibrary has ---- +print("\n--- SystemLibrary overlap/trace methods ---") +methods = [m for m in dir(unreal.SystemLibrary) if 'overlap' in m.lower() or 'trace' in m.lower()] +for m in methods: + print(f" {m}") + +print("\n--- EditorLevelLibrary methods ---") +methods = [m for m in dir(unreal.EditorLevelLibrary) if 'overlap' in m.lower() or 'trace' in m.lower()] +for m in methods: + print(f" {m}") + +print("\n" + "=" * 60) +print("Probe complete.") + +# Write results to file +try: + from pathlib import Path + out = Path(__file__).parent.parent.parent / "output" / "asset-catalog" / "probe_result.txt" + out.parent.mkdir(parents=True, exist_ok=True) + with open(out, "w", encoding="utf-8") as f: + f.write("See UE5 Output Log for probe results.\n") + print(f"Result marker written to {out}") +except Exception: + pass + +# Quit editor +unreal.SystemLibrary.execute_console_command(None, "quit") diff --git a/tools/asset-catalog/probe_trace_v2.py b/tools/asset-catalog/probe_trace_v2.py new file mode 100644 index 0000000..4f7da61 --- /dev/null +++ b/tools/asset-catalog/probe_trace_v2.py @@ -0,0 +1,102 @@ +"""UE5 Line Trace API 精确探测。""" + +import unreal + +print("=" * 60) +print("Line Trace API Probe v2") +print("=" * 60) + +# Spawn test cube +mesh = unreal.load_asset("/Game/BlockingStarterPack/Meshes/Primitives/SM_Cube.SM_Cube") +subsys = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) +actor = subsys.spawn_actor_from_object(mesh, unreal.Vector(0, 0, 0)) + +trace_ch = unreal.TraceTypeQuery.TRACE_TYPE_QUERY1 +print(f"trace_channel = {trace_ch} (type: {type(trace_ch).__name__})") + +# 1. Find draw_debug_type enum +print("\n--- Find draw_debug_type ---") +for name in ['EDrawDebugTrace', 'DrawDebugTrace']: + try: + enum_cls = getattr(unreal, name) + print(f" unreal.{name} found: {dir(enum_cls)}") + none_val = enum_cls.NONE + print(f" {name}.NONE = {none_val}") + break + except AttributeError: + print(f" unreal.{name}: not found") + +# 2. Test line_trace_single with correct enum +print("\n--- Test line_trace_single ---") +try: + draw_debug = unreal.EDrawDebugTrace.NONE +except AttributeError: + draw_debug = None + +# Attempt 1: 7 args with draw_debug +print(f" Attempt 1: 7 args, draw_debug={draw_debug}") +try: + hit = unreal.SystemLibrary.line_trace_single( + actor, + unreal.Vector(50, 50, 200), + unreal.Vector(50, 50, -200), + trace_ch, + False, [], draw_debug, + ) + print(f" HIT: {hit}") + if hit and hasattr(hit, 'impact_point'): + print(f" impact: ({hit.impact_point.x},{hit.impact_point.y},{hit.impact_point.z})") +except Exception as e: + print(f" FAIL: {e}") + +# Attempt 2: try line_trace_single_new +print(f" Attempt 2: line_trace_single_new") +try: + hit = unreal.SystemLibrary.line_trace_single_new( + actor, + unreal.Vector(50, 50, 200), + unreal.Vector(50, 50, -200), + trace_ch, + False, [], + ) + print(f" HIT: {hit}") + if hit and hasattr(hit, 'impact_point'): + print(f" impact: ({hit.impact_point.x},{hit.impact_point.y},{hit.impact_point.z})") +except Exception as e: + print(f" FAIL: {e}") + +# Attempt 3: line_trace_single_for_objects +print(f" Attempt 3: line_trace_single_for_objects") +try: + hit = unreal.SystemLibrary.line_trace_single_for_objects( + actor, + unreal.Vector(50, 50, 200), + unreal.Vector(50, 50, -200), + [unreal.ObjectTypeQuery1], + False, [], draw_debug, + ) + print(f" HIT: {hit}") +except Exception as e: + print(f" FAIL: {e}") + +# Attempt 4: line_trace_single_by_profile +print(f" Attempt 4: line_trace_single_by_profile") +try: + hit = unreal.SystemLibrary.line_trace_single_by_profile( + actor, + unreal.Vector(50, 50, 200), + unreal.Vector(50, 50, -200), + "Visibility", + False, [], + ) + print(f" HIT: {hit}") + if hit and hasattr(hit, 'impact_point'): + print(f" impact: ({hit.impact_point.x},{hit.impact_point.y},{hit.impact_point.z})") +except Exception as e: + print(f" FAIL: {e}") + +# Cleanup +unreal.EditorLevelLibrary.destroy_actor(actor) +print("\n" + "=" * 60) +print("Done.") +unreal.SystemLibrary.execute_console_command(None, "quit") diff --git a/tools/asset-catalog/run_all.bat b/tools/asset-catalog/run_all.bat new file mode 100644 index 0000000..dbb705c --- /dev/null +++ b/tools/asset-catalog/run_all.bat @@ -0,0 +1,61 @@ +@echo off +setlocal enabledelayedexpansion + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_DIR=%SCRIPT_DIR%..\.." +for %%i in ("%PROJECT_DIR%\.") do set "PROJECT_ROOT=%%~fi" +set "UPROJECT=%PROJECT_ROOT%\ChildHood.uproject" + +set "UE_EDITOR=" +if defined UE5_ROOT ( + if exist "%UE5_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe" ( + set "UE_EDITOR=%UE5_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe" + ) +) +if "%UE_EDITOR%"=="" ( + for %%p in ( + "C:\Program Files\Epic Games\UE_5.7" + "C:\Program Files (x86)\Epic Games\UE_5.7" + "D:\Program Files\Epic Games\UE_5.7" + ) do ( + if not defined UE_EDITOR ( + if exist "%%~p\Engine\Binaries\Win64\UnrealEditor.exe" ( + set "UE_EDITOR=%%~p\Engine\Binaries\Win64\UnrealEditor.exe" + ) + ) + ) +) +if "%UE_EDITOR%"=="" ( + echo [ERROR] UE5 not found. Set UE5_ROOT or install to standard path. + exit /b 1 +) + +if not defined AC_SCAN_ROOT set "AC_SCAN_ROOT=/Game/BlockingStarterPack" +if not defined AC_OUTPUT_DIR set "AC_OUTPUT_DIR=%PROJECT_ROOT%\output\asset-catalog" +if not defined AC_STEPS set "AC_STEPS=discover,mesh,material,blueprint" +if not defined AC_NO_QUIT set "AC_NO_QUIT=0" + +set "AC_PROJECT_ROOT=%PROJECT_ROOT%" + +set "SCRIPT_PATH=%SCRIPT_DIR%run_all.py" +set "SCRIPT_PATH=!SCRIPT_PATH:\=/!" +set "UPROJECT_FWD=!UPROJECT:\=/!" +set "AC_OUTPUT_DIR_FWD=!AC_OUTPUT_DIR:\=/!" + +echo. +echo ================================================ +echo UE5 Asset Catalog Tool +echo ================================================ +echo UE5: !UE_EDITOR! +echo Project: !UPROJECT_FWD! +echo Script: !SCRIPT_PATH! +echo Scan Root: !AC_SCAN_ROOT! +echo Output: !AC_OUTPUT_DIR_FWD! +echo Steps: !AC_STEPS! +echo ================================================ +echo. + +"!UE_EDITOR!" "!UPROJECT_FWD!" -unattended -NoSound -NullRHI -ExecutePythonScript=!SCRIPT_PATH! + +echo. +echo Done. Check output in: !AC_OUTPUT_DIR_FWD! diff --git a/tools/asset-catalog/run_all.py b/tools/asset-catalog/run_all.py new file mode 100644 index 0000000..84a5ace --- /dev/null +++ b/tools/asset-catalog/run_all.py @@ -0,0 +1,213 @@ +"""UE5 资产探索编排器。 + +在单次 UE5 编辑器会话内顺序执行所有分析步骤。 + +步骤: + 1. discover — 资产发现 + 2. mesh — 模型几何提取 + 3. material — 材质参数提取 + 4. blueprint — 蓝图结构提取 + +通过 AC_STEPS 环境变量控制执行哪些步骤(逗号分隔)。 +默认全部执行。 + +环境变量(由 run_all.bat 设置): + AC_SCAN_ROOT — 扫描路径 + AC_OUTPUT_DIR — 输出目录 + AC_NO_QUIT — 是否不退出 + AC_STEPS — 步骤列表 + AC_PROJECT_ROOT — 项目根目录(用于 sys.path) +""" + +import json +import os +import sys +import time +from pathlib import Path + +# ---- sys.path 设置 ---- +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +# 项目根目录 +_project_root = os.environ.get("AC_PROJECT_ROOT", "") +if _project_root: + PROJECT_ROOT = Path(_project_root) +else: + PROJECT_ROOT = _SCRIPT_DIR.parent.parent + +# ---- Unreal import ---- +try: + import unreal + HAS_UNREAL = True +except ImportError: + HAS_UNREAL = False + print("[AssetCatalog] 错误: unreal 模块不可用,必须在 UE5 环境中运行") + sys.exit(1) + +from _common import ( + get_scan_root, get_output_dir, get_steps, should_no_quit, + write_json, wait_for_editor, quit_editor, iso_now, +) + +TAG = "[AssetCatalog]" + + +def run(): + """主流程:顺序执行所有启用的步骤。""" + print(f"{TAG} {'=' * 50}") + print(f"{TAG} UE5 资产探索工具") + print(f"{TAG} {'=' * 50}") + + scan_root = get_scan_root() + output_dir = get_output_dir() + steps = get_steps() + + print(f"{TAG} 扫描路径: {scan_root}") + print(f"{TAG} 输出目录: {output_dir}") + print(f"{TAG} 执行步骤: {', '.join(steps)}") + print(f"{TAG}") + + # 确保输出目录存在 + output_dir.mkdir(parents=True, exist_ok=True) + + # 等待编辑器就绪 + if not wait_for_editor(): + print(f"{TAG} 错误: 编辑器初始化超时") + return + + log = { + "started_at": iso_now(), + "scan_root": scan_root, + "output_dir": str(output_dir), + "steps": [], + } + + index_data = None + + # ---- Step 0: Discover ---- + if "discover" in steps: + print(f"\n{TAG} {'-' * 40}") + print(f"{TAG} Step 0: Discover") + print(f"{TAG} {'-' * 40}") + + t0 = time.time() + from discover_assets import run as run_discover + result = run_discover() + elapsed = time.time() - t0 + + result["elapsed_sec"] = round(elapsed, 1) + log["steps"].append({"name": "discover", **result}) + + if result.get("status") == "ok": + # load index.json for subsequent steps + try: + index_data = json.loads( + (output_dir / "index.json").read_text(encoding="utf-8") + ) + except Exception as e: + print(f"{TAG} WARN: load index.json failed: {e}") + else: + print(f"{TAG} Discover failed, skipping remaining steps") + log["completed_at"] = iso_now() + log["status"] = "discover_failed" + _save_log(log, output_dir) + _maybe_quit() + return + + # ---- Step 1: Meshes ---- + if "mesh" in steps: + print(f"\n{TAG} {'-' * 40}") + print(f"{TAG} Step 1: Mesh Analysis") + print(f"{TAG} {'-' * 40}") + + t0 = time.time() + try: + from analyze_meshes import run as run_mesh + result = run_mesh(index_data) + except Exception as e: + print(f"{TAG} ERROR in mesh analysis: {e}") + import traceback + traceback.print_exc() + result = {"status": "error", "reason": str(e)} + elapsed = time.time() - t0 + + result["elapsed_sec"] = round(elapsed, 1) + log["steps"].append({"name": "mesh", **result}) + + # ---- Step 2: Materials ---- + if "material" in steps: + print(f"\n{TAG} {'-' * 40}") + print(f"{TAG} Step 2: Material Analysis") + print(f"{TAG} {'-' * 40}") + + t0 = time.time() + try: + from analyze_materials import run as run_material + result = run_material(index_data) + except Exception as e: + print(f"{TAG} ERROR in material analysis: {e}") + import traceback + traceback.print_exc() + result = {"status": "error", "reason": str(e)} + elapsed = time.time() - t0 + + result["elapsed_sec"] = round(elapsed, 1) + log["steps"].append({"name": "material", **result}) + + # ---- Step 3: Blueprints ---- + if "blueprint" in steps: + print(f"\n{TAG} {'-' * 40}") + print(f"{TAG} Step 3: Blueprint Analysis") + print(f"{TAG} {'-' * 40}") + + t0 = time.time() + try: + from analyze_blueprints import run as run_blueprint + result = run_blueprint(index_data) + except Exception as e: + print(f"{TAG} ERROR in blueprint analysis: {e}") + import traceback + traceback.print_exc() + result = {"status": "error", "reason": str(e)} + elapsed = time.time() - t0 + + result["elapsed_sec"] = round(elapsed, 1) + log["steps"].append({"name": "blueprint", **result}) + + # ---- 汇总 ---- + log["completed_at"] = iso_now() + log["status"] = "ok" + _save_log(log, output_dir) + + print(f"\n{TAG} {'=' * 50}") + print(f"{TAG} 全部完成") + print(f"{TAG} {'=' * 50}") + for step in log["steps"]: + print(f"{TAG} {step['name']}: {step.get('status', '?')} " + f"({step.get('analyzed', step.get('total', '?'))} 成功, " + f"{step.get('elapsed_sec', '?')}s)") + + _maybe_quit() + + +def _save_log(log: dict, output_dir: Path): + """保存运行日志。""" + log_path = output_dir / "catalog_log.json" + write_json(log, log_path) + print(f"{TAG} 日志已保存: {log_path}") + + +def _maybe_quit(): + """根据 AC_NO_QUIT 决定是否退出编辑器。""" + if should_no_quit(): + print(f"{TAG} AC_NO_QUIT=1,不退出编辑器") + else: + print(f"{TAG} 退出编辑器...") + quit_editor() + + +# ---- 入口 ---- +if __name__ == "__main__": + run() diff --git a/tools/asset-catalog/run_probe.bat b/tools/asset-catalog/run_probe.bat new file mode 100644 index 0000000..b600e1d --- /dev/null +++ b/tools/asset-catalog/run_probe.bat @@ -0,0 +1,46 @@ +@echo off +setlocal enabledelayedexpansion + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_DIR=%SCRIPT_DIR%..\.." +for %%i in ("%PROJECT_DIR%\.") do set "PROJECT_ROOT=%%~fi" +set "UPROJECT=%PROJECT_ROOT%\ChildHood.uproject" + +set "UE_EDITOR=" +if defined UE5_ROOT ( + if exist "%UE5_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe" ( + set "UE_EDITOR=%UE5_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe" + ) +) +if "%UE_EDITOR%"=="" ( + for %%p in ( + "C:\Program Files\Epic Games\UE_5.7" + "C:\Program Files (x86)\Epic Games\UE_5.7" + "D:\Program Files\Epic Games\UE_5.7" + ) do ( + if not defined UE_EDITOR ( + if exist "%%~p\Engine\Binaries\Win64\UnrealEditor.exe" ( + set "UE_EDITOR=%%~p\Engine\Binaries\Win64\UnrealEditor.exe" + ) + ) + ) +) +if "%UE_EDITOR%"=="" ( + echo [ERROR] UE5 not found. + exit /b 1 +) + +set "SCRIPT_PATH=%SCRIPT_DIR%probe_collision_api.py" +set "SCRIPT_PATH=!SCRIPT_PATH:\=/!" +set "UPROJECT_FWD=!UPROJECT:\=/!" + +echo. +echo === Collision API Probe === +echo UE5: !UE_EDITOR! +echo Script: !SCRIPT_PATH! +echo. + +"!UE_EDITOR!" "!UPROJECT_FWD!" -unattended -NoSound -NullRHI -ExecutePythonScript=!SCRIPT_PATH! + +echo. +echo Done. diff --git a/tools/asset-catalog/run_probe_v2.bat b/tools/asset-catalog/run_probe_v2.bat new file mode 100644 index 0000000..affae77 --- /dev/null +++ b/tools/asset-catalog/run_probe_v2.bat @@ -0,0 +1,46 @@ +@echo off +setlocal enabledelayedexpansion + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_DIR=%SCRIPT_DIR%..\.." +for %%i in ("%PROJECT_DIR%\.") do set "PROJECT_ROOT=%%~fi" +set "UPROJECT=%PROJECT_ROOT%\ChildHood.uproject" + +set "UE_EDITOR=" +if defined UE5_ROOT ( + if exist "%UE5_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe" ( + set "UE_EDITOR=%UE5_ROOT%\Engine\Binaries\Win64\UnrealEditor.exe" + ) +) +if "%UE_EDITOR%"=="" ( + for %%p in ( + "C:\Program Files\Epic Games\UE_5.7" + "C:\Program Files (x86)\Epic Games\UE_5.7" + "D:\Program Files\Epic Games\UE_5.7" + ) do ( + if not defined UE_EDITOR ( + if exist "%%~p\Engine\Binaries\Win64\UnrealEditor.exe" ( + set "UE_EDITOR=%%~p\Engine\Binaries\Win64\UnrealEditor.exe" + ) + ) + ) +) +if "%UE_EDITOR%"=="" ( + echo [ERROR] UE5 not found. + exit /b 1 +) + +set "SCRIPT_PATH=%SCRIPT_DIR%probe_trace_v2.py" +set "SCRIPT_PATH=!SCRIPT_PATH:\=/!" +set "UPROJECT_FWD=!UPROJECT:\=/!" + +echo. +echo === Collision API Probe === +echo UE5: !UE_EDITOR! +echo Script: !SCRIPT_PATH! +echo. + +"!UE_EDITOR!" "!UPROJECT_FWD!" -unattended -NoSound -NullRHI -ExecutePythonScript=!SCRIPT_PATH! + +echo. +echo Done.