ModelTranslator: 菜单集成计划勾选完成(Unity 手动验证通过)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
dd28cf8176
commit
a405333bf7
@@ -0,0 +1,334 @@
|
||||
# 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"
|
||||
```
|
||||
Reference in New Issue
Block a user