Editor/Core/Export/TextureFiles.cs
#nullable enable annotations
namespace HumanoidRigger;
/// <summary>Packages authored texture links without renaming or merging material slots.</summary>
public static class TextureFiles
{
public static SourceMaterial[] Copy(ImportedCharacter character,string destination,bool overwrite=false)
{
var references=new Dictionary<string,string>(StringComparer.OrdinalIgnoreCase);
var sourceFolder=string.IsNullOrEmpty(character.SourcePath)?null:Path.GetDirectoryName(character.SourcePath);
string? Resolve(string? reference)
{
if(string.IsNullOrWhiteSpace(reference))return null;
if(references.TryGetValue(reference,out var prior))return prior;
var decoded=Uri.UnescapeDataString(reference).Replace('\\','/');
var name=Path.GetFileName(decoded);
var safe=new string(name.Select(c=>char.IsLetterOrDigit(c)||c is '.' or '_' or '-'?c:'_').ToArray());
var embedded=character.EmbeddedTextures.FirstOrDefault(p=>p.Key.Replace('\\','/').Equals(decoded,StringComparison.OrdinalIgnoreCase)).Value;
string? source=null;
if(embedded is null)
{
var direct=Path.IsPathFullyQualified(decoded)?decoded:sourceFolder is null?null:Path.Combine(sourceFolder,decoded);
if(direct is not null&&File.Exists(direct))source=direct;
if(source is null&&sourceFolder is not null)
{
var parent=Path.GetDirectoryName(sourceFolder)??sourceFolder;var fbm=Path.GetFileNameWithoutExtension(character.SourcePath)+".fbm";
var candidates=new[]{sourceFolder,Path.Combine(sourceFolder,fbm),Path.Combine(parent,fbm),Path.Combine(sourceFolder,"textures"),Path.Combine(parent,"textures")};
foreach(var folder in candidates.Distinct(StringComparer.OrdinalIgnoreCase).Where(Directory.Exists))
{
var matches=Directory.EnumerateFiles(folder,"*",folder==sourceFolder?SearchOption.TopDirectoryOnly:SearchOption.AllDirectories)
.Where(p=>Path.GetFileName(p).Equals(name,StringComparison.OrdinalIgnoreCase)).ToArray();
if(matches.Length>1)throw new IOException($"More than one texture matches '{name}'. Restore its authored relative path.");
if(matches.Length==1){source=matches[0];break;}
}
}
if(source is null)throw new FileNotFoundException($"Cannot find texture '{reference}' beside the imported model.");
}
if(string.IsNullOrEmpty(Path.GetExtension(safe)))
{
var header=embedded;
if(header is null){header=new byte[12];using var stream=File.OpenRead(source!);int count=stream.Read(header,0,header.Length);Array.Resize(ref header,count);}
safe+=ImageExtension(header)??throw new FormatException($"Texture '{name}' has no extension and its image format could not be identified.");
}
// Source 2's native compiler still uses MAX_PATH. Leave room for
// converted alpha images and its hash + .generated.vtex_c suffix.
// The per-reference index keeps abbreviated filenames distinct.
string prefix=references.Count+"_",extension=Path.GetExtension(safe),stem=Path.GetFileNameWithoutExtension(safe);
const int compilerSuffixReserve=40,maxNativePath=259;
int availableStem=maxNativePath-compilerSuffixReserve-Path.GetFullPath(Path.Combine(destination,"textures",prefix+extension)).Length;
if(availableStem<1)throw new PathTooLongException("The texture destination is too long for s&box. Choose a shorter output folder or filename.");
if(stem.Length>availableStem)stem=stem[..availableStem];
var relative="textures/"+prefix+stem+extension;
var output=Path.Combine(destination,relative);
Directory.CreateDirectory(Path.GetDirectoryName(output)!);
if(embedded is not null){using var stream=new FileStream(output,overwrite?FileMode.Create:FileMode.CreateNew,FileAccess.Write);stream.Write(embedded);}
else File.Copy(source!,output,overwrite);
references.Add(reference,relative);return relative;
}
return character.Materials.Select(m=>m with{
ColorTexture=Resolve(m.ColorTexture),NormalTexture=Resolve(m.NormalTexture),RoughnessTexture=Resolve(m.RoughnessTexture),MetallicRoughnessTexture=Resolve(m.MetallicRoughnessTexture),
MetalnessTexture=Resolve(m.MetalnessTexture),OcclusionTexture=Resolve(m.OcclusionTexture),EmissiveTexture=Resolve(m.EmissiveTexture),OpacityTexture=Resolve(m.OpacityTexture)
}).ToArray();
}
static string? ImageExtension(byte[] header)
{
if(header.Length>=8&&header.AsSpan(0,8).SequenceEqual(new byte[]{137,80,78,71,13,10,26,10}))return ".png";
if(header.Length>=3&&header[0]==255&&header[1]==216&&header[2]==255)return ".jpg";
if(header.Length>=12&&System.Text.Encoding.ASCII.GetString(header,0,4)=="RIFF"&&System.Text.Encoding.ASCII.GetString(header,8,4)=="WEBP")return ".webp";
return null;
}
public static SourceMaterial[] RelativeTo(SourceMaterial[] materials,string directory)
{
string? Prefix(string? path)=>path is null?null:directory.Replace('\\','/').TrimEnd('/')+"/"+path;
return materials.Select(m=>m with{
ColorTexture=Prefix(m.ColorTexture),NormalTexture=Prefix(m.NormalTexture),RoughnessTexture=Prefix(m.RoughnessTexture),MetallicRoughnessTexture=Prefix(m.MetallicRoughnessTexture),
MetalnessTexture=Prefix(m.MetalnessTexture),OcclusionTexture=Prefix(m.OcclusionTexture),EmissiveTexture=Prefix(m.EmissiveTexture),OpacityTexture=Prefix(m.OpacityTexture)
}).ToArray();
}
}