Editor/WeaponImporter/Core/Generation/ModelDmxWriter.cs
#nullable enable annotations

using System.Numerics;
using WeaponImporter.Core.Formats.Dmx;
using WeaponImporter.Core.Geometry;
using WeaponImporter.Core.Weapon;

namespace WeaponImporter.Core.Generation;

using Vector2 = System.Numerics.Vector2;
using Vector3 = System.Numerics.Vector3;

/// <summary>A written model DMX plus the faceSet material names it uses.</summary>
public sealed class ModelDmxResult
{
    public required string Text { get; init; }

    /// <summary>Sanitised, unique faceSet material name and the source material, in file order.</summary>
    public required IReadOnlyList<(string DmxMaterial, MaterialInfo Material)> Materials { get; init; }
}

/// <summary>
/// Writes a weapon's render mesh as a Source 2 model DMX (<c>keyvalues2_noids</c>): a DmeModel
/// with the skeleton (DmeJoint rest locals) and one DmeDag/DmeMesh per material. Vertex data
/// keeps separate position / normal / texcoord index arrays so per-corner normals and UVs
/// survive; skinning is rigid (one joint per position, weight 1, jointCount 1) on
/// <see cref="TriMesh.VertexBone"/>.
/// </summary>
public static class ModelDmxWriter
{
    /// <summary>Writes the given triangles (indices into <see cref="WeaponAsset.Mesh"/>) as model DMX text.</summary>
    public static string Write(WeaponAsset asset, IReadOnlyList<int> triangles, DmxSpace space, string name)
        => WriteWithMaterials(asset, triangles, space, name).Text;

    /// <summary>As <see cref="Write"/>, also returning the faceSet material mapping.</summary>
    public static ModelDmxResult WriteWithMaterials(WeaponAsset asset, IReadOnlyList<int>? triangles, DmxSpace? space, string name)
    {
        ArgumentNullException.ThrowIfNull(asset);
        space ??= DmxSpace.SourceZUpInches;
        name = string.IsNullOrWhiteSpace(name) ? asset.Name : name;
        var mesh = asset.Mesh;
        var skeleton = asset.Skeleton;
        if (skeleton.Count == 0)
            throw new ArgumentException("The weapon has no bones.", nameof(asset));

        // Triangles grouped by material, first use order of the material index for determinism.
        var selected = triangles ?? Enumerable.Range(0, mesh.TriangleCount).ToArray();
        var seen = new HashSet<int>();
        var byMaterial = new SortedDictionary<int, List<int>>();
        foreach (var t in selected)
        {
            if ((uint)t >= (uint)mesh.TriangleCount)
                throw new ArgumentOutOfRangeException(nameof(triangles), $"Triangle {t} is out of range (count {mesh.TriangleCount}).");
            if (!seen.Add(t))
                continue;
            var m = mesh.TriangleMaterial[t];
            if (!byMaterial.TryGetValue(m, out var list))
                byMaterial[m] = list = new List<int>();
            list.Add(t);
        }
        if (byMaterial.Count == 0)
            throw new ArgumentException("No triangles to write.", nameof(triangles));

        var usedMaterialNames = new HashSet<string>(StringComparer.Ordinal);
        var parts = new List<Part>();
        var normals = mesh.EnsureNormals();
        var uvs = mesh.EnsureUVs();
        foreach (var (materialIndex, tris) in byMaterial)
        {
            var material = mesh.Materials[materialIndex];
            var dmxName = DmxNames.Unique(DmxNames.Material(material.Name), usedMaterialNames);
            parts.Add(BuildPart(mesh, tris, dmxName, material, normals, uvs, space, skeleton.Count));
        }

        var boneNames = DmxNames.Bones(skeleton);
        var scope = "weapon-model:" + name;
        var modelId = DmxIds.Guid(scope, "model");
        var jointIds = boneNames.Select(b => DmxIds.Guid(scope, "joint:" + b)).ToArray();

        var w = new Kv2Writer();
        w.Raw(Kv2Writer.ModelHeader);
        w.BeginTop("DmElement");
        w.Attr("name", "string", "root");
        w.Attr("model", "element", modelId);
        w.Attr("skeleton", "element", modelId);
        w.EndTop();

        w.BeginTop("DmeModel");
        w.Attr("id", "elementid", modelId);
        w.Attr("name", "string", name);
        Transform(w, "transform", Vector3.Zero, Quaternion.Identity);
        w.Attr("visible", "bool", "1");
        var children = new List<string>();
        for (var i = 0; i < skeleton.Count; i++)
            if (skeleton[i].ParentIndex < 0)
                children.Add(jointIds[i]);
        children.AddRange(parts.Select(p => p.DagId(scope)));
        w.Refs("children", children);
        w.Refs("jointList", jointIds);
        space.WriteAxisSystem(w);
        w.EndTop();

        for (var i = 0; i < skeleton.Count; i++)
        {
            var bone = skeleton[i];
            var local = space.Local(bone.RestLocal, bone.ParentIndex < 0);
            w.BeginTop("DmeJoint");
            w.Attr("id", "elementid", jointIds[i]);
            w.Attr("name", "string", boneNames[i]);
            Transform(w, "transform", local.Pos, local.Rot);
            w.Attr("visible", "bool", "1");
            var boneChildren = new List<string>();
            for (var c = 0; c < skeleton.Count; c++)
                if (skeleton[c].ParentIndex == i)
                    boneChildren.Add(jointIds[c]);
            if (boneChildren.Count > 0)
                w.Refs("children", boneChildren);
            w.EndTop();
        }

        foreach (var part in parts)
        {
            var dagId = part.DagId(scope);
            var meshId = DmxIds.Guid(scope, "mesh:" + part.Material);
            var vertexId = DmxIds.Guid(scope, "vertices:" + part.Material);

            w.BeginTop("DmeDag");
            w.Attr("id", "elementid", dagId);
            w.Attr("name", "string", part.Material);
            Transform(w, "transform", Vector3.Zero, Quaternion.Identity);
            w.Attr("shape", "element", meshId);
            w.Attr("visible", "bool", "1");
            w.EndTop();

            w.BeginTop("DmeMesh");
            w.Attr("id", "elementid", meshId);
            w.Attr("name", "string", part.Material);
            w.Attr("visible", "bool", "1");
            w.Attr("currentState", "element", vertexId);
            w.Refs("baseStates", new[] { vertexId });
            w.BeginArray("faceSets");
            w.BeginArrayElement("DmeFaceSet");
            w.Attr("name", "string", part.Material);
            w.BeginArray("faces", "int_array");
            for (var i = 0; i < part.Faces.Count; i++)
            {
                w.Value(Kv2Writer.I(part.Faces[i]), false);
                if (i % 3 == 2)
                    w.Value("-1", i == part.Faces.Count - 1);
            }
            w.EndArray();
            w.BeginInline("material", "DmeMaterial");
            w.Attr("name", "string", part.Material);
            w.Attr("mtlName", "string", part.Material);
            w.EndInline();
            w.EndArrayElement(true);
            w.EndArray();
            w.EndTop();

            w.BeginTop("DmeVertexData");
            w.Attr("id", "elementid", vertexId);
            w.Attr("name", "string", "bind");
            w.ValueArray("vertexFormat", "string_array", new[] { "position$0", "normal$0", "texcoord$0", "blendweights$0", "blendindices$0" }, s => s);
            w.Attr("jointCount", "int", "1");
            w.Attr("flipVCoordinates", "bool", space.UvBottomLeft ? "1" : "0");
            w.ValueArray("position$0", "vector3_array", part.Positions, Kv2Writer.Vec);
            w.ValueArray("position$0Indices", "int_array", part.PositionIndices, Kv2Writer.I);
            w.ValueArray("normal$0", "vector3_array", part.Normals, Kv2Writer.Vec);
            w.ValueArray("normal$0Indices", "int_array", part.NormalIndices, Kv2Writer.I);
            w.ValueArray("texcoord$0", "vector2_array", part.UVs, Kv2Writer.Vec);
            w.ValueArray("texcoord$0Indices", "int_array", part.UvIndices, Kv2Writer.I);
            // Skinning data is per position (indexed through position$0Indices).
            w.ValueArray("blendweights$0", "float_array", part.Joints, _ => "1");
            w.ValueArray("blendindices$0", "int_array", part.Joints, Kv2Writer.I);
            w.EndTop();
        }

        return new ModelDmxResult
        {
            Text = w.ToString(),
            Materials = parts.Select(p => (p.Material, p.Source)).ToList(),
        };
    }

    private sealed class Part
    {
        public required string Material;
        public required MaterialInfo Source;
        public readonly List<Vector3> Positions = new();
        public readonly List<int> Joints = new();
        public readonly List<Vector3> Normals = new();
        public readonly List<Vector2> UVs = new();
        public readonly List<int> PositionIndices = new();
        public readonly List<int> NormalIndices = new();
        public readonly List<int> UvIndices = new();
        public readonly List<int> Faces = new();

        public string DagId(string scope) => DmxIds.Guid(scope, "dag:" + Material);
    }

    private static Part BuildPart(TriMesh mesh, List<int> tris, string dmxName, MaterialInfo material, Vector3[] normals, Vector2[] uvs, DmxSpace space, int boneCount)
    {
        var part = new Part { Material = dmxName, Source = material };
        var positionOf = new Dictionary<int, int>();
        var normalOf = new Dictionary<Vector3, int>();
        var uvOf = new Dictionary<Vector2, int>();
        var vertexOf = new Dictionary<(int P, int N, int U), int>();
        foreach (var t in tris)
        {
            for (var k = 0; k < 3; k++)
            {
                var corner = t * 3 + k;
                var v = mesh.Indices[corner];
                if (!positionOf.TryGetValue(v, out var p))
                {
                    p = part.Positions.Count;
                    positionOf[v] = p;
                    part.Positions.Add(space.Point(mesh.Positions[v]));
                    var bone = mesh.VertexBone[v];
                    part.Joints.Add(bone >= 0 && bone < boneCount ? bone : 0);
                }
                var nv = space.Direction(normals[corner]);
                var len = nv.Length();
                nv = len > 1e-12f && float.IsFinite(len) ? nv / len : space.Direction(Vector3.UnitZ);
                if (!normalOf.TryGetValue(nv, out var n))
                {
                    n = part.Normals.Count;
                    normalOf[nv] = n;
                    part.Normals.Add(nv);
                }
                var uvValue = space.Uv(uvs[corner]);
                if (!float.IsFinite(uvValue.X) || !float.IsFinite(uvValue.Y))
                    uvValue = Vector2.Zero;
                if (!uvOf.TryGetValue(uvValue, out var u))
                {
                    u = part.UVs.Count;
                    uvOf[uvValue] = u;
                    part.UVs.Add(uvValue);
                }
                if (!vertexOf.TryGetValue((p, n, u), out var vertex))
                {
                    vertex = part.PositionIndices.Count;
                    vertexOf[(p, n, u)] = vertex;
                    part.PositionIndices.Add(p);
                    part.NormalIndices.Add(n);
                    part.UvIndices.Add(u);
                }
                part.Faces.Add(vertex);
            }
        }
        return part;
    }

    private static void Transform(Kv2Writer w, string name, Vector3 position, Quaternion orientation)
    {
        w.BeginInline(name, "DmeTransform");
        w.Attr("name", "string", name);
        w.Attr("position", "vector3", Kv2Writer.Vec(position));
        w.Attr("orientation", "quaternion", Kv2Writer.Quat(orientation));
        w.Attr("scale", "float", "1");
        w.EndInline();
    }
}