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