Files
AIC-Project/docs/superpowers/plans/2026-07-15-unity-menu-model-translator.md

13 KiB
Raw Permalink Blame History

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.pyCLI 零改动),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

  • Step 1: 创建文件,写入完整代码

#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<string> _pending = new List<string>();

        // 仓库根 = <repo>/Client/Assets 的上两级
        private static string RepoRoot =>
            Path.GetFullPath(Path.Combine(Application.dataPath, "..", ".."));

        public static void Open()
        {
            var w = GetWindow<ModelTranslatorWindow>("模型转换(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<string> pick, Action<string> 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<DefaultAsset>(_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
  • Step 2: 提交(.cs 先行,.meta 待 Unity 生成后补)
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.metaUnity 自动生成)

  • Step 1: 让 Unity 编译

切到 Unity 编辑器等待编译,确认 Console 无报错,菜单栏出现 XWorld/模型转换(XPbr)

  • Step 2: 按 spec 清单手动验证
  1. 正常转换 Tools/ModelTranslator/src/well.fbxAssets/Res/well/ 出现且模型材质正确
  2. FBX 路径不存在 / blender 路径错 → 按钮置灰并提示,不启动进程
  3. 转换中取消 → 进程被 kill,按钮恢复
  4. 同一模型二次转换 → GUID 不变(场景中已摆放的实例不丢材质)
  5. 输出子目录填 Scene/props → 结果在 Assets/Res/Scene/props/<模型名>/
  6. 关闭窗口再打开 → 参数记住上次的值
  • Step 3: 提交 Unity 生成的 .meta
cd "D:/UD/AI/AIC#Project"
git add Client/Assets/Editor/ModelTranslatorMenu.cs.meta
git commit -m "ModelTranslator: 菜单脚本 .meta"