Files
UmaViewer/tools/ExclusiveModelBatch.cs
T

380 lines
18 KiB
C#

// Copied into Assets/Editor of an isolated project by tools/export_exclusive_models.py.
// Never execute this against the user's open Unity project.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using LibMMD.Model;
using LibMMD.Reader;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
public static class ExclusiveModelBatch
{
private sealed class Outfit
{
public int id;
public string name;
public string costume;
public string error;
public long bytes;
public int textures;
}
private sealed class State
{
public List<Outfit> outfits = new List<Outfit>();
public DateTime updatedUtc;
}
private static State state;
private static string root;
private static List<Outfit> queue;
private static string statePath;
private static int cursor;
private static int attempted;
private static int batchSize;
private static int readyFrames;
private static int cleanupFrames;
private static int cleanupStartFrame;
private static AsyncOperation cleanupOperation;
private static bool loading;
private static bool done;
private static DateTime started;
private static readonly Regex Body = new Regex(@"^3d/chara/body/bdy(?<id>\d+)_(?<costume>\d{2,3})/pfb_bdy\k<id>_\k<costume>$", RegexOptions.Compiled);
public static void Run()
{
root = Environment.GetEnvironmentVariable("UMA_EXCLUSIVE_OUTPUT");
if (string.IsNullOrEmpty(root)) throw new Exception("UMA_EXCLUSIVE_OUTPUT not set");
Directory.CreateDirectory(root);
statePath = Environment.GetEnvironmentVariable("UMA_EXCLUSIVE_REPORT") ?? throw new Exception("UMA_EXCLUSIVE_REPORT not set");
batchSize = Math.Max(1, int.Parse(Environment.GetEnvironmentVariable("UMA_EXCLUSIVE_BATCH") ?? "30"));
started = DateTime.UtcNow;
SessionState.SetBool("ExclusiveModelBatch.Active", true);
EditorApplication.update -= Tick;
EditorApplication.update += Tick;
EditorSceneManager.OpenScene("Assets/Scenes/Version2.unity");
EditorApplication.isPlaying = true;
Debug.Log("[ExclusiveModelBatch] Entering isolated play mode");
}
[InitializeOnLoadMethod]
private static void Resume()
{
if (!SessionState.GetBool("ExclusiveModelBatch.Active", false)) return;
root = Environment.GetEnvironmentVariable("UMA_EXCLUSIVE_OUTPUT");
if (string.IsNullOrEmpty(root)) return;
statePath = Environment.GetEnvironmentVariable("UMA_EXCLUSIVE_REPORT") ?? throw new Exception("UMA_EXCLUSIVE_REPORT not set");
batchSize = Math.Max(1, int.Parse(Environment.GetEnvironmentVariable("UMA_EXCLUSIVE_BATCH") ?? "30"));
started = DateTime.UtcNow;
EditorApplication.update -= Tick;
EditorApplication.update += Tick;
}
private static void Tick()
{
if (done || !EditorApplication.isPlaying || UmaViewerMain.Instance == null ||
UmaViewerUI.Instance == null || UmaViewerBuilder.Instance == null ||
UmaAssetManager.instance == null || UmaViewerMain.Instance.Characters.Count == 0 ||
UmaViewerBuilder.Instance.ShaderList == null || UmaViewerBuilder.Instance.ShaderList.Count == 0 ||
UmaViewerUI.Instance.ModelSettings == null) return;
if (state == null)
{
if (++readyFrames < 15) return;
Initialize();
return;
}
if (loading) return;
if (cleanupFrames > 0)
{
if (Time.frameCount < cleanupStartFrame + 2) return;
cleanupFrames = 0;
}
if (cleanupFrames == 0 && cleanupOperation == null && attempted > 0)
{
// Destroy from UnloadUma is deferred; never unload the mesh's bundle in that frame.
UmaAssetManager.UnloadAllBundle(true);
cleanupOperation = Resources.UnloadUnusedAssets();
cleanupFrames = -1;
return;
}
if (cleanupOperation != null)
{
if (!cleanupOperation.isDone) return;
cleanupOperation = null;
GC.Collect();
}
if (attempted >= batchSize || cursor >= queue.Count)
{
Finish();
return;
}
var outfit = queue[cursor++];
var chara = UmaViewerMain.Instance.Characters.FirstOrDefault(c => !c.IsMob && c.Id == outfit.id);
if (chara == null) { Fail(outfit, "Character absent from Main.Characters"); return; }
try
{
if (Validate(outfit, false)) return;
string path = $"3d/chara/body/bdy{outfit.id}_{outfit.costume}/pfb_bdy{outfit.id}_{outfit.costume}";
if (!UmaViewerMain.Instance.AbList.TryGetValue(path, out var body) || !File.Exists(body.Path))
{
Fail(outfit, "Dedicated body asset absent on disk: " + path);
return;
}
var list = new List<UmaDatabaseEntry>();
var main = UmaViewerMain.Instance;
string bodyPrefix = $"3d/chara/body/bdy{outfit.id}_{outfit.costume}/";
string headPrefix = $"3d/chara/head/chr{outfit.id}_{outfit.costume}/";
string baseHeadPrefix = $"3d/chara/head/chr{outfit.id}_00/";
string tailPrefix = $"3d/chara/tail/tail{outfit.id}_{outfit.costume}/";
list.AddRange(main.AbChara.Where(e => e.Name.StartsWith(bodyPrefix, StringComparison.Ordinal) ||
e.Name.StartsWith(headPrefix, StringComparison.Ordinal) ||
e.Name.StartsWith(baseHeadPrefix, StringComparison.Ordinal) ||
e.Name.StartsWith(tailPrefix, StringComparison.Ordinal)));
// The ordinary fallback tail may be shared with many characters; include its bundles too.
int tailId = Convert.ToInt32(UmaDatabaseController.ReadCharaData(chara)["tail_model_id"]);
if (tailId > 0)
{
string fallback = $"3d/chara/tail/tail{tailId:0000}_00/";
list.AddRange(main.AbChara.Where(e => e.Name.StartsWith(fallback, StringComparison.Ordinal)));
}
list.Add(main.AbList["3d/animator/drivenkeylocator"]);
attempted++;
loading = true;
UmaViewerUI.Instance.ModelSettings.SetHeadFix(false);
UmaViewerUI.Instance.ModelSettings.SetTPose(true);
UmaAssetManager.PreLoadAndRun(list, () =>
{
try
{
// LoadUma is synchronous up to its yield break, as in the existing UI callsite.
var routine = UmaViewerBuilder.Instance.LoadUma(chara, outfit.costume, false);
while (routine.MoveNext()) { }
var container = UmaViewerBuilder.Instance.CurrentUMAContainer;
if (container == null || container.Body == null || container.Head == null ||
container.CharaEntry != chara || container.IsGeneric)
throw new Exception("Builder did not construct a complete exclusive model");
string dir = OutfitDirectory(outfit);
// Drop an interrupted prior attempt entirely: PNG exporter skips filenames already present.
if (Directory.Exists(dir)) Directory.Delete(dir, true);
Directory.CreateDirectory(dir);
ModelExporter.ExportModel(container, Path.Combine(dir, "model.pmx"));
if (!Validate(outfit, true)) throw new Exception("PMX readback or texture verification failed");
outfit.error = null;
Debug.Log($"[ExclusiveModelBatch] PASS {outfit.id}_{outfit.costume} pmxBytes={outfit.bytes} textures={outfit.textures}");
}
catch (Exception ex)
{
Fail(outfit, ex.ToString());
try
{
string partial = OutfitDirectory(outfit);
if (Directory.Exists(partial)) Directory.Delete(partial, true);
}
catch (Exception cleanupError)
{
outfit.error += "\nFailed to remove partial output: " + cleanupError;
}
}
finally
{
try
{
UmaViewerBuilder.Instance.UnloadUma();
UmaViewerBuilder.Instance.CurrentUMAContainer = null;
}
catch (Exception ex) { Debug.LogError("[ExclusiveModelBatch] Cleanup: " + ex); }
// Destroy is deferred until Unity advances its play-mode frame.
cleanupStartFrame = Time.frameCount;
cleanupFrames = 1;
cleanupOperation = null;
loading = false;
Save();
}
});
}
catch (Exception ex) { Fail(outfit, ex.ToString()); loading = false; }
}
private static void Initialize()
{
var main = UmaViewerMain.Instance;
if (File.Exists(statePath)) state = JsonConvert.DeserializeObject<State>(File.ReadAllText(statePath));
if (state == null) state = new State();
var prior = state.outfits.ToDictionary(o => o.id + "_" + o.costume);
var characters = main.Characters.Where(c => !c.IsMob).ToDictionary(c => c.Id);
state.outfits = main.AbList.Keys.Select(key => Body.Match(key))
.Where(match => match.Success)
.Select(match => new { id = int.Parse(match.Groups["id"].Value), costume = match.Groups["costume"].Value })
.Where(x => characters.ContainsKey(x.id))
.GroupBy(x => x.id + "_" + x.costume).Select(group => group.First())
.OrderBy(x => x.id).ThenBy(x => x.costume)
.Select(x => prior.TryGetValue(x.id + "_" + x.costume, out var old)
? new Outfit { id = x.id, name = characters[x.id].Name, costume = x.costume, bytes = old.bytes, textures = old.textures, error = old.error }
: new Outfit { id = x.id, name = characters[x.id].Name, costume = x.costume })
.ToList();
foreach (var outfit in state.outfits.Where(o => o.bytes > 0))
{
long expected = outfit.bytes;
string pmx = Path.Combine(OutfitDirectory(outfit), "model.pmx");
if (!Validate(outfit, false) || outfit.bytes != expected || new FileInfo(pmx).Length != expected)
{
outfit.bytes = 0;
outfit.error = null;
}
}
string requested = Environment.GetEnvironmentVariable("UMA_EXCLUSIVE_RETRY_IDS") ?? "";
var retryIds = new HashSet<string>(requested.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
queue = state.outfits.Where(o => o.bytes == 0 &&
(o.error == null || retryIds.Contains(o.id + "_" + o.costume))).ToList();
string only = Environment.GetEnvironmentVariable("UMA_EXCLUSIVE_ONLY");
if (!string.IsNullOrEmpty(only))
{
var targetIds = new HashSet<string>(only.Split(','));
queue = queue.Where(o => targetIds.Contains(o.id + "_" + o.costume)).ToList();
}
// Missing local bundles are reportable candidates, never silently omitted from the asset/master intersection.
Debug.Log($"[ExclusiveModelBatch] DISCOVER characters={main.Characters.Count} exclusiveOutfits={state.outfits.Count} pending={queue.Count} batch={batchSize}");
Save();
}
private static string OutfitDirectory(Outfit outfit) => Path.Combine(root, "models", outfit.id.ToString(), outfit.costume);
private static bool Validate(Outfit outfit, bool strict)
{
string dir = OutfitDirectory(outfit);
string path = Path.Combine(dir, "model.pmx");
if (!File.Exists(path)) return false;
try
{
var pmx = new PMXReader().Read(path, new ModelConfig { GlobalToonPath = "Toon" });
if (pmx.Vertices == null || pmx.Vertices.Length == 0 || pmx.Parts == null || pmx.Parts.Length == 0 ||
pmx.Bones == null || pmx.Bones.Length == 0 || pmx.TextureList.Count == 0)
throw new Exception("PMX has empty geometry, bones, or texture table");
if (pmx.TriangleIndexes == null || pmx.TriangleIndexes.Length == 0 ||
pmx.Parts.Sum(part => part.TriangleIndexNum) != pmx.TriangleIndexes.Length)
throw new Exception("PMX material groups do not cover all triangle indices");
foreach (int index in pmx.TriangleIndexes)
if (index < 0 || index >= pmx.Vertices.Length)
throw new Exception("PMX triangle references an invalid vertex " + index);
foreach (var part in pmx.Parts)
if (part.TriangleIndexNum < 0 || part.TriangleIndexNum % 3 != 0 ||
part.Material == null || part.Material.Texture == null ||
!pmx.TextureList.Contains(part.Material.Texture))
throw new Exception("PMX has invalid material coverage or texture reference");
foreach (var vertex in pmx.Vertices)
{
if (!Finite(vertex.Coordinate.x) || !Finite(vertex.Coordinate.y) || !Finite(vertex.Coordinate.z) ||
!Finite(vertex.Normal.x) || !Finite(vertex.Normal.y) || !Finite(vertex.Normal.z) ||
!Finite(vertex.UvCoordinate.x) || !Finite(vertex.UvCoordinate.y))
throw new Exception("PMX contains non-finite vertex geometry");
ValidateSkinning(vertex.SkinningOperator, pmx.Bones.Length);
}
foreach (var texture in pmx.TextureList)
{
string relative = texture.TexturePath.Replace('\\', '/');
if (!relative.StartsWith("Texture2D/", StringComparison.Ordinal) || relative.Contains("..") ||
!relative.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
throw new Exception("Invalid texture path " + relative);
string texturePath = Path.Combine(dir, relative.Replace('/', Path.DirectorySeparatorChar));
using (var file = File.OpenRead(texturePath))
{
var signature = new byte[8];
if (file.Length < 32 || file.Read(signature, 0, 8) != 8 ||
!signature.SequenceEqual(new byte[] {137, 80, 78, 71, 13, 10, 26, 10}))
throw new Exception("Missing/corrupt PNG " + relative);
}
}
outfit.bytes = new FileInfo(path).Length;
outfit.textures = pmx.TextureList.Count;
outfit.error = null;
return true;
}
catch (Exception ex)
{
if (strict) throw;
outfit.error = "Existing output invalid; re-export: " + ex.Message;
return false;
}
}
private static bool Finite(float value) => !float.IsNaN(value) && !float.IsInfinity(value);
private static void ValidateInfluence(int bone, float weight, int boneCount)
{
if (!Finite(weight) || weight < 0 || weight > 1 ||
(weight > 0 && (bone < 0 || bone >= boneCount)))
throw new Exception($"PMX has invalid bone influence bone={bone} weight={weight}");
}
private static void ValidateSkinning(SkinningOperator skin, int boneCount)
{
if (skin == null) throw new Exception("PMX vertex lacks bone skinning");
switch (skin.Type)
{
case SkinningOperator.SkinningType.SkinningBdef1:
ValidateInfluence(((SkinningOperator.Bdef1)skin.Param).BoneId, 1, boneCount);
break;
case SkinningOperator.SkinningType.SkinningBdef2:
var two = (SkinningOperator.Bdef2)skin.Param;
ValidateInfluence(two.BoneId[0], two.BoneWeight, boneCount);
ValidateInfluence(two.BoneId[1], 1 - two.BoneWeight, boneCount);
break;
case SkinningOperator.SkinningType.SkinningBdef4:
var four = (SkinningOperator.Bdef4)skin.Param;
float total = 0;
for (int i = 0; i < 4; i++)
{
ValidateInfluence(four.BoneId[i], four.BoneWeight[i], boneCount);
total += four.BoneWeight[i];
}
if (Math.Abs(total - 1) > 0.001f)
throw new Exception("PMX bone weights do not sum to one: " + total);
break;
case SkinningOperator.SkinningType.SkinningSdef:
var sdef = (SkinningOperator.Sdef)skin.Param;
ValidateInfluence(sdef.BoneId[0], sdef.BoneWeight, boneCount);
ValidateInfluence(sdef.BoneId[1], 1 - sdef.BoneWeight, boneCount);
break;
default:
throw new Exception("PMX vertex has unsupported skinning type " + skin.Type);
}
}
private static void Fail(Outfit outfit, string error)
{
outfit.bytes = 0;
outfit.textures = 0;
outfit.error = error;
Debug.LogError($"[ExclusiveModelBatch] FAIL {outfit.id}_{outfit.costume}: {error}");
Save();
}
private static void Save()
{
state.updatedUtc = DateTime.UtcNow;
string temp = statePath + ".tmp";
File.WriteAllText(temp, JsonConvert.SerializeObject(state, Formatting.Indented));
if (File.Exists(statePath)) File.Replace(temp, statePath, null);
else File.Move(temp, statePath);
}
private static void Finish()
{
done = true;
Save();
int valid = state.outfits.Count(o => o.bytes > 0 && o.error == null);
int failed = state.outfits.Count(o => o.error != null);
Debug.Log($"[ExclusiveModelBatch] DONE total={state.outfits.Count} valid={valid} failures={failed} attempted={attempted} duration={DateTime.UtcNow - started}");
SessionState.SetBool("ExclusiveModelBatch.Active", false);
EditorApplication.update -= Tick;
EditorApplication.Exit(0);
}
}