Editor/Core/Export/GltfExporter.cs
#nullable enable annotations
using System.Numerics;
using System.Text;
using System.Text.Json;
namespace HumanoidRigger;
using Vector2=System.Numerics.Vector2;
using Vector3=System.Numerics.Vector3;
using Vector4=System.Numerics.Vector4;

public sealed record GltfOutput(byte[] Document,byte[] Buffer);
/// <summary>glTF 2.0 in meters, preserving profile bone names, local frames and every skin influence.</summary>
public static class GltfExporter
{
    readonly record struct Corner(int Vertex,Vector3 Normal,Vector2 Uv,Vector4 Color);
    public static GltfOutput Write(ImportedCharacter character,GeneratedRig rig,string bufferName,SourceMaterial[]? materials=null,bool binary=false,Func<string,byte[]>? readTexture=null)
    {
        character.Validate();materials??=character.Materials;
        if(rig.Bones.Length==0||rig.Bones.Length>65535)throw new FormatException("glTF export requires between 1 and 65535 bones.");
        var views=new List<object>();var accessors=new List<object>();var meshes=new List<object>();var nodes=new List<Dictionary<string,object>>();
        using var stream=new MemoryStream();using var writer=new BinaryWriter(stream);
        int View(Action write,int target=0)
        {
            while(stream.Position%4!=0)writer.Write((byte)0);int start=checked((int)stream.Position);write();
            int index=views.Count;var view=new Dictionary<string,object>{["buffer"]=0,["byteOffset"]=start,["byteLength"]=checked((int)stream.Position-start)};if(target!=0)view["target"]=target;views.Add(view);return index;
        }
        int Floats(float[] values,int components,string type,bool position=false)
        {
            var accessor=new Dictionary<string,object>{["bufferView"]=View(()=>{foreach(float v in values){if(!float.IsFinite(v))throw new FormatException("Non-finite glTF export value.");writer.Write(v);}},type=="MAT4"?0:34962),["componentType"]=5126,["count"]=values.Length/components,["type"]=type};
            if(position)
            {
                accessor["min"]=Enumerable.Range(0,components).Select(c=>Enumerable.Range(0,values.Length/components).Min(v=>values[v*components+c])).ToArray();
                accessor["max"]=Enumerable.Range(0,components).Select(c=>Enumerable.Range(0,values.Length/components).Max(v=>values[v*components+c])).ToArray();
            }
            int index=accessors.Count;accessors.Add(accessor);return index;
        }
        int Integers(int[] values,int components,bool shortValues)
        {
            int view=View(()=>{foreach(int v in values){if(shortValues)writer.Write(checked((ushort)v));else writer.Write(checked((uint)v));}},components==1?34963:34962);
            int index=accessors.Count;accessors.Add(new{bufferView=view,componentType=shortValues?5123:5125,count=values.Length/components,type=components==1?"SCALAR":"VEC4"});return index;
        }
        float[] Matrix(Matrix4x4 m)=>[m.M11,m.M12,m.M13,m.M14,m.M21,m.M22,m.M23,m.M24,m.M31,m.M32,m.M33,m.M34,m.M41,m.M42,m.M43,m.M44];
        var world=rig.Bones.Select(b=>Matrix4x4.CreateFromQuaternion(b.Rotation)*Matrix4x4.CreateTranslation(b.Position/100)).ToArray();
        var inverse=new Matrix4x4[world.Length];
        for(int i=0;i<world.Length;i++)
        {
            if(!Matrix4x4.Invert(world[i],out inverse[i]))throw new FormatException("Cannot export a singular bone frame.");
            // These are rigid affine frames; glTF requires the final column to
            // be exactly (0,0,0,1), without generic inversion's rounding residue.
            inverse[i].M14=inverse[i].M24=inverse[i].M34=0;inverse[i].M44=1;
        }
        for(int i=0;i<rig.Bones.Length;i++)
        {
            var bone=rig.Bones[i];if(bone.Parent>=i||bone.Parent< -1)throw new FormatException("Invalid export bone hierarchy.");
            var local=bone.Parent<0?world[i]:world[i]*inverse[bone.Parent];
            if(!Matrix4x4.Decompose(local,out _,out var rotation,out var translation))throw new FormatException("Cannot decompose a local bone frame.");
            rotation=Quaternion.Normalize(rotation);
            var node=new Dictionary<string,object>{["name"]=bone.Name,["translation"]=new[]{translation.X,translation.Y,translation.Z},["rotation"]=new[]{rotation.X,rotation.Y,rotation.Z,rotation.W}};
            var children=Enumerable.Range(0,rig.Bones.Length).Where(c=>rig.Bones[c].Parent==i).ToArray();if(children.Length>0)node["children"]=children;
            nodes.Add(node);
        }
        int inverseAccessor=Floats(inverse.SelectMany(Matrix).ToArray(),16,"MAT4");
        for(int partIndex=0;partIndex<character.Meshes.Length;partIndex++)
        {
            var part=character.Meshes[partIndex];var primitives=new List<object>();
            foreach(var group in Enumerable.Range(0,part.Triangles.Length/3).GroupBy(t=>part.TriangleMaterials.Length>0?part.TriangleMaterials[t]:-1))
            {
                var corners=new List<Corner>();var map=new Dictionary<Corner,int>();var indices=new List<int>();
                foreach(int t in group)for(int c=0;c<3;c++)
                {
                    int at=t*3+c;var key=new Corner(part.Triangles[at],part.CornerNormals.Length>0?part.CornerNormals[at]:Vector3.Zero,part.CornerTexCoords.Length>0?part.CornerTexCoords[at]:Vector2.Zero,part.CornerColors.Length>0?part.CornerColors[at]:Vector4.One);
                    if(!map.TryGetValue(key,out int index)){index=corners.Count;map.Add(key,index);corners.Add(key);}indices.Add(index);
                }
                var attributes=new Dictionary<string,int>{["POSITION"]=Floats(corners.SelectMany(c=>{var p=part.Vertices[c.Vertex]/100;return new[]{p.X,p.Y,p.Z};}).ToArray(),3,"VEC3",true)};
                if(part.CornerNormals.Length>0)attributes["NORMAL"]=Floats(corners.SelectMany(c=>new[]{c.Normal.X,c.Normal.Y,c.Normal.Z}).ToArray(),3,"VEC3");
                if(part.CornerTexCoords.Length>0)attributes["TEXCOORD_0"]=Floats(corners.SelectMany(c=>new[]{c.Uv.X,1-c.Uv.Y}).ToArray(),2,"VEC2");
                if(part.CornerColors.Length>0)attributes["COLOR_0"]=Floats(corners.SelectMany(c=>new[]{c.Color.X,c.Color.Y,c.Color.Z,c.Color.W}).ToArray(),4,"VEC4");
                int sets=(corners.Max(c=>rig.Weights[partIndex][c.Vertex].Length)+3)/4;
                for(int set=0;set<sets;set++)
                {
                    var joints=new int[corners.Count*4];var weights=new float[joints.Length];
                    for(int v=0;v<corners.Count;v++)for(int c=0;c<4;c++)
                    {
                        var influences=rig.Weights[partIndex][corners[v].Vertex];int index=set*4+c;if(index>=influences.Length)continue;
                        joints[v*4+c]=influences[index].Bone;weights[v*4+c]=influences[index].Weight;
                    }
                    attributes["JOINTS_"+set]=Integers(joints,4,true);attributes["WEIGHTS_"+set]=Floats(weights,4,"VEC4");
                }
                var primitive=new Dictionary<string,object>{["attributes"]=attributes,["indices"]=Integers(indices.ToArray(),1,false),["mode"]=4};if(group.Key>=0)primitive["material"]=group.Key;primitives.Add(primitive);
            }
            if(primitives.Count==0)continue;
            int meshIndex=meshes.Count;meshes.Add(new{name=part.Name,primitives});nodes.Add(new(){["name"]=part.Name,["mesh"]=meshIndex,["skin"]=0});
        }
        var images=new List<object>();var textures=new List<object>();var imageIds=new Dictionary<string,int>(StringComparer.OrdinalIgnoreCase);
        object? Texture(string? path)
        {
            if(string.IsNullOrEmpty(path))return null;
            if(!imageIds.TryGetValue(path,out int id))
            {
                id=images.Count;imageIds.Add(path,id);
                if(binary)
                {
                    byte[] bytes=readTexture is not null?readTexture(path):character.EmbeddedTextures.TryGetValue(path,out var embedded)?embedded:File.ReadAllBytes(Path.IsPathFullyQualified(path)?path:ModelImporter.DependencyPath(character.SourcePath,path));
                    string mime=bytes.Length>=8&&bytes[0]==137&&bytes[1]==80?"image/png":bytes.Length>=3&&bytes[0]==255&&bytes[1]==216?"image/jpeg":throw new FormatException("Convert glTF textures to PNG or JPEG before export.");
                    images.Add(new{bufferView=View(()=>writer.Write(bytes)),mimeType=mime});
                }
                else images.Add(new{uri=UriPath(path)});
                textures.Add(new{source=id});
            }
            return new{index=id};
        }
        var materialJson=materials.Select(m=>
        {
            var color=m.ColorFactor??Vector3.One;
            var pbr=new Dictionary<string,object>{["baseColorFactor"]=new[]{color.X,color.Y,color.Z,m.OpacityFactor},["metallicFactor"]=m.AuthoredPbr?m.MetallicFactor:m.MetallicRoughnessTexture is null?0:1,["roughnessFactor"]=m.AuthoredPbr?m.RoughnessFactor:1};
            if(Texture(m.ColorTexture) is {} baseMap)pbr["baseColorTexture"]=baseMap;
            if(Texture(m.MetallicRoughnessTexture) is {} packed)pbr["metallicRoughnessTexture"]=packed;
            var result=new Dictionary<string,object>{["name"]=m.Name??"material",["pbrMetallicRoughness"]=pbr,["doubleSided"]=m.DoubleSided,["alphaMode"]=m.Translucent?"BLEND":m.AlphaTest?"MASK":"OPAQUE"};
            if(m.AlphaTest)result["alphaCutoff"]=m.AlphaCutoff;
            if(m.Unlit)result["extensions"]=new Dictionary<string,object>{["KHR_materials_unlit"]=new{}};
            foreach(var (key,path) in new[]{("normalTexture",m.NormalTexture),("occlusionTexture",m.OcclusionTexture),("emissiveTexture",m.EmissiveTexture)})if(Texture(path) is {} info)result[key]=info;
            var emission=m.AuthoredPbr||m.EmissiveTexture is not null?m.EmissiveFactor:Vector3.Zero;result["emissiveFactor"]=new[]{emission.X,emission.Y,emission.Z};return result;
        }).ToArray();
        var roots=Enumerable.Range(0,rig.Bones.Length).Where(i=>rig.Bones[i].Parent<0).Concat(Enumerable.Range(rig.Bones.Length,nodes.Count-rig.Bones.Length)).ToArray();
        byte[] data=stream.ToArray();var buffer=new Dictionary<string,object>{["byteLength"]=data.Length};if(!binary)buffer["uri"]=UriPath(bufferName);
        var document=new Dictionary<string,object>{["asset"]=new{version="2.0",generator="s&box Humanoid Rigger"},["scene"]=0,["scenes"]=new[]{new{nodes=roots}},["nodes"]=nodes,["meshes"]=meshes,["skins"]=new[]{new{joints=Enumerable.Range(0,rig.Bones.Length).ToArray(),inverseBindMatrices=inverseAccessor}},["accessors"]=accessors,["bufferViews"]=views,["buffers"]=new[]{buffer}};
        if(materialJson.Length>0)document["materials"]=materialJson;if(images.Count>0){document["images"]=images;document["textures"]=textures;}
        if(materials.Any(m=>m.Unlit))document["extensionsUsed"]=new[]{"KHR_materials_unlit"};
        var json=JsonSerializer.SerializeToUtf8Bytes(document);
        if(!binary)return new(json,data);
        using var output=new MemoryStream();using var glb=new BinaryWriter(output);
        int jsonLength=(json.Length+3)&~3,binLength=(data.Length+3)&~3;
        glb.Write(0x46546c67u);glb.Write(2u);glb.Write(checked((uint)(28+jsonLength+binLength)));
        glb.Write(jsonLength);glb.Write(0x4e4f534au);glb.Write(json);for(int i=json.Length;i<jsonLength;i++)glb.Write((byte)32);
        glb.Write(binLength);glb.Write(0x004e4942u);glb.Write(data);for(int i=data.Length;i<binLength;i++)glb.Write((byte)0);
        return new(output.ToArray(),[]);
    }
    static string UriPath(string path)=>string.Join("/",path.Replace('\\','/').Split('/').Select(Uri.EscapeDataString));
}