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

335 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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
```
- [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"
```