AutoRig/Export/RigExporter.cs

Exporter utility that converts a rigged mesh and rig result into an export bundle containing FBX bytes, a vmdl text file, filenames, and any extra companion files like textures and a generated .vmat. It recenters the mesh to sit on the origin floor, optionally lifts joints, extracts a source material texture and tint, and constructs vmat and vmdl content.

File Access
using AutoRig.Mesh;
using AutoRig.Rig;

namespace AutoRig.Export;

/// <summary>Everything a caller needs to write a rigged model to disk.</summary>
public sealed class ExportBundle
{
    public required byte[] Fbx { get; init; }
    public required string Vmdl { get; init; }
    public required string FbxFileName { get; init; }
    public required string VmdlFileName { get; init; }

    /// <summary>Companion files (texture image + generated .vmat), written into the
    /// same folder as the fbx/vmdl. Empty when the source had no textures.</summary>
    public IReadOnlyList<(string FileName, byte[] Bytes)> ExtraFiles { get; init; }
        = Array.Empty<(string, byte[])>();
}

/// <summary>
/// Export façade: RigResult → binary FBX bytes + vmdl text. No file IO here (Code/
/// discipline) — the editor layer writes the files where the user chose.
/// </summary>
public static class RigExporter
{
    /// <param name="assetFolder">Project-relative folder the caller will write the
    /// bundle into (used for texture/vmat references inside the generated files).</param>
    public static ExportBundle Export(
        RigMesh mesh, RigResult rig, string modelName, string assetFolder = "models/autorig" )
    {
        ArgumentNullException.ThrowIfNull( mesh );
        ArgumentNullException.ThrowIfNull( rig );
        ArgumentNullException.ThrowIfNull( modelName );

        var name = NameUtil.Sanitize( modelName );
        var fbxFileName = $"{name}.fbx";
        var folder = assetFolder.Replace( '\\', '/' ).Trim( '/' );

        // Place the model where ModelDoc expects it: centered on the origin's
        // ground plane. Off-origin sources otherwise appear shoved to one side
        // (X/Z offset) or sunk through the floor (Y offset). Center the footprint
        // (X, Z at the bbox center → 0) and floor-snap so the lowest point sits at
        // y = 0. Mesh AND joints move together so the rig stays aligned.
        var bounds = mesh.ComputeBounds();
        var center = bounds.Center;
        var lift = new System.Numerics.Vector3( -center.X, -bounds.Min.Y, -center.Z );
        if ( lift.Length() > 1e-4f )
        {
            var lifted = new Mesh.RigMesh
            {
                SourceName = mesh.SourceName,
                Positions = mesh.Positions.Select( p => p + lift ).ToArray(),
                Normals = mesh.Normals,
                Uvs = mesh.Uvs,
                Triangles = mesh.Triangles,
                TriangleTags = mesh.TriangleTags,
                Tags = mesh.Tags,
                Materials = mesh.Materials,
                TriangleMaterials = mesh.TriangleMaterials,
            };
            var liftedSkeleton = new Rig.RigSkeleton();
            foreach ( var j in rig.Skeleton.Joints )
                liftedSkeleton.Joints.Add( new Rig.RigJoint
                {
                    Name = j.Name,
                    Parent = j.Parent,
                    Position = j.Position + lift,
                    HingeAxis = j.HingeAxis,
                } );
            mesh = lifted;
            rig = new Rig.RigResult
            {
                Skeleton = liftedSkeleton,
                Weights = rig.Weights,
                SolverName = rig.SolverName,
                Degraded = rig.Degraded,
                Explanation = rig.Explanation,
            };
        }

        // Texture passthrough (v1: whole model bound to the first textured source
        // material — the FBX carries a single material named "{name}_mat").
        var extras = new List<(string, byte[])>();
        string remapFrom = null, remapTo = null;
        var source = mesh.Materials.FirstOrDefault( m => m.BaseColorImage is not null )
            ?? mesh.Materials.FirstOrDefault();
        if ( source is not null )
        {
            var vmat = $"// generated by auto_rig from '{mesh.SourceName}'\nLayer0\n{{\n"
                + "\tshader \"shaders/complex.shader\"\n";
            if ( source.BaseColorImage is { } image )
            {
                var extension = image.Length > 2 && image[0] == 0xFF && image[1] == 0xD8
                    ? "jpg" : "png";
                var imageFileName = $"{name}_color.{extension}";
                extras.Add( (imageFileName, image) );
                vmat += $"\tTextureColor \"{folder}/{imageFileName}\"\n";
            }
            if ( source.Tint != new System.Numerics.Vector3( 1f, 1f, 1f ) )
                vmat += $"\tg_vColorTint \"[{source.Tint.X:0.###} {source.Tint.Y:0.###} {source.Tint.Z:0.###}]\"\n";
            vmat += "}\n";

            var vmatFileName = $"{name}_mat.vmat";
            extras.Add( (vmatFileName, System.Text.Encoding.UTF8.GetBytes( vmat )) );
            remapFrom = $"{name}_mat.vmat";                 // FBX material name
            remapTo = $"{folder}/{vmatFileName}";
        }

        return new ExportBundle
        {
            Fbx = FbxRigWriter.Write( mesh, rig, name ),
            Vmdl = VmdlGenerator.Generate( fbxFileName, name, remapFrom, remapTo ),
            FbxFileName = fbxFileName,
            VmdlFileName = $"{name}.vmdl",
            ExtraFiles = extras,
        };
    }
}