Editor/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();
var c = CultureInfo.InvariantCulture;
sb.Append( "# generated by Effigy\n" );
sb.Append( $"# {mesh.VertexCount} vertices, {mesh.FaceCount} faces\n" );
sb.Append( $"o {objectName}\n" );
foreach ( var p in mesh.Positions )
sb.Append( string.Format( c, "v {0:0.######} {1:0.######} {2:0.######}\n", p.x, p.y, p.z ) );
// 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];
var key = ((long)MathF.Round( uv.x * 1e5f ), (long)MathF.Round( uv.y * 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, uv.y ) );
}
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.
sb.Append( $" {f.Indices[i] + 1}/{faceUVRefs[fi][i] + 1}/{cornerNormals[fi][i] + 1}" );
}
sb.Append( '\n' );
}
return sb.ToString();
}
}
/// <summary>
/// Minimal OBJ reader, for round-tripping in tests. Not a general importer — it understands only
/// what ObjWriter emits, which is exactly enough to prove the writer produces something parseable
/// with the counts and topology intact.
/// </summary>
public static class ObjReader
{
public static PolyMesh Read( string text )
{
var mesh = new PolyMesh();
var uvs = new List<Vec2>();
var c = CultureInfo.InvariantCulture;
foreach ( var raw in text.Split( '\n' ) )
{
var line = raw.Trim();
if ( line.Length == 0 || line[0] == '#' )
continue;
var parts = line.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
switch ( parts[0] )
{
case "v":
mesh.AddVertex( new Vec3(
float.Parse( parts[1], c ),
float.Parse( parts[2], c ),
float.Parse( parts[3], c ) ) );
break;
case "vt":
uvs.Add( new Vec2( float.Parse( parts[1], c ), float.Parse( parts[2], c ) ) );
break;
case "f":
{
var n = parts.Length - 1;
var indices = new int[n];
var faceUVs = new Vec2[n];
for ( var i = 0; i < n; i++ )
{
var refs = parts[i + 1].Split( '/' );
indices[i] = int.Parse( refs[0], c ) - 1;
if ( refs.Length > 1 && refs[1].Length > 0 )
faceUVs[i] = uvs[int.Parse( refs[1], c ) - 1];
}
mesh.AddFace( indices, faceUVs );
break;
}
}
}
return mesh;
}
}