Editor/Core/Import/ObjImporter.cs
#nullable enable annotations
using System.Globalization;
using System.Text;
namespace HumanoidRigger;
using Vector2=System.Numerics.Vector2;
using Vector3=System.Numerics.Vector3;
using Vector4=System.Numerics.Vector4;

internal static class ObjImporter
{
    sealed class Part(string name)
    {
        public string Name=name;
        public List<(int Position,int Uv,int Normal)> Corners=[];
        public List<int> Materials=[];
    }
    internal static float Number(string text)=>float.TryParse(text,NumberStyles.Float,CultureInfo.InvariantCulture,out float value)&&float.IsFinite(value)?value:throw new FormatException("OBJ contains an invalid number.");
    internal static string[] Words(string line)=>line.Split((char[]?)null,StringSplitOptions.RemoveEmptyEntries);
    public static ImportedCharacter Import(byte[] bytes,string name,string sourcePath)
    {
        var positions=new List<Vector3>();var normals=new List<Vector3>();var uvs=new List<Vector2>();var colors=new List<Vector4>();bool hasColors=false;
        var parts=new List<Part>();var part=new Part(name);var materials=new List<SourceMaterial>();var warnings=new List<string>();
        var materialIds=new Dictionary<string,int>(StringComparer.Ordinal);int material=-1;float? authoredUnit=null;
        int Material(string value)
        {
            if(materialIds.TryGetValue(value,out int found))return found;
            int index=materials.Count;materialIds.Add(value,index);materials.Add(new(){Name=value,DoubleSided=true});return index;
        }
        int Index(string value,int count)
        {
            if(!int.TryParse(value,NumberStyles.Integer,CultureInfo.InvariantCulture,out int index)||index==0)throw new FormatException("Invalid OBJ face index.");
            index=index<0?count+index:index-1;
            return index>=0&&index<count?index:throw new FormatException("OBJ face references a missing vertex, normal or UV.");
        }
        using var reader=new StringReader(Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF'));
        string? line;
        while((line=reader.ReadLine()) is not null)
        {
            if(line.StartsWith('#'))
            {
                var comment=line.ToLowerInvariant();
                if(comment.Contains("centimeters as units")||comment.Contains("units: centimeters"))authoredUnit=1;
                else if(comment.Contains("millimeters as units")||comment.Contains("units: millimeters"))authoredUnit=.1f;
                else if(comment.Contains("meters as units")||comment.Contains("units: meters"))authoredUnit=100;
                continue;
            }
            while(line.TrimEnd().EndsWith('\\'))line=line.TrimEnd()[..^1]+" "+(reader.ReadLine()??throw new FormatException("Unterminated OBJ continuation."));
            int hash=line.IndexOf('#');if(hash>=0)line=line[..hash];
            var words=Words(line);if(words.Length==0)continue;
            switch(words[0])
            {
                case "v":
                    if(words.Length<4)throw new FormatException("Incomplete OBJ vertex.");
                    var p=new Vector3(Number(words[1]),Number(words[2]),Number(words[3]));
                    if(words.Length==5){float w=Number(words[4]);if(w==0)throw new FormatException("Zero homogeneous OBJ coordinate.");p/=w;}
                    positions.Add(p);var color=Vector4.One;
                    if(words.Length>=7){color=new(Number(words[4]),Number(words[5]),Number(words[6]),words.Length>7?Number(words[7]):1);hasColors=true;}
                    colors.Add(color);break;
                case "vt":
                    if(words.Length<2)throw new FormatException("Incomplete OBJ UV.");
                    uvs.Add(new(Number(words[1]),words.Length>2?Number(words[2]):0));break;
                case "vn":
                    if(words.Length!=4)throw new FormatException("Incomplete OBJ normal.");
                    normals.Add(new(Number(words[1]),Number(words[2]),Number(words[3])));break;
                case "o": case "g":
                    if(part.Corners.Count>0){parts.Add(part);part=new Part(name);}
                    part.Name=words.Length>1?string.Join(" ",words.Skip(1)):name;break;
                case "usemtl":material=words.Length<2||words[1]=="off"?-1:Material(string.Join(" ",words.Skip(1)));break;
                case "mtllib":
                    string joined=string.Join(" ",words.Skip(1)).Trim('"');
                    var references=File.Exists(ModelImporter.DependencyPath(sourcePath,joined))?new[]{joined}:System.Text.RegularExpressions.Regex.Matches(line[6..],"\"[^\"]+\"|\\S+").Select(m=>m.Value.Trim('"')).ToArray();
                    foreach(string reference in references)
                    {
                        var path=ModelImporter.DependencyPath(sourcePath,reference);
                        if(!File.Exists(path)){warnings.Add($"Material library '{reference}' is missing; available geometry will still import.");continue;}
                        foreach(var m in ObjMaterials.Read(path))materials[Material(m.Name)]=m;
                    }
                    break;
                case "f":
                    var face=words.Skip(1).Select(word=>
                    {
                        var fields=word.Split('/');if(fields.Length>3)throw new FormatException("Invalid OBJ face corner.");
                        return(Position:Index(fields[0],positions.Count),Uv:fields.Length>1&&fields[1].Length>0?Index(fields[1],uvs.Count):-1,Normal:fields.Length>2&&fields[2].Length>0?Index(fields[2],normals.Count):-1);
                    }).ToArray();
                    int[] triangles;
                    try{triangles=PolygonTriangles.Triangulate(face.Select(c=>positions[c.Position]).ToArray());}
                    catch(FormatException e){throw new FormatException(e.Message+" Face: "+line,e);}
                    part.Corners.AddRange(triangles.Select(c=>face[c]));part.Materials.AddRange(Enumerable.Repeat(material,triangles.Length/3));break;
                case "curv": case "surf":throw new FormatException("OBJ free-form surfaces must be converted to polygon meshes before importing.");
            }
        }
        if(part.Corners.Count>0)parts.Add(part);
        if(parts.Count==0)throw new FormatException("OBJ contains no polygon mesh.");
        var used=parts.SelectMany(x=>x.Corners.Select(c=>positions[c.Position])).ToArray();var extent=used.Aggregate(Vector3.Max)-used.Aggregate(Vector3.Min);
        bool zUp=extent.Z>extent.Y*1.5f&&extent.Z>extent.X*.7f;
        float height=zUp?extent.Z:extent.Y,unit=authoredUnit??(height<10?100:1);
        if(authoredUnit is null)warnings.Add($"OBJ has no unit metadata; interpreted as {(unit==100?"meters":"centimeters")}.");
        if(zUp)warnings.Add("Detected a Z-up OBJ and converted it to Y-up.");
        Vector3 Direction(Vector3 v)=>zUp?new(v.X,v.Z,-v.Y):v;
        var meshes=parts.Select(item=>
        {
            var indices=new Dictionary<int,int>();var vertices=new List<Vector3>();var triangles=new int[item.Corners.Count];
            for(int i=0;i<triangles.Length;i++)
            {
                int original=item.Corners[i].Position;
                if(!indices.TryGetValue(original,out int mapped)){mapped=vertices.Count;indices.Add(original,mapped);vertices.Add(Direction(positions[original])*unit);}
                triangles[i]=mapped;
            }
            var cornerNormals=new Vector3[triangles.Length];
            for(int t=0;t<triangles.Length;t+=3)
            {
                var normal=Vector3.Cross(vertices[triangles[t+1]]-vertices[triangles[t]],vertices[triangles[t+2]]-vertices[triangles[t]]);
                for(int c=0;c<3;c++){int index=item.Corners[t+c].Normal;var n=index>=0?Direction(normals[index]):normal;cornerNormals[t+c]=n.LengthSquared()>1e-12f?Vector3.Normalize(n):Vector3.Zero;}
            }
            return new MeshPart(item.Name,vertices.ToArray(),triangles,ModelImporter.Classify(item.Name))
            {
                CornerNormals=cornerNormals,CornerTexCoords=item.Corners.Any(c=>c.Uv>=0)?item.Corners.Select(c=>c.Uv>=0?uvs[c.Uv]:Vector2.Zero).ToArray():[],
                CornerColors=hasColors?item.Corners.Select(c=>colors[c.Position]).ToArray():[],TriangleMaterials=item.Materials.ToArray()
            };
        }).ToArray();
        if(hasColors)foreach(var m in materials)m.VertexColors=true;
        if(warnings.Any(w=>w.StartsWith("Material library"))&&materials.Any(m=>m.ColorTexture is null&&m.ColorFactor is null))
        {
            var directory=Path.GetDirectoryName(sourcePath)!;
            var folders=new[]{directory,Path.Combine(directory,"textures"),Path.Combine(Path.GetDirectoryName(directory)??directory,"textures")};
            var images=folders.Distinct(StringComparer.OrdinalIgnoreCase).Where(Directory.Exists).SelectMany(folder=>
                Directory.EnumerateFiles(folder,"*",folder==directory?SearchOption.TopDirectoryOnly:SearchOption.AllDirectories))
                .Where(file=>Path.GetExtension(file).ToLowerInvariant() is ".png" or ".jpg" or ".jpeg" or ".tga" or ".dds" or ".webp").Distinct(StringComparer.OrdinalIgnoreCase);
            if(TextureNames.SingleColor(images) is {} diffuse)
            {
                foreach(var m in materials.Where(m=>m.ColorTexture is null&&m.ColorFactor is null))m.ColorTexture=diffuse;
                warnings.Add("Using the only nearby color texture. Original material assignments are unavailable without the Mtl file.");
            }
        }
        var result=new ImportedCharacter{Name=name,SourcePath=sourcePath,SourceUnitCm=unit,SourceUpAxis=zUp?2:1,Meshes=meshes,Materials=materials.ToArray(),ImportWarnings=warnings.Distinct().ToArray()};
        result.Validate();return result;
    }
}