# ModelTranslator Unity 菜单集成 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Unity 菜单 `XWorld/模型转换(XPbr)`:选 FBX → 参数面板(持久化)→ 调用 Python CLI 转换到 `Assets/Res/`。 **Architecture:** 单个 EditorWindow 脚本,`Process` 异步调用现有 `Tools/ModelTranslator/model_translator.py`(CLI 零改动),`EditorApplication.update` 轮询进程与日志,完成后 `AssetDatabase.Refresh` + 选中结果目录。参数存 `EditorPrefs`。 **Tech Stack:** Unity Editor C#(无第三方依赖);测试为手动验证清单(项目无 C# 测试框架,spec 已确认)。 **Spec:** `docs/superpowers/specs/2026-07-15-unity-menu-model-translator-design.md` --- ### Task 1: ModelTranslatorMenu.cs 完整实现 **Files:** - Create: `Client/Assets/Editor/ModelTranslatorMenu.cs` - [x] **Step 1: 创建文件,写入完整代码** ```csharp #if UNITY_EDITOR using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Text; using UnityEditor; using UnityEngine; namespace XGame.Editor { public static class ModelTranslatorMenu { [MenuItem("XWorld/模型转换(XPbr)", false, 803)] public static void Open() => ModelTranslatorWindow.Open(); } public sealed class ModelTranslatorWindow : EditorWindow { private const string PrefPrefix = "XWorld.ModelTranslator."; private const string DefaultBlender = @"D:\tools\blender-5.0.0-windows-x64\blender.exe"; private static readonly int[] SizeOptions = { 512, 1024, 2048, 4096 }; private static readonly string[] SizeLabels = { "512", "1024", "2048", "4096" }; private string _fbxPath = ""; private int _maxSize = 2048; private string _outSubDir = ""; private string _blenderPath = DefaultBlender; private string _pythonPath = "python"; private Process _proc; private string _resultAssetDir; // 成功后要 ping 的 Assets 相对目录 private readonly StringBuilder _log = new StringBuilder(); private Vector2 _scroll; // stdout/stderr 回调在后台线程触发,先入队、OnUpdate 时再合入 _log private readonly object _lock = new object(); private readonly List _pending = new List(); // 仓库根 = /Client/Assets 的上两级 private static string RepoRoot => Path.GetFullPath(Path.Combine(Application.dataPath, "..", "..")); public static void Open() { var w = GetWindow("模型转换(XPbr)"); w.minSize = new Vector2(480, 380); w.Show(); } private void OnEnable() { _maxSize = EditorPrefs.GetInt(PrefPrefix + "maxSize", 2048); _outSubDir = EditorPrefs.GetString(PrefPrefix + "outSubDir", ""); _blenderPath = EditorPrefs.GetString(PrefPrefix + "blenderPath", DefaultBlender); _pythonPath = EditorPrefs.GetString(PrefPrefix + "pythonPath", "python"); EditorApplication.update += OnUpdate; } private void OnDisable() { EditorApplication.update -= OnUpdate; SavePrefs(); KillProc(); } private void SavePrefs() { EditorPrefs.SetInt(PrefPrefix + "maxSize", _maxSize); EditorPrefs.SetString(PrefPrefix + "outSubDir", _outSubDir); EditorPrefs.SetString(PrefPrefix + "blenderPath", _blenderPath); EditorPrefs.SetString(PrefPrefix + "pythonPath", _pythonPath); } private void OnGUI() { bool running = _proc != null; using (new EditorGUI.DisabledScope(running)) { PathField("FBX 文件", ref _fbxPath, () => EditorUtility.OpenFilePanel("选择 FBX", EditorPrefs.GetString(PrefPrefix + "lastFbxDir", ""), "fbx"), picked => EditorPrefs.SetString(PrefPrefix + "lastFbxDir", Path.GetDirectoryName(picked))); int idx = Array.IndexOf(SizeOptions, _maxSize); if (idx < 0) idx = 2; idx = EditorGUILayout.Popup("贴图最大尺寸", idx, SizeLabels); _maxSize = SizeOptions[idx]; _outSubDir = EditorGUILayout.TextField(new GUIContent("输出子目录", "Assets/Res/ 下的相对目录,留空=直接放 Res/<模型名>"), _outSubDir); PathField("Blender", ref _blenderPath, () => EditorUtility.OpenFilePanel("选择 blender.exe", "", "exe"), null); PathField("Python", ref _pythonPath, () => EditorUtility.OpenFilePanel("选择 python.exe", "", "exe"), null); } GUILayout.Space(4); if (!running) { string err = Validate(); using (new EditorGUI.DisabledScope(err != null)) if (GUILayout.Button("转换", GUILayout.Height(28))) StartConvert(); if (err != null) EditorGUILayout.HelpBox(err, MessageType.Info); } else { if (GUILayout.Button("取消", GUILayout.Height(28))) { KillProc(); Append("[已取消]"); } EditorGUILayout.HelpBox("转换中…(Blender 处理大模型可能要几十秒)", MessageType.Info); } _scroll = EditorGUILayout.BeginScrollView(_scroll); EditorGUILayout.TextArea(_log.ToString(), GUILayout.ExpandHeight(true)); EditorGUILayout.EndScrollView(); } // 带"选择…"按钮的路径行;onPicked 可为 null private static void PathField(string label, ref string value, Func pick, Action onPicked) { EditorGUILayout.BeginHorizontal(); value = EditorGUILayout.TextField(label, value); if (GUILayout.Button("选择…", GUILayout.Width(56))) { string p = pick(); if (!string.IsNullOrEmpty(p)) { value = p.Replace('/', '\\'); onPicked?.Invoke(p); GUI.FocusControl(null); } } EditorGUILayout.EndHorizontal(); } // 返回错误提示;null = 可以转换 private string Validate() { if (string.IsNullOrEmpty(_fbxPath)) return "请选择要转换的 FBX 文件"; if (!File.Exists(_fbxPath)) return "FBX 文件不存在:" + _fbxPath; if (!_fbxPath.ToLowerInvariant().EndsWith(".fbx")) return "请选择 .fbx 文件"; if (!File.Exists(_blenderPath)) return "未找到 blender.exe,请在上方指定路径"; // python 允许裸命令名(走 PATH),带目录时才检查存在 if ((_pythonPath.Contains("\\") || _pythonPath.Contains("/")) && !File.Exists(_pythonPath)) return "未找到 python,请安装 Python 3 或在上方指定路径"; string sub = _outSubDir.Replace('\\', '/').Trim('/', ' '); if (sub.Contains("..") || Path.IsPathRooted(sub)) return "输出子目录必须是 Assets/Res 下的相对路径"; return null; } private void StartConvert() { SavePrefs(); string sub = _outSubDir.Replace('\\', '/').Trim('/', ' '); string outRel = string.IsNullOrEmpty(sub) ? "Client/Assets/Res" : "Client/Assets/Res/" + sub; string modelName = Path.GetFileNameWithoutExtension(_fbxPath); // CLI 会在 -o 下自动建 <模型名>/ 子目录(见 model_translator.py main) _resultAssetDir = (string.IsNullOrEmpty(sub) ? "Assets/Res/" : "Assets/Res/" + sub + "/") + modelName; string script = Path.Combine(RepoRoot, "Tools", "ModelTranslator", "model_translator.py"); var psi = new ProcessStartInfo { FileName = _pythonPath, Arguments = string.Format( "\"{0}\" \"{1}\" -o \"{2}\" --max-size {3} --blender \"{4}\"", script, _fbxPath, Path.Combine(RepoRoot, outRel), _maxSize, _blenderPath), WorkingDirectory = RepoRoot, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; psi.EnvironmentVariables["PYTHONIOENCODING"] = "utf-8"; // CLI 输出中文 _log.Length = 0; Append("== " + Path.GetFileName(_fbxPath) + " -> " + _resultAssetDir); try { _proc = Process.Start(psi); } catch (Exception e) { _proc = null; EditorUtility.DisplayDialog("模型转换", "启动 python 失败:" + e.Message + "\n请安装 Python 3 或在面板中指定 python.exe 路径", "确定"); return; } _proc.OutputDataReceived += (_, ev) => Enqueue(ev.Data); _proc.ErrorDataReceived += (_, ev) => Enqueue(ev.Data); _proc.BeginOutputReadLine(); _proc.BeginErrorReadLine(); } private void Enqueue(string line) { if (line == null) return; lock (_lock) _pending.Add(line); } private void Append(string line) { _log.AppendLine(line); _scroll.y = float.MaxValue; // 滚到最底 } private void OnUpdate() { bool dirty = false; lock (_lock) { foreach (string l in _pending) { Append(l); dirty = true; } _pending.Clear(); } if (_proc != null && _proc.HasExited) { _proc.WaitForExit(); // 确保异步输出全部送达 lock (_lock) // 先冲掉剩余输出,保证 [完成]/[失败] 在日志最后 { foreach (string l in _pending) Append(l); _pending.Clear(); } int code = _proc.ExitCode; _proc.Dispose(); _proc = null; dirty = true; if (code == 0) { Append("[完成] " + _resultAssetDir); AssetDatabase.Refresh(); var folder = AssetDatabase.LoadAssetAtPath(_resultAssetDir); if (folder != null) { Selection.activeObject = folder; EditorGUIUtility.PingObject(folder); } } else { Append("[失败] 退出码 " + code); EditorUtility.DisplayDialog("模型转换", "转换失败(退出码 " + code + "),详见窗口日志", "确定"); } } if (dirty) Repaint(); } private void KillProc() { if (_proc == null) return; try { if (!_proc.HasExited) _proc.Kill(); } catch { /* 进程可能刚好退出 */ } _proc.Dispose(); _proc = null; } } } #endif ``` - [x] **Step 2: 提交(.cs 先行,.meta 待 Unity 生成后补)** ```bash cd "D:/UD/AI/AIC#Project" git add Client/Assets/Editor/ModelTranslatorMenu.cs git commit -m "ModelTranslator: Unity 菜单集成(XWorld/模型转换(XPbr))" ``` ### Task 2: Unity 内编译与手动验证 **Files:** - Create: `Client/Assets/Editor/ModelTranslatorMenu.cs.meta`(Unity 自动生成) - [x] **Step 1: 让 Unity 编译** 切到 Unity 编辑器等待编译,确认 Console 无报错,菜单栏出现 `XWorld/模型转换(XPbr)`。 - [x] **Step 2: 按 spec 清单手动验证** 1. 正常转换 `Tools/ModelTranslator/src/well.fbx` → `Assets/Res/well/` 出现且模型材质正确 2. FBX 路径不存在 / blender 路径错 → 按钮置灰并提示,不启动进程 3. 转换中取消 → 进程被 kill,按钮恢复 4. 同一模型二次转换 → GUID 不变(场景中已摆放的实例不丢材质) 5. 输出子目录填 `Scene/props` → 结果在 `Assets/Res/Scene/props/<模型名>/` 6. 关闭窗口再打开 → 参数记住上次的值 - [x] **Step 3: 提交 Unity 生成的 .meta** ```bash cd "D:/UD/AI/AIC#Project" git add Client/Assets/Editor/ModelTranslatorMenu.cs.meta git commit -m "ModelTranslator: 菜单脚本 .meta" ```