using WeaLib;
namespace NResUI.Rendering.Materials;
/// Результат эвристического поиска WEA рядом с MSH.
public sealed class WeaResourceMatch
{
public required string Path { get; init; }
public required string Reason { get; init; }
public required WeaFile File { get; init; }
}
///
/// UI-side resolver для связи MSH-файла с WEA-файлом.
/// Это не часть WeaLib, потому что matching зависит от экспортированных имен и расположения ресурсов на диске.
///
public static class WeaResourceResolver
{
///
/// Ищет WEA рядом с MSH и сразу парсит его.
/// Если файл не найден или битый, viewport все равно может показать модель без материалов.
///
public static WeaResourceMatch? TryLoadMatchingWeaForMsh(string mshPath, ICollection? warnings = null)
{
var match = FindMatchingWeaPath(mshPath, out var reason);
if (match == null)
return null;
var parseResult = WeaParser.ReadFile(match);
if (parseResult.WeaFile == null)
{
warnings?.Add($"Failed to parse WEA '{match}': {parseResult.Error}");
return null;
}
return new WeaResourceMatch
{
Path = match,
Reason = reason,
File = parseResult.WeaFile
};
}
private static string? FindMatchingWeaPath(string mshPath, out string reason)
{
reason = "No matching WEA found.";
var directory = Path.GetDirectoryName(mshPath);
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
return null;
var mshResourceName = GetExportedResourceNameWithoutExtension(mshPath);
var mshObjectStem = ExtractObjectStem(mshResourceName);
var candidates = Directory.EnumerateFiles(directory, "*.wea", SearchOption.TopDirectoryOnly)
.Select(path => new
{
Path = path,
ResourceName = GetExportedResourceNameWithoutExtension(path),
})
.Select(x => new
{
x.Path,
x.ResourceName,
ObjectStem = ExtractObjectStem(x.ResourceName),
})
.ToList();
var exact = candidates.FirstOrDefault(x => string.Equals(x.ObjectStem, mshObjectStem,
StringComparison.OrdinalIgnoreCase));
// Лучший случай: MESH_* и WEAR_* после нормализации указывают на один объект.
if (exact != null)
{
reason = "Object stem matched exactly.";
return exact.Path;
}
var best = candidates
.Select(x => new
{
x.Path,
Score = CommonPrefixTokenCount(
SplitResourceTokens(mshObjectStem),
SplitResourceTokens(x.ObjectStem))
})
.OrderByDescending(x => x.Score)
.FirstOrDefault();
// Fallback нужен для экспортов, где суффиксы MESH/WEAR отличаются, но начало имени объекта совпадает.
if (best?.Score > 0)
{
reason = $"Shared first {best.Score} resource token(s).";
return best.Path;
}
return null;
}
private static string GetExportedResourceNameWithoutExtension(string path)
{
var name = Path.GetFileNameWithoutExtension(path);
var firstUnderscore = name.IndexOf('_');
// Экспортер часто добавляет numeric prefix: 58_MESH_..., 81_WEAR_...
if (firstUnderscore > 0 && name[..firstUnderscore].All(char.IsDigit))
name = name[(firstUnderscore + 1)..];
return name;
}
private static string ExtractObjectStem(string resourceName)
{
var normalized = resourceName;
// Тип ресурса не является частью имени объекта, поэтому MESH_o_x и WEAR_o_x должны совпасть.
foreach (var prefix in new[] { "MESH_", "WEAR_", "TEXT_", "ANIM_" })
{
if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[prefix.Length..];
break;
}
}
var tokens = normalized.Split('_', StringSplitOptions.RemoveEmptyEntries);
return string.Join('_', tokens);
}
private static string[] SplitResourceTokens(string value)
{
return value.Split('_', StringSplitOptions.RemoveEmptyEntries);
}
private static int CommonPrefixTokenCount(string[] a, string[] b)
{
var count = Math.Min(a.Length, b.Length);
var result = 0;
for (var i = 0; i < count; i++)
{
if (!string.Equals(a[i], b[i], StringComparison.OrdinalIgnoreCase))
break;
result++;
}
return result;
}
}