Editor/WeaponImporter/Core/Materials/TextureMatcher.cs
#nullable enable annotations
using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Geometry;
using WeaponImporter.Core.Weapon;
namespace WeaponImporter.Core.Materials;
public enum TextureSlot { BaseColor, Normal, Roughness, Metalness, AmbientOcclusion, Emissive, Opacity }
/// <summary>A texture file matched to a material slot.</summary>
public sealed record TextureMatch
{
public required int Material { get; init; }
public required string MaterialName { get; init; }
public required TextureSlot Slot { get; init; }
public required string Path { get; init; }
/// <summary>0..1: how sure the match is (exact names ~1, fuzzy name overlap lower).</summary>
public float Confidence { get; init; }
public string Reason { get; init; } = "";
/// <summary>Already linked by the file (shown, never changed).</summary>
public bool Linked { get; init; }
/// <summary>Read from one channel of a packed map (ORM: R = AO, G = roughness, B = metalness).</summary>
public TextureChannel Channel { get; init; } = TextureChannel.All;
public string Key => $"{MaterialName}|{Slot}";
public static string Label(TextureSlot slot) => slot switch
{
TextureSlot.BaseColor => "Base Color",
TextureSlot.AmbientOcclusion => "AO",
_ => slot.ToString(),
};
}
/// <summary>
/// "Attach Textures": finds image files beside a weapon that belong to its materials but were
/// never linked (or are linked to paths on another machine), matches them to material slots by
/// name, and reports each match with a confidence. Matches at or above
/// <see cref="AutoAttach"/> are attached automatically; weaker ones are proposals the user
/// confirms. Linked textures are never replaced.
/// Ported from the original importer's TextureResolver (subject tokens, channel suffix,
/// ubiquitous-token rule), extended with confidences, packed maps and single-material weapons.
/// </summary>
public static class TextureMatcher
{
public const float AutoAttach = 0.6f;
private static readonly string[] ImageExtensions = { ".png", ".jpg", ".jpeg", ".tga", ".tif", ".tiff", ".bmp", ".psd", ".webp", ".dds" };
private static readonly string[] TextureFolders = { "textures", "texture", "tex", "maps", "images", "materials", "mats", "texturas" };
private static readonly (TextureSlot Slot, string[] Words)[] SlotWords =
{
(TextureSlot.BaseColor, new[] { "albedo", "basecolor", "diffuse", "diff", "dif", "color", "colour", "col", "alb", "bc", "d", "c" }),
(TextureSlot.Normal, new[] { "normal", "normals", "nrm", "nor", "norm", "nm", "n", "normalmap", "nrml" }),
(TextureSlot.Roughness, new[] { "roughness", "rough", "rgh", "r" }),
(TextureSlot.Metalness, new[] { "metalness", "metallic", "metal", "mtl", "m" }),
(TextureSlot.AmbientOcclusion, new[] { "ao", "occlusion", "ambientocclusion" }),
(TextureSlot.Emissive, new[] { "emissive", "emission", "glow", "illum", "selfillum", "e" }),
(TextureSlot.Opacity, new[] { "opacity", "alpha", "transparency", "mask" }),
};
/// <summary>Words that describe a texture's format rather than what it belongs to.</summary>
private static readonly HashSet<string> FormatWords = new(StringComparer.Ordinal)
{
"opengl", "open", "gl", "ogl", "directx", "direct", "dx", "x", "map", "tex", "texture", "base", "mixed", "baked", "srgb",
"1k", "2k", "4k", "8k", "t", "tx", "mat", "mi", "orm", "arm", "gloss", "glossiness", "spec", "specular", "height", "disp", "displacement", "bump",
};
/// <summary>Folders searched for images: the model's, its parent, and texture folders in both.</summary>
public static List<string> SearchFolders(string modelPath)
{
var folders = new List<string>();
void Add(string? folder, bool withTextureFolders)
{
if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
return;
var full = System.IO.Path.GetFullPath(folder);
if (!folders.Contains(full, StringComparer.OrdinalIgnoreCase))
folders.Add(full);
if (!withTextureFolders)
return;
try
{
foreach (var sub in Directory.GetDirectories(full))
if (TextureFolders.Contains(System.IO.Path.GetFileName(sub).ToLowerInvariant()))
Add(sub, false);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
}
}
var dir = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(modelPath));
Add(dir, true);
Add(dir is null ? null : System.IO.Path.GetDirectoryName(dir), true);
return folders;
}
public static List<string> FindImages(IEnumerable<string> folders)
{
var images = new List<string>();
foreach (var folder in folders)
{
try
{
var recursive = TextureFolders.Contains(System.IO.Path.GetFileName(folder).ToLowerInvariant());
foreach (var file in Directory.EnumerateFiles(folder, "*", new EnumerationOptions { RecurseSubdirectories = recursive, MaxRecursionDepth = 2, IgnoreInaccessible = true }))
if (ImageExtensions.Contains(System.IO.Path.GetExtension(file).ToLowerInvariant()) && !images.Contains(file, StringComparer.OrdinalIgnoreCase))
images.Add(file);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
// An unreadable folder never fails an import.
}
}
images.Sort(StringComparer.OrdinalIgnoreCase);
return images;
}
/// <summary>Every slot of every material: linked ones (as found) and matches for the empty ones.</summary>
public static List<TextureMatch> Match(WeaponAsset asset, string modelPath, IReadOnlyList<string>? images = null)
{
images ??= FindImages(SearchFolders(modelPath));
var materials = asset.Mesh.Materials;
var modelSubject = SubjectTokens(System.IO.Path.GetFileNameWithoutExtension(modelPath));
// Words shared by most of this weapon's textures (its own name) are judged among the
// images that relate to it, not every image in the neighbouring folders.
var known = materials.SelectMany(m => SubjectTokens(m.Name)).Concat(modelSubject).ToHashSet(StringComparer.Ordinal);
var related = images.Where(i => SubjectTokens(Stem(i)).Any(known.Contains)).ToList();
var ubiquitous = UbiquitousTokens(related.Count >= 3 ? related : images);
var result = new List<TextureMatch>();
for (var m = 0; m < materials.Count; m++)
{
var material = materials[m];
foreach (var slot in Enum.GetValues<TextureSlot>())
{
if (Get(material, slot) is { } linked)
{
result.Add(new TextureMatch { Material = m, MaterialName = material.Name, Slot = slot, Path = linked.FilePath ?? $"(embedded) {linked.Name}", Confidence = 1f, Reason = "linked in the file", Linked = true });
continue;
}
}
foreach (var match in MatchByName(m, material.Name, images, ubiquitous, materials.Count, modelSubject))
if (Get(material, match.Slot) is null)
result.Add(match);
}
return result;
}
/// <summary>The asset with the given (non-linked) matches attached; linked slots are left alone.</summary>
public static WeaponAsset Apply(WeaponAsset asset, IEnumerable<TextureMatch> matches)
{
var list = matches.Where(x => !x.Linked && File.Exists(x.Path)).ToList();
if (list.Count == 0)
return asset;
var materials = asset.Mesh.Materials.ToList();
foreach (var match in list)
{
if (match.Material < 0 || match.Material >= materials.Count || Get(materials[match.Material], match.Slot) is not null)
continue;
var tex = TextureRef.FromFile(match.Path) with { Channel = match.Channel };
materials[match.Material] = Set(materials[match.Material], match.Slot, tex);
}
var mesh = asset.Mesh;
var newMesh = new TriMesh(mesh.Positions, mesh.Indices, mesh.VertexBone, mesh.TrianglePart, mesh.PartNames, mesh.CornerNormals, mesh.CornerUVs, mesh.TriangleMaterial, materials);
var copy = new WeaponAsset { Name = asset.Name, Kind = asset.Kind, SourcePath = asset.SourcePath, Skeleton = asset.Skeleton, Mesh = newMesh, Clips = asset.Clips, Attachments = asset.Attachments };
copy.Notes.AddRange(asset.Notes);
copy.Notes.Add($"Attached {list.Count} texture{(list.Count == 1 ? "" : "s")} found beside the model.");
return copy;
}
public static TextureRef? Get(MaterialInfo m, TextureSlot slot) => slot switch
{
TextureSlot.BaseColor => m.BaseColorTexture,
TextureSlot.Normal => m.NormalTexture,
TextureSlot.Roughness => m.RoughnessTexture,
TextureSlot.Metalness => m.MetalnessTexture,
TextureSlot.AmbientOcclusion => m.AmbientOcclusionTexture,
TextureSlot.Emissive => m.EmissiveTexture,
TextureSlot.Opacity => m.OpacityTexture,
_ => null,
};
private static MaterialInfo Set(MaterialInfo m, TextureSlot slot, TextureRef t) => slot switch
{
// A colour texture shows as is: drop the placeholder tint.
TextureSlot.BaseColor => m with { BaseColorTexture = t, BaseColor = new System.Numerics.Vector4(1f, 1f, 1f, m.BaseColor.W) },
TextureSlot.Normal => m with { NormalTexture = t },
TextureSlot.Roughness => m with { RoughnessTexture = t },
TextureSlot.Metalness => m with { MetalnessTexture = t },
TextureSlot.AmbientOcclusion => m with { AmbientOcclusionTexture = t },
TextureSlot.Emissive => m with { EmissiveTexture = t },
TextureSlot.Opacity => m with { OpacityTexture = t },
_ => m,
};
// ------------------------------------------------------------------ names
private static string Stem(string path)
{
var stem = System.IO.Path.GetFileNameWithoutExtension(path);
// UDIM tile (".1001") and Blender duplicates (".001").
var dot = stem.LastIndexOf('.');
if (dot > 0 && stem[(dot + 1)..].All(char.IsDigit))
stem = stem[..dot];
return stem;
}
private static List<string> Words(string name) => NameTokens.Split(name).Where(w => !(w.Length == 4 && w.All(char.IsDigit))).ToList();
/// <summary>Name words without channel/format words: what the texture or material is about.</summary>
private static List<string> SubjectTokens(string name)
{
var words = Words(name);
// Drop the trailing channel words ("rifle_base_color" -> rifle).
while (words.Count > 0 && (FormatWords.Contains(words[^1]) || SlotOfWord(words[^1]) is not null))
words.RemoveAt(words.Count - 1);
return words.Where(w => !FormatWords.Contains(w)).ToList();
}
private static TextureSlot? SlotOfWord(string word)
{
foreach (var (slot, words) in SlotWords)
if (words.Contains(word))
return slot;
return null;
}
/// <summary>
/// The slots an image fills, from its last channel word ("rifle_base_color", "Gun_Normal_OpenGL",
/// "gun_orm" = packed AO/roughness/metalness). Null: the name doesn't say.
/// </summary>
private static List<(TextureSlot Slot, TextureChannel Channel)>? SlotsOf(string stem)
{
var words = Words(stem);
for (var i = words.Count - 1; i >= 0; i--)
{
var w = words[i];
if (w is "opengl" or "open" or "gl" or "ogl" or "directx" or "direct" or "dx" or "srgb" or "1k" or "2k" or "4k" or "8k")
continue;
if (w is "orm" or "arm")
return new() { (TextureSlot.AmbientOcclusion, TextureChannel.R), (TextureSlot.Roughness, TextureChannel.G), (TextureSlot.Metalness, TextureChannel.B) };
if (i > 0 && words[i - 1] == "base" && w is "color" or "colour")
return new() { (TextureSlot.BaseColor, TextureChannel.All) };
if (i > 0 && words[i - 1] == "ambient" && w == "occlusion")
return new() { (TextureSlot.AmbientOcclusion, TextureChannel.All) };
// Single letters only count as a suffix after a separator ("gun_n", not "gun").
if (w.Length == 1 && i == 0)
return null;
return SlotOfWord(w) is { } slot ? new() { (slot, TextureChannel.All) } : null;
}
return null;
}
private static HashSet<string> UbiquitousTokens(IReadOnlyList<string> images)
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var image in images)
foreach (var token in SubjectTokens(Stem(image)).Distinct())
counts[token] = counts.GetValueOrDefault(token) + 1;
return counts.Where(kv => kv.Value >= 3 && kv.Value * 2 >= images.Count).Select(kv => kv.Key).ToHashSet(StringComparer.Ordinal);
}
private static IEnumerable<TextureMatch> MatchByName(int materialIndex, string materialName, IReadOnlyList<string> images, HashSet<string> ubiquitous, int materialCount, List<string> modelSubject)
{
var materialTokens = SubjectTokens(materialName);
var best = new Dictionary<TextureSlot, TextureMatch>();
foreach (var image in images)
{
var stem = Stem(image);
var slots = SlotsOf(stem);
var imageTokens = SubjectTokens(stem);
float confidence;
string reason;
if (materialTokens.Count > 0 && imageTokens.Count > 0 && string.Concat(imageTokens) == string.Concat(materialTokens))
{
confidence = slots is null ? 0.7f : 0.95f;
reason = "named after the material";
}
else if (materialCount == 1 && (imageTokens.Count == 0 || string.Concat(imageTokens) == string.Concat(modelSubject) || imageTokens.All(ubiquitous.Contains)))
{
// One material: textures named after the model (or unnamed) are its textures.
if (slots is null)
continue;
confidence = imageTokens.Count == 0 ? 0.65f : 0.8f;
reason = "the weapon's only material";
}
else
{
if (materialTokens.Count == 0 || imageTokens.Count == 0)
continue;
var shared = imageTokens.Where(materialTokens.Contains).Distinct().ToList();
var distinctive = shared.Count(t => !ubiquitous.Contains(t));
if (distinctive == 0)
continue;
// Words every image shares (the asset's own name) say nothing about which part it is.
var imageOwn = imageTokens.Where(t => !ubiquitous.Contains(t)).Distinct().ToList();
var materialOwn = materialTokens.Where(t => !ubiquitous.Contains(t)).Distinct().ToList();
var coverage = imageOwn.Count == 0 ? 0f : distinctive / (float)imageOwn.Count;
var recall = materialOwn.Count == 0 ? 0f : distinctive / (float)materialOwn.Count;
if (coverage < 0.5f)
continue;
confidence = Math.Clamp(0.25f + 0.45f * coverage * recall + 0.1f * Math.Min(distinctive, 2), 0f, 0.9f);
if (slots is null)
confidence *= 0.7f;
reason = $"name shares \"{string.Join(" ", shared)}\"";
}
foreach (var (slot, channel) in slots ?? new() { (TextureSlot.BaseColor, TextureChannel.All) })
{
var match = new TextureMatch { Material = materialIndex, MaterialName = materialName, Slot = slot, Path = image, Confidence = confidence, Reason = reason, Channel = channel };
if (!best.TryGetValue(slot, out var current) || confidence > current.Confidence + 1e-4f
|| (MathF.Abs(confidence - current.Confidence) <= 1e-4f && Rank(image) < Rank(current.Path)))
best[slot] = match;
}
}
return best.Values;
}
/// <summary>Between equally good images prefer engine-friendly formats and OpenGL normals.</summary>
private static int Rank(string path)
{
var ext = System.IO.Path.GetExtension(path).ToLowerInvariant();
var rank = ext switch { ".png" => 0, ".tga" => 1, ".jpg" => 2, ".jpeg" => 3, _ => 4 };
if (path.Contains("directx", StringComparison.OrdinalIgnoreCase) || path.Contains("_dx", StringComparison.OrdinalIgnoreCase))
rank += 10;
return rank;
}
}