Code/AutoRig/Formats/Wavefront/ObjImporter.cs

OBJ file importer for the AutoRig system. Parses Wavefront .obj text from a byte array, reads positions/normals/UVs, resolves face indices (including negative indices), triangulates n-gons, de-duplicates per-corner attribute combinations into parallel output arrays, and builds a RigMesh with triangle tags from o/g/usemtl directives.

File Access
using System.Globalization;
using System.Numerics;
using System.Text;
using AutoRig.Mesh;

namespace AutoRig.Formats.Wavefront;

// s&box compat: the engine defines Vector2/Vector3 in the GLOBAL namespace, which
// shadows using-directive imports - alias explicitly to System.Numerics.
using Vector2 = System.Numerics.Vector2;
using Vector3 = System.Numerics.Vector3;


/// <summary>
/// Wavefront OBJ importer. Supports v/vn/vt/f (all face index forms, negative
/// indices, fan triangulation of n-gons); o/g/usemtl become triangle tags.
/// Per-corner normals/UVs are re-indexed so the output arrays stay parallel,
/// duplicating positions where one position is used with different normals/UVs.
/// </summary>
public static class ObjImporter
{
    /// <exception cref="FormatException">On any malformed line.</exception>
    public static RigMesh Import( byte[] data, string sourceName )
    {
        ArgumentNullException.ThrowIfNull( data );

        var objPositions = new List<Vector3>();
        var objNormals = new List<Vector3>();
        var objUvs = new List<Vector2>();

        var outPositions = new List<Vector3>();
        var outNormals = new List<Vector3>();
        var outUvs = new List<Vector2>();
        var outTriangles = new List<int>();
        var outTriangleTags = new List<int>();
        var corner = new Dictionary<(int V, int Vt, int Vn), int>();

        var tags = new List<string>();
        var tagIndex = new Dictionary<string, int>( StringComparer.Ordinal );
        var currentTag = -1;
        var sawGroup = false;
        var anyNormal = false;
        var anyUv = false;

        int TagFor( string name )
        {
            if ( !tagIndex.TryGetValue( name, out var i ) )
            {
                i = tags.Count;
                tags.Add( name );
                tagIndex.Add( name, i );
            }
            return i;
        }

        // Manual line scan: StringReader is not on the s&box whitelist.
        var content = Encoding.UTF8.GetString( data );
        var lineNumber = 0;
        var cornerBuffer = new int[8]; // reused across faces (grown on demand)
        var lineStart = 0;
        while ( lineStart <= content.Length )
        {
            var lineEnd = content.IndexOf( '\n', lineStart );
            var line = lineEnd < 0
                ? content[lineStart..]
                : content[lineStart..lineEnd];
            var atEnd = lineEnd < 0;
            lineStart = atEnd ? content.Length + 1 : lineEnd + 1;

            lineNumber++;
            var text = line.Trim();
            if ( text.Length == 0 || text[0] == '#' )
                continue;

            var fields = text.Split( (char[]?)null, StringSplitOptions.RemoveEmptyEntries );
            try
            {
                switch ( fields[0] )
                {
                    case "v":
                        objPositions.Add( new( F( fields[1] ), F( fields[2] ), F( fields[3] ) ) );
                        break;
                    case "vn":
                        objNormals.Add( new( F( fields[1] ), F( fields[2] ), F( fields[3] ) ) );
                        break;
                    case "vt":
                        objUvs.Add( new( F( fields[1] ), fields.Length > 2 ? F( fields[2] ) : 0f ) );
                        break;
                    case "o":
                    case "g":
                        currentTag = fields.Length > 1 ? TagFor( string.Join( ' ', fields[1..] ) ) : currentTag;
                        sawGroup = true;
                        break;
                    case "usemtl":
                        if ( !sawGroup && fields.Length > 1 )
                            currentTag = TagFor( string.Join( ' ', fields[1..] ) );
                        break;
                    case "f":
                        if ( fields.Length < 4 )
                            throw new FormatException( "face needs at least 3 corners" );
                        if ( cornerBuffer.Length < fields.Length - 1 )
                            cornerBuffer = new int[fields.Length - 1];
                        Span<int> cornerIndices = cornerBuffer.AsSpan( 0, fields.Length - 1 );
                        for ( var i = 1; i < fields.Length; i++ )
                        {
                            var (v, vt, vn) = ParseCorner( fields[i], objPositions.Count, objUvs.Count, objNormals.Count );
                            if ( !corner.TryGetValue( (v, vt, vn), out var outIndex ) )
                            {
                                outIndex = outPositions.Count;
                                outPositions.Add( objPositions[v] );
                                outNormals.Add( vn >= 0 ? objNormals[vn] : Vector3.UnitZ );
                                outUvs.Add( vt >= 0 ? objUvs[vt] : Vector2.Zero );
                                corner.Add( (v, vt, vn), outIndex );
                            }
                            if ( vn >= 0 ) anyNormal = true;
                            if ( vt >= 0 ) anyUv = true;
                            cornerIndices[i - 1] = outIndex;
                        }
                        var faceTag = currentTag >= 0 ? currentTag : TagFor( "default" );
                        for ( var i = 2; i < cornerIndices.Length; i++ )
                        {
                            outTriangles.Add( cornerIndices[0] );
                            outTriangles.Add( cornerIndices[i - 1] );
                            outTriangles.Add( cornerIndices[i] );
                            outTriangleTags.Add( faceTag );
                        }
                        break;
                }
            }
            catch ( Exception e ) when ( e is not FormatException || !e.Message.StartsWith( "OBJ", StringComparison.Ordinal ) )
            {
                throw new FormatException( $"OBJ parse error at line {lineNumber}: {text} ({e.Message})" );
            }
        }

        var mesh = new RigMesh
        {
            SourceName = sourceName,
            Positions = [ .. outPositions ],
            Normals = anyNormal ? [ .. outNormals ] : [],
            Uvs = anyUv ? [ .. outUvs ] : [],
            Triangles = [ .. outTriangles ],
            TriangleTags = [ .. outTriangleTags ],
            Tags = [ .. tags ],
        };
        mesh.Validate();
        return mesh;
    }

    static float F( string s ) => float.Parse( s, CultureInfo.InvariantCulture );

    static (int V, int Vt, int Vn) ParseCorner( string token, int vCount, int vtCount, int vnCount )
    {
        var parts = token.Split( '/' );
        if ( parts.Length is < 1 or > 3 )
            throw new FormatException( $"bad face corner '{token}'" );

        var v = Resolve( parts[0], vCount, required: true );
        var vt = parts.Length > 1 ? Resolve( parts[1], vtCount, required: false ) : -1;
        var vn = parts.Length > 2 ? Resolve( parts[2], vnCount, required: false ) : -1;
        return (v, vt, vn);
    }

    static int Resolve( string s, int count, bool required )
    {
        if ( s.Length == 0 )
        {
            if ( required ) throw new FormatException( "missing vertex index" );
            return -1;
        }
        var i = int.Parse( s, CultureInfo.InvariantCulture );
        var resolved = i > 0 ? i - 1 : count + i;
        if ( resolved < 0 || resolved >= count )
            throw new FormatException( $"index {i} out of range (have {count})" );
        return resolved;
    }
}