Code/AutoRig/Export/RigExporter.cs

Exporter helper that converts a RigMesh and RigResult into an ExportBundle containing FBX bytes, vmdl text, filenames and any companion files (textures and generated .vmat). It recenters the mesh and joints to sit on the ground, extracts a primary material texture to include, creates a vmat text, and calls FbxRigWriter.Write and VmdlGenerator.Generate to produce the outputs.

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,
        };
    }
}