Effigy/ObjWriter.cs
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
namespace Effigy;
/// <summary>
/// Wavefront OBJ export — the DEBUG and interchange format, not the export path.
///
/// THIS IS NO LONGER HOW MODELS REACH s&box. OBJ cannot carry bones or vertex weights, so it stops
/// being viable the moment a model is rigged, and rigging is in scope. SmdWriter is the export
/// path for both static and skinned models; see the note at the top of it for why one format
/// covers both.
///
/// What OBJ is still the best tool for, and why it stays:
///
/// It is the only format here that PRESERVES QUADS. SMD triangulates on the way out, so an OBJ is
/// the only way to look at the cage as the kernel actually holds it — which is exactly what you
/// need when checking whether a subdivision result is right. Open it in Blender and the topology
/// is there to read.
///
/// The test suite writes one per primitive for that reason, and round-trips them to prove the
/// writer emits something parseable with its topology intact.
///
/// Godot needs neither path — there the kernel hands vertices to ArrayMesh directly.
/// </summary>
public static class ObjWriter
{
/// <summary>Kept as an alias so existing callers do not have to know where this moved to.</summary>
public const float DefaultSmoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees;
/// <summary>What an unnamed slot is called. Shared with SmdWriter and DmxWriter so a model
/// exported three ways names its materials the same three times.</summary>
public static string DefaultMaterialName( int slot ) => $"material_{slot}";
public static void WriteFile( PolyMesh mesh, string path, string objectName = "model",
float smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )
{
File.WriteAllText( path, Write( mesh, objectName, smoothingAngleDegrees, materialName ) );
}
/// <param name="materialName">What to call each material slot. Defaults to material_0, material_1
/// and so on — a name a person chose is the difference between binding by meaning and binding by
/// number in whatever the model lands in.</param>
public static string Write( PolyMesh mesh, string objectName = "model",
float smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )
{
var sb = new StringBuilder();
sb.Append( "# generated by Effigy\n" );
sb.Append( $"# {mesh.VertexCount} vertices, {mesh.FaceCount} faces\n" );
var offsets = (Vertices: 0, UVs: 0, Normals: 0);
AppendPiece( sb, mesh, objectName, smoothingAngleDegrees, materialName, ref offsets );
return sb.ToString();
}
/// <summary>
/// Several named pieces in one file, each under its own <c>o</c> marker — which is what makes
/// it come back as several parts rather than one welded lump.
///
/// THE MERGE IS THE LOSSY STEP, not the format. A part-segmentation export exists precisely so
/// that a hand is a hand and a torso is a torso, and flattening fourteen of those into one mesh
/// on the way out throws away the only thing that made the file worth segmenting: everything
/// downstream that works per part — rigging a bone to a body, hiding one while you sculpt
/// another, a Remesh with a budget of its own — has nothing left to work per part ON.
///
/// OBJ's indices are file-global rather than per-object, which is the entire reason this cannot
/// be done by concatenating what <see cref="Write(PolyMesh, string, float, Func{int, string})"/>
/// returns: every piece after the first would reference the first one's vertices.
/// </summary>
public static void WriteFile( IReadOnlyList<ObjReader.ObjPiece> pieces, string path,
float smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )
{
File.WriteAllText( path, Write( pieces, smoothingAngleDegrees, materialName ) );
}
/// <inheritdoc cref="WriteFile(IReadOnlyList{ObjReader.ObjPiece}, string, float, Func{int, string})"/>
public static string Write( IReadOnlyList<ObjReader.ObjPiece> pieces,
float smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func<int, string> materialName = null )
{
var sb = new StringBuilder();
var offsets = (Vertices: 0, UVs: 0, Normals: 0);
var vertices = 0;
var faces = 0;
foreach ( var piece in pieces )
{
vertices += piece.Mesh?.VertexCount ?? 0;
faces += piece.Mesh?.FaceCount ?? 0;
}
sb.Append( "# generated by Effigy\n" );
sb.Append( $"# {vertices} vertices, {faces} faces, {pieces.Count} objects\n" );
for ( var i = 0; i < pieces.Count; i++ )
{
var piece = pieces[i];
if ( piece.Mesh is null || piece.Mesh.FaceCount == 0 )
continue;
// An unnamed piece still needs a marker, or it merges into the one before it on the way
// back in. ReadPieces hands back "" for faces that appeared before any o/g line.
var name = string.IsNullOrWhiteSpace( piece.Name ) ? $"part_{i}" : piece.Name;
AppendPiece( sb, piece.Mesh, name, smoothingAngleDegrees, materialName, ref offsets );
}
return sb.ToString();
}
/// <summary>One object's worth of the file. The offsets go in as the counts written so far and
/// come back raised by this piece's own, because OBJ numbers v/vt/vn across the whole file.
/// </summary>
private static void AppendPiece( StringBuilder sb, PolyMesh mesh, string objectName,
float smoothingAngleDegrees, Func<int, string> materialName,
ref (int Vertices, int UVs, int Normals) offsets )
{
var c = CultureInfo.InvariantCulture;
sb.Append( $"o {objectName}\n" );
for ( var i = 0; i < mesh.Positions.Count; i++ )
{
var p = mesh.Positions[i];
sb.Append( string.Format( c, "v {0:0.######} {1:0.######} {2:0.######}", p.x, p.y, p.z ) );
// Vertex colour, when there is one. OBJ vertex colour is RGB, and "no paint" is WHITE —
// the consumer multiplies the material by the colour, so white means "unchanged" and
// coverage fades the vertex from white toward the paint colour.
if ( mesh.VertexColors is not null && i < mesh.VertexColors.Length )
{
var tint = mesh.VertexColors[i].Tint();
sb.Append( string.Format( c, " {0:0.######} {1:0.######} {2:0.######}",
tint.x, tint.y, tint.z ) );
}
sb.Append( '\n' );
}
// UVs are per corner, so the same value recurs constantly. Deduping keeps the file to a
// sane size without changing what it means.
var uvIndex = new Dictionary<(long, long), int>();
var faceUVRefs = new int[mesh.FaceCount][];
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
var f = mesh.Faces[fi];
faceUVRefs[fi] = new int[f.Count];
for ( var i = 0; i < f.Count; i++ )
{
var uv = f.UVs[i];
// OBJ puts the UV origin at the bottom-left where Effigy's is top-left, so V is
// flipped on the way out — the same flip FbxWriter makes for the same reason, and the
// one DmxWriter avoids by leaving flipVCoordinates off and writing V as-is. Without it
// a compiled static model samples its texture upside down relative to the viewport.
var flippedV = 1f - uv.y;
var key = ((long)MathF.Round( uv.x * 1e5f ), (long)MathF.Round( flippedV * 1e5f ));
if ( !uvIndex.TryGetValue( key, out var idx ) )
{
idx = uvIndex.Count;
uvIndex[key] = idx;
sb.Append( string.Format( c, "vt {0:0.######} {1:0.######}\n", uv.x, flippedV ) );
}
faceUVRefs[fi][i] = idx;
}
}
var (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );
foreach ( var n in normals )
sb.Append( string.Format( c, "vn {0:0.######} {1:0.######} {2:0.######}\n", n.x, n.y, n.z ) );
var currentMaterial = int.MinValue;
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
var f = mesh.Faces[fi];
if ( f.Material != currentMaterial )
{
currentMaterial = f.Material;
sb.Append( $"usemtl {(materialName ?? DefaultMaterialName)( currentMaterial )}\n" );
}
sb.Append( 'f' );
for ( var i = 0; i < f.Count; i++ )
{
// OBJ indices are 1-based, and counted from the top of the FILE rather than from
// the top of this object — hence the offsets, which are zero for a one-piece write.
sb.Append( $" {f.Indices[i] + 1 + offsets.Vertices}"
+ $"/{faceUVRefs[fi][i] + 1 + offsets.UVs}"
+ $"/{cornerNormals[fi][i] + 1 + offsets.Normals}" );
}
sb.Append( '\n' );
}
offsets = (offsets.Vertices + mesh.VertexCount, offsets.UVs + uvIndex.Count,
offsets.Normals + normals.Count);
}
}
/// <summary>
/// Wavefront OBJ reader — the import path, and the round-trip for tests.
///
/// Understands what <see cref="ObjWriter"/> emits, plus the subset a Meshy or Blender export
/// actually uses: positions, optional vertex colour, UVs, faces (with or without vt/vn), and
/// <c>usemtl material_N</c>. Everything else on a line is ignored rather than refused, because
/// a real OBJ is full of <c>o</c> / <c>g</c> / <c>s</c> / <c>vn</c> / <c>mtllib</c> that do not
/// change the solid.
///
/// V IS FLIPPED, matching the writer. OBJ's UV origin is bottom-left; Effigy's is top-left.
/// Reading without the flip would import every textured mesh upside down, and a writer→reader
/// round trip would invert V twice and look correct by accident.
/// </summary>
public static class ObjReader
{
public static PolyMesh ReadFile( string path ) => Read( File.ReadAllText( path ) );
/// <summary>One named lump of an OBJ — an <c>o</c> object or a <c>g</c> group, with the faces
/// that followed it. See <see cref="ReadPieces"/>.</summary>
public readonly struct ObjPiece
{
public readonly string Name;
public readonly PolyMesh Mesh;
public ObjPiece( string name, PolyMesh mesh )
{
Name = name;
Mesh = mesh;
}
}
/// <summary>
/// The whole file as one mesh, objects and groups fused. What every caller wanted before
/// imports could be split, and what a writer's round-trip still compares against.
/// </summary>
public static PolyMesh Read( string text )
{
var parsed = Parse( text );
var mesh = new PolyMesh { Positions = parsed.Positions };
foreach ( var f in parsed.Faces )
mesh.AddFace( f.Indices, f.UVs, f.Material );
if ( parsed.SawColor )
mesh.VertexColors = parsed.Colors.ToArray();
return mesh;
}
/// <summary>
/// The file split at its <c>o</c> and <c>g</c> lines, one mesh per lump.
///
/// WHY THIS EXISTS: an OBJ's vertex indices are global to the FILE, not to the object, so an
/// exporter that carefully kept a character's eyelids, brows and hair as separate objects hands
/// us one flat pile of triangles with the boundaries recorded only in these marker lines.
/// Reading it with <see cref="Read"/> throws that structure away and produces one welded body —
/// which is exactly the body you cannot build a tidy feature tree out of, and cannot hide,
/// re-material or weight a piece of on its own.
///
/// Each piece gets its own vertex list, renumbered, holding only the vertices its faces
/// actually use. Faces before the first marker land in a piece with an empty name; the caller
/// decides what to call that, since only it knows what the file was called.
///
/// A file with no markers at all comes back as one unnamed piece, so a caller can always take
/// this path rather than branching on whether the exporter bothered.
/// </summary>
public static List<ObjPiece> ReadPieces( string text )
{
var parsed = Parse( text );
var pieces = new List<ObjPiece>();
// Grouped by index rather than by name so two objects sharing a name stay two pieces —
// a duplicated name is an exporter's business, not a reason to weld geometry together.
var byGroup = new Dictionary<int, List<ObjFace>>();
var order = new List<int>();
foreach ( var f in parsed.Faces )
{
if ( !byGroup.TryGetValue( f.Group, out var list ) )
{
byGroup[f.Group] = list = new List<ObjFace>();
order.Add( f.Group );
}
list.Add( f );
}
foreach ( var group in order )
{
var faces = byGroup[group];
var mesh = new PolyMesh();
var remap = new Dictionary<int, int>();
var colors = parsed.SawColor ? new List<Vec4>() : null;
foreach ( var f in faces )
{
var indices = new int[f.Indices.Length];
for ( var i = 0; i < f.Indices.Length; i++ )
{
var source = f.Indices[i];
if ( !remap.TryGetValue( source, out var local ) )
{
local = mesh.AddVertex( parsed.Positions[source] );
remap[source] = local;
colors?.Add( parsed.Colors[source] );
}
indices[i] = local;
}
mesh.AddFace( indices, f.UVs, f.Material );
}
if ( colors is not null )
mesh.VertexColors = colors.ToArray();
pieces.Add( new ObjPiece( group >= 0 ? parsed.GroupNames[group] : "", mesh ) );
}
return pieces;
}
readonly struct ObjFace
{
public readonly int[] Indices;
public readonly Vec2[] UVs;
public readonly int Material;
public readonly int Group;
public ObjFace( int[] indices, Vec2[] uvs, int material, int group )
{
Indices = indices;
UVs = uvs;
Material = material;
Group = group;
}
}
sealed class ObjData
{
public List<Vec3> Positions = new();
public List<Vec4> Colors = new();
public List<Vec2> UVs = new();
public List<ObjFace> Faces = new();
public List<string> GroupNames = new();
public bool SawColor;
}
/// <summary>
/// One pass over the text. Both readers above run on this, so a fused read and a split read
/// cannot disagree about what the file says — which is the whole reason it is factored out
/// rather than written twice.
/// </summary>
static ObjData Parse( string text )
{
var data = new ObjData();
var material = 0;
var group = -1;
// One pass over the string as spans. The string.Split / Trim version this replaced did
// three allocations a line on a dense file — the line, the token array, and every token
// itself — which on a million-face OBJ is tens of millions of objects for the GC to chew.
// Spans keep the whole parse allocation-free except for the data it is actually building.
var remaining = text.AsSpan();
while ( remaining.Length > 0 )
{
var newline = remaining.IndexOf( '\n' );
var line = newline < 0 ? remaining : remaining.Slice( 0, newline );
remaining = newline < 0 ? default : remaining.Slice( newline + 1 );
line = line.Trim();
if ( line.Length == 0 || line[0] == '#' )
continue;
var space = line.IndexOf( ' ' );
var head = space < 0 ? line : line.Slice( 0, space );
var rest = space < 0 ? default : line.Slice( space + 1 );
switch ( head[0] )
{
case 'v':
{
if ( head.Length == 2 )
{
// vt: writer flips V on the way out; this un-flips it. External OBJs
// (bottom-left origin) land in Effigy space the same way. vn (normals) is
// skipped, as it always was.
if ( head[1] == 't' )
data.UVs.Add( new Vec2( NextFloat( ref rest ), 1f - NextFloat( ref rest ) ) );
break;
}
data.Positions.Add( new Vec3(
NextFloat( ref rest ),
NextFloat( ref rest ),
NextFloat( ref rest ) ) );
if ( CountTokens( rest ) >= 3 )
{
data.SawColor = true;
data.Colors.Add( new Vec4(
NextFloat( ref rest ),
NextFloat( ref rest ),
NextFloat( ref rest ),
1f ) );
}
else
{
data.Colors.Add( new Vec4( 1f, 1f, 1f, 1f ) );
}
break;
}
case 'f':
{
var n = CountTokens( rest );
if ( n < 3 )
break;
var indices = new int[n];
var faceUVs = new Vec2[n];
for ( var i = 0; i < n; i++ )
{
NextToken( ref rest, out var corner );
var slash = corner.IndexOf( '/' );
indices[i] = ObjIndex( slash < 0 ? corner : corner.Slice( 0, slash ), data.Positions.Count );
if ( slash >= 0 )
{
var after = corner.Slice( slash + 1 );
var second = after.IndexOf( '/' );
var vt = second < 0 ? after : after.Slice( 0, second );
if ( vt.Length > 0 && data.UVs.Count > 0 )
faceUVs[i] = data.UVs[ObjIndex( vt, data.UVs.Count )];
}
}
data.Faces.Add( new ObjFace( indices, faceUVs, material, group ) );
break;
}
// BOTH MARKERS START A PIECE. Exporters disagree about which one means "object":
// Blender writes `o` per object and `g` for its vertex groups, other tools only
// ever write `g`. Treating either as a boundary is what makes the split work on
// files this tool did not write, which is the only kind it gets.
case 'o':
case 'g':
data.GroupNames.Add( rest.Length > 0 ? JoinTokens( rest ) : "" );
group = data.GroupNames.Count - 1;
break;
case 'u':
// usemtl — the only u-keyword the reader acts on.
if ( head.SequenceEqual( "usemtl".AsSpan() ) )
material = SlotFromMaterialName( NextToken( ref rest, out var name ) ? name.ToString() : "", material );
break;
}
}
return data;
}
/// <summary>The next whitespace-delimited token, advancing <paramref name="s"/> past it.</summary>
static bool NextToken( ref ReadOnlySpan<char> s, out ReadOnlySpan<char> token )
{
while ( s.Length > 0 && (s[0] == ' ' || s[0] == '\t') )
s = s.Slice( 1 );
if ( s.Length == 0 )
{
token = default;
return false;
}
var end = 0;
while ( end < s.Length && s[end] != ' ' && s[end] != '\t' )
end++;
token = s.Slice( 0, end );
s = s.Slice( end );
return true;
}
/// <summary>The next token as a float.</summary>
static float NextFloat( ref ReadOnlySpan<char> s )
{
NextToken( ref s, out var token );
return ParseFloat( token );
}
/// <summary>
/// A decimal float with no culture and no allocation.
///
/// The reader used <c>float.Parse</c>, which on a dense OBJ is the whole of the load time — a
/// million vertices is a few million culture-aware, correctly-rounding parses into a general
/// number machinery. OBJ numbers are plain decimals, so the digits are accumulated into an
/// exact integer and scaled by a power of ten, which matches <c>float.Parse</c> on every value a
/// Wavefront file actually contains and runs many times faster.
/// </summary>
static float ParseFloat( ReadOnlySpan<char> s )
{
var i = 0;
var len = s.Length;
var negative = false;
if ( i < len && (s[i] == '-' || s[i] == '+') )
{
negative = s[i] == '-';
i++;
}
// Integer digits, then fraction digits, accumulated into one integer. Stopped at 15 digits:
// below 2^53 the double conversion is exact, and 15 significant figures is already more than
// a float can carry, so nothing is lost. Digits past that only move the decimal point.
const long Cap = 1_000_000_000_000_000L;
long mantissa = 0;
var digits = 0;
var intDigits = 0;
while ( i < len && s[i] >= '0' && s[i] <= '9' )
{
if ( mantissa < Cap )
{
mantissa = mantissa * 10 + (s[i] - '0');
digits++;
}
intDigits++;
i++;
}
if ( i < len && s[i] == '.' )
{
i++;
while ( i < len && s[i] >= '0' && s[i] <= '9' )
{
if ( mantissa < Cap )
{
mantissa = mantissa * 10 + (s[i] - '0');
digits++;
}
i++;
}
}
// Where the decimal point sits relative to the accumulated digits.
var exponent = intDigits - digits;
if ( i < len && (s[i] == 'e' || s[i] == 'E') )
{
i++;
var expNegative = false;
if ( i < len && (s[i] == '-' || s[i] == '+') )
{
expNegative = s[i] == '-';
i++;
}
var e = 0;
while ( i < len && s[i] >= '0' && s[i] <= '9' )
{
e = e * 10 + (s[i] - '0');
i++;
}
exponent += expNegative ? -e : e;
}
var value = (double)mantissa * Pow10( exponent );
return (float)(negative ? -value : value);
}
static readonly double[] Pow10Positive = BuildPow10();
static double[] BuildPow10()
{
var table = new double[39];
table[0] = 1.0;
for ( var i = 1; i < table.Length; i++ )
table[i] = table[i - 1] * 10.0;
return table;
}
/// <summary>10 to the power of <paramref name="e"/>, from a table for the exponents a mesh
/// coordinate uses and <see cref="Math.Pow"/> for the rest.</summary>
static double Pow10( int e )
{
if ( e >= 0 && e < Pow10Positive.Length )
return Pow10Positive[e];
if ( e < 0 && -e < Pow10Positive.Length )
return 1.0 / Pow10Positive[-e];
return Math.Pow( 10.0, e );
}
/// <summary>How many whitespace-delimited tokens remain.</summary>
static int CountTokens( ReadOnlySpan<char> s )
{
var count = 0;
while ( NextToken( ref s, out _ ) )
count++;
return count;
}
/// <summary>The remaining tokens rejoined with single spaces — what string.Join over the split
/// produced before.</summary>
static string JoinTokens( ReadOnlySpan<char> s )
{
var builder = new StringBuilder();
while ( NextToken( ref s, out var token ) )
{
if ( builder.Length > 0 )
builder.Append( ' ' );
builder.Append( token );
}
return builder.ToString();
}
/// <summary>Wavefront indices are 1-based, and negative counts back from the last element.</summary>
static int ObjIndex( ReadOnlySpan<char> token, int count )
{
var n = ParseInt( token );
return n > 0 ? n - 1 : count + n;
}
/// <summary>A plain base-10 integer, no culture, no allocation. Face indices only, so small.</summary>
static int ParseInt( ReadOnlySpan<char> s )
{
var i = 0;
var negative = false;
if ( i < s.Length && (s[i] == '-' || s[i] == '+') )
{
negative = s[i] == '-';
i++;
}
var value = 0;
while ( i < s.Length )
{
value = value * 10 + (s[i] - '0');
i++;
}
return negative ? -value : value;
}
/// <summary><see cref="ObjWriter.DefaultMaterialName"/> is <c>material_N</c>; anything else
/// leaves the current slot alone rather than inventing one.</summary>
static int SlotFromMaterialName( string name, int current )
{
const string prefix = "material_";
if ( name.StartsWith( prefix, StringComparison.OrdinalIgnoreCase )
&& int.TryParse( name.AsSpan( prefix.Length ), NumberStyles.Integer, CultureInfo.InvariantCulture, out var slot )
&& slot >= 0 )
return slot;
return current;
}
}