OBJ importer for Wavefront .obj files. Parses vertex positions, normals, UVs, faces (including n-gons via fan triangulation), groups/objects/material uses into tags, and builds a RigMesh with parallel arrays of positions, normals, uvs, triangles and tags.
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;
}
}