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