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

/// <summary>Native model DMX writer in inches. KV2 emission is reused from the suite's GltfModelDmxWriter.</summary>
public static class DmxExporter
{
    public static string PartName(int index)=>"rigged_part_"+index;
    sealed record FaceSet(string Material,int[] Triangles);
    sealed record ExportPart(string Name,FaceSet[] Faces,Vector3[] Positions,Vector3[] Normals,Vector2[] TexCoords,Vector4[] Colors,float[] Weights,int[] Joints);
    public static string Write(ImportedCharacter character,GeneratedRig rig,IReadOnlyDictionary<int,string>? materials=null)
    {
        character.Validate();
        VmdlBoneNames.Validate(rig.Bones);
        var parts=character.Meshes.Select((m,index)=>
        {
            var normals=new Vector3[m.Vertices.Length];
            for(int t=0;t<m.Triangles.Length;t+=3)
            {
                int a=m.Triangles[t],b=m.Triangles[t+1],c=m.Triangles[t+2];
                var n=Vector3.Cross(m.Vertices[b]-m.Vertices[a],m.Vertices[c]-m.Vertices[a]);
                normals[a]+=n;normals[b]+=n;normals[c]+=n;
            }
            normals=normals.Select(n=>n.LengthSquared()>1e-12f?Vector3.Normalize(n):Vector3.UnitY).ToArray();
            // DMX vertices split at authored UV seams and normal discontinuities.
            // Each render corner retains the weights of its source control point.
            bool corners=m.CornerNormals.Length>0||m.CornerTexCoords.Length>0||m.CornerColors.Length>0;
            var sourceIndices=corners?m.Triangles:Enumerable.Range(0,m.Vertices.Length).ToArray();
            var weights=new float[sourceIndices.Length*4];var joints=new int[weights.Length];
            if(rig.Profile.MaximumInfluences>4)throw new InvalidOperationException("DMX export currently supports at most four influences per vertex.");
            for(int v=0;v<sourceIndices.Length;v++)for(int w=0;w<rig.Weights[index][sourceIndices[v]].Length;w++)
            {weights[v*4+w]=rig.Weights[index][sourceIndices[v]][w].Weight;joints[v*4+w]=rig.Weights[index][sourceIndices[v]][w].Bone;}
            var triangles=corners?Enumerable.Range(0,sourceIndices.Length).ToArray():m.Triangles;
            var faces=Enumerable.Range(0,triangles.Length/3).GroupBy(t=>m.TriangleMaterials.Length>0?m.TriangleMaterials[t]:-1)
                .Select(group=>new FaceSet(materials is not null&&materials.TryGetValue(group.Key,out var material)?material:"materials/dev/gray_grid_8.vmat",
                    group.SelectMany(t=>triangles.Skip(t*3).Take(3)).ToArray())).ToArray();
            return new ExportPart(PartName(index),faces,
                sourceIndices.Select(v=>m.Vertices[v]).ToArray(),
                sourceIndices.Select((v,c)=>m.CornerNormals.Length>0&&m.CornerNormals[c].LengthSquared()>1e-12f?Vector3.Normalize(m.CornerNormals[c]):normals[v]).ToArray(),
                m.CornerTexCoords.Length>0?m.CornerTexCoords.Select(uv=>new Vector2(uv.X,1-uv.Y)).ToArray():new Vector2[sourceIndices.Length],
                m.CornerColors,
                weights,joints);
        }).ToArray();
        return Emit(rig,character.Name,parts);
    }
    static (Vector3 Position,Quaternion Rotation) Local(GeneratedRig rig,int index)
    {
        var bone=rig.Bones[index];
        if(bone.Parent<0)return (bone.Position,bone.Rotation);
        var parent=rig.Bones[bone.Parent];var inverse=Quaternion.Inverse(parent.Rotation);
        return (Vector3.Transform(bone.Position-parent.Position,inverse),Quaternion.Normalize(inverse*bone.Rotation));
    }
    private static string Emit(GeneratedRig rig, string name, IReadOnlyList<ExportPart> parts)
    {
        var writer = new Kv2Writer();
        var modelId = Id(name, "model");
        var jointIds = new string[rig.Bones.Length];
        for (var i = 0; i < rig.Bones.Length; i++)
            jointIds[i] = Id(name, "joint:" + rig.Bones[i].Name);
        var dagIds = new string[parts.Count];
        var meshIds = new string[parts.Count];
        var vertexIds = new string[parts.Count];
        for (var i = 0; i < parts.Count; i++)
        {
            dagIds[i] = Id(name, $"dag:{i}:{parts[i].Name}");
            meshIds[i] = Id(name, $"mesh:{i}:{parts[i].Name}");
            vertexIds[i] = Id(name, $"vertices:{i}:{parts[i].Name}");
        }

        writer.Raw("<!-- dmx encoding keyvalues2_noids 4 format model 22 -->");
        writer.BeginTop("DmElement");
        writer.Attr("name", "string", "root");
        writer.Attr("model", "element", modelId);
        writer.Attr("skeleton", "element", modelId);
        writer.EndTop();

        writer.BeginTop("DmeModel");
        writer.Attr("id", "elementid", modelId);
        writer.Attr("name", "string", name);
        WriteTransform(writer, "transform", Vector3.Zero, Quaternion.Identity);
        writer.Attr("visible", "bool", "1");
        var children = new List<string>();
        for (var i = 0; i < rig.Bones.Length; i++)
            if (rig.Bones[i].Parent < 0)
                children.Add(jointIds[i]);
        children.AddRange(dagIds);
        WriteRefs(writer, "children", children);
        WriteRefs(writer, "jointList", jointIds);
        writer.Attr("upAxis", "string", "Y");
        writer.BeginInline("axisSystem", "DmeAxisSystem");
        writer.Attr("upAxis", "int", "2");
        writer.Attr("forwardParity", "int", "2");
        writer.Attr("coordSys", "int", "0");
        writer.EndInline();
        writer.EndTop();

        for (var i = 0; i < rig.Bones.Length; i++)
        {
            var bone = rig.Bones[i];
            var local = Local(rig, i);
            writer.BeginTop("DmeJoint");
            writer.Attr("id", "elementid", jointIds[i]);
            writer.Attr("name", "string", VmdlBoneNames.Convert(bone.Name));
            WriteTransform(writer, "transform", local.Position, local.Rotation);
            writer.Attr("visible", "bool", "1");
            var boneChildren = new List<string>();
            for (var child = 0; child < rig.Bones.Length; child++)
                if (rig.Bones[child].Parent == i)
                    boneChildren.Add(jointIds[child]);
            if (boneChildren.Count > 0)
                WriteRefs(writer, "children", boneChildren);
            writer.EndTop();
        }

        for (var i = 0; i < parts.Count; i++)
        {
            var part = parts[i];
            writer.BeginTop("DmeDag");
            writer.Attr("id", "elementid", dagIds[i]);
            writer.Attr("name", "string", part.Name);
            WriteTransform(writer, "transform", Vector3.Zero, Quaternion.Identity);
            writer.Attr("shape", "element", meshIds[i]);
            writer.Attr("visible", "bool", "1");
            writer.EndTop();

            writer.BeginTop("DmeMesh");
            writer.Attr("id", "elementid", meshIds[i]);
            writer.Attr("name", "string", part.Name);
            writer.Attr("visible", "bool", "1");
            writer.Attr("currentState", "element", vertexIds[i]);
            WriteRefs(writer, "baseStates", new[] { vertexIds[i] });
            writer.BeginArray("faceSets");
            for(int faceIndex=0;faceIndex<part.Faces.Length;faceIndex++)
            {
            var face=part.Faces[faceIndex];
            writer.BeginArrayElement("DmeFaceSet");
            writer.Attr("name", "string", face.Material);
            writer.BeginArray("faces", "int_array");
            for (var index = 0; index < face.Triangles.Length; index++)
            {
                writer.Value(face.Triangles[index].ToString(CultureInfo.InvariantCulture), false);
                if (index % 3 == 2)
                    writer.Value("-1", index == face.Triangles.Length - 1);
            }
            writer.EndArray();
            writer.BeginInline("material", "DmeMaterial");
            writer.Attr("name", "string", face.Material);
            writer.Attr("mtlName", "string", face.Material);
            writer.EndInline();
            writer.EndArrayElement(faceIndex==part.Faces.Length-1);
            }
            writer.EndArray();
            writer.EndTop();

            writer.BeginTop("DmeVertexData");
            writer.Attr("id", "elementid", vertexIds[i]);
            writer.Attr("name", "string", "bind");
            writer.BeginArray("vertexFormat", "string_array");
            var formats = new[]{ "position$0", "normal$0", "texcoord$0", "blendweights$0", "blendindices$0" }
                .Concat(part.Colors.Length>0?new[]{"color$0"}:Array.Empty<string>()).ToArray();
            for (var format = 0; format < formats.Length; format++)
                writer.Value(formats[format], format == formats.Length - 1);
            writer.EndArray();
            writer.Attr("jointCount", "int", "4");
            writer.Attr("flipVCoordinates", "bool", "0");
            WriteVectors(writer, "position$0", "vector3_array", part.Positions,
                value => Inches(value));
            WriteIdentityIndices(writer, "position$0Indices", part.Positions.Length);
            WriteVectors(writer, "normal$0", "vector3_array", part.Normals,
                value => Vec(value));
            WriteIdentityIndices(writer, "normal$0Indices", part.Normals.Length);
            WriteVectors(writer, "texcoord$0", "vector2_array", part.TexCoords,
                value => $"{F(value.X)} {F(value.Y)}");
            WriteIdentityIndices(writer, "texcoord$0Indices", part.TexCoords.Length);
            if(part.Colors.Length>0)
            {
                WriteVectors(writer,"color$0","vector4_array",part.Colors,value=>$"{F(value.X)} {F(value.Y)} {F(value.Z)} {F(value.W)}");
                WriteIdentityIndices(writer,"color$0Indices",part.Colors.Length);
            }
            WriteScalars(writer, "blendweights$0", "float_array", part.Weights,
                value => F(value));
            WriteScalars(writer, "blendindices$0", "int_array", part.Joints,
                value => value.ToString(CultureInfo.InvariantCulture));
            writer.EndTop();
        }
        return writer.ToString();
    }

    private static void WriteTransform(
        Kv2Writer writer, string name, Vector3 position, Quaternion orientation)
    {
        writer.BeginInline(name, "DmeTransform");
        writer.Attr("name", "string", name);
        writer.Attr("position", "vector3", Inches(position));
        writer.Attr("orientation", "quaternion",
            $"{F(orientation.X)} {F(orientation.Y)} {F(orientation.Z)} {F(orientation.W)}");
        writer.Attr("scale", "float", "1");
        writer.EndInline();
    }

    private static void WriteRefs(Kv2Writer writer, string name, IReadOnlyList<string> ids)
    {
        writer.BeginArray(name);
        for (var i = 0; i < ids.Count; i++)
            writer.ElementRef(ids[i], i == ids.Count - 1);
        writer.EndArray();
    }

    private static void WriteVectors<T>(
        Kv2Writer writer, string name, string type, T[] values, Func<T, string> format)
    {
        writer.BeginArray(name, type);
        for (var i = 0; i < values.Length; i++)
            writer.Value(format(values[i]), i == values.Length - 1);
        writer.EndArray();
    }

    private static void WriteScalars<T>(
        Kv2Writer writer, string name, string type, T[] values, Func<T, string> format)
        => WriteVectors(writer, name, type, values, format);

    private static void WriteIdentityIndices(Kv2Writer writer, string name, int count)
    {
        writer.BeginArray(name, "int_array");
        for (var i = 0; i < count; i++)
            writer.Value(i.ToString(CultureInfo.InvariantCulture), i == count - 1);
        writer.EndArray();
    }

    private static string Id(string name, string path)
        => new Guid(System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(name + ":" + path)))
            .ToString("D", CultureInfo.InvariantCulture);

    private static string F(float value)
        => value == 0f ? "0" : ((double)value).ToString("0.##########", CultureInfo.InvariantCulture);

    private static string Vec(Vector3 value) => $"{F(value.X)} {F(value.Y)} {F(value.Z)}";

    // Convert positions once before the native importer composes the hierarchy.
    // Import-time scaling loses additional precision in long joint chains.
    private static string Inches(Vector3 value)
        => $"{F((float)(value.X/2.54))} {F((float)(value.Y/2.54))} {F((float)(value.Z/2.54))}";

    private sealed class Kv2Writer
    {
        private readonly StringBuilder _text = new();
        private int _indent;

        public void Raw(string value) => _text.Append(value).Append("\r\n");

        private void Line(string value)
            => _text.Append('\t', _indent).Append(value).Append("\r\n");

        public void Attr(string name, string type, string value)
            => Line($"\"{Escape(name)}\" \"{type}\" \"{Escape(value)}\"");

        public void BeginTop(string type)
        {
            Line($"\"{type}\"");
            Line("{");
            _indent++;
        }

        public void EndTop()
        {
            _indent--;
            Line("}");
            _text.Append("\r\n");
        }

        public void BeginInline(string name, string type)
        {
            Line($"\"{Escape(name)}\" \"{type}\"");
            Line("{");
            _indent++;
        }

        public void EndInline()
        {
            _indent--;
            Line("}");
        }

        public void BeginArray(string name, string type = "element_array")
        {
            Line($"\"{Escape(name)}\" \"{type}\"");
            Line("[");
            _indent++;
        }

        public void EndArray()
        {
            _indent--;
            Line("]");
        }

        public void BeginArrayElement(string type)
        {
            Line($"\"{type}\"");
            Line("{");
            _indent++;
        }

        public void EndArrayElement(bool last)
        {
            _indent--;
            Line(last ? "}" : "},");
        }

        public void ElementRef(string id, bool last)
            => Line($"\"element\" \"{id}\"" + (last ? "" : ","));

        public void Value(string value, bool last)
            => Line($"\"{Escape(value)}\"" + (last ? "" : ","));

        private static string Escape(string value)
            => value.Replace("\\", "\\\\").Replace("\"", "\\\"")
                .Replace("\r", " ").Replace("\n", " ");

        public override string ToString() => _text.ToString();
    }
}