Effigy.Tests/RemeshGen.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// Remesh, proven on the file that prompted it.
///
/// The feature exists because a Meshy export lands at hundreds of thousands of triangles and
/// nothing in the kernel could take any away. The DecimateTests prove the algorithm on primitives
/// — a sphere, a grid, a material seam — and say nothing about the real input: 46 MB, 450k
/// vertices, 900k triangles, fourteen genus-0 parts, vertex colour baked into the positions. That
/// is the case the feature was written for, and the two big parts (300k and 360k triangles) are the
/// ones that show whether the collapse loop is fast enough to sit in a rebuild rather than hang it.
///
/// WHAT THIS RUNS: read the OBJ with ObjReader.ReadPieces, then Decimate each piece to a budget
/// proportional to its share of a ~2% overall target — the same split the feature would make, done
/// directly rather than through a PartStudio so each part's time is reported on its own. It prints
/// a per-part table (in, out, reached, closed, worst error, time), writes the reduced model back
/// out as a multi-object OBJ that still has its parts, and renders a before/after contact sheet so the shape can be checked without
/// opening the editor.
///
/// Invoked as: Effigy.Tests.exe --remesh [outDir] [sourceObj]
/// </summary>
public static class RemeshGen
{
	/// <summary>The file that made Remesh necessary. A part-segmentation export, closed and genus-0,
	/// so it is the easy case for decimation — the same algorithm on a scan with holes would spend
	/// its budget holding borders instead of reducing triangles.</summary>
	const string DefaultSource =
		@"C:\Users\pooh\Downloads\Meshy_AI_Grinning Scrapmaster_1789007624_part-segmentation\Meshy_AI_Grinning_Scrapmaster_1789007624_part-segmentation.obj";

	/// <summary>2% of ~900k is ~18k triangles — dense enough to weight and pose, sparse enough to
	/// prove the reduction actually happened.</summary>
	const float BudgetFraction = 0.02f;

	public static int Run( string outDir, string sourcePath = null, float? keepPercent = null )
	{
		sourcePath = string.IsNullOrWhiteSpace( sourcePath ) ? DefaultSource : sourcePath;
		Directory.CreateDirectory( outDir );

		if ( !File.Exists( sourcePath ) )
		{
			Console.WriteLine( "remesh: source not found" );
			Console.WriteLine( "  " + sourcePath );
			Console.WriteLine( "  pass a second argument: --remesh <outDir> <sourceObj>" );
			return 1;
		}

		var clock = Stopwatch.StartNew();
		var pieces = ObjReader.ReadPieces( File.ReadAllText( sourcePath ) );
		clock.Stop();

		var total = pieces.Sum( p => Decimate.TriangleCount( p.Mesh ) );

		// The default is the one the feature was proven at; the argument is for the second look you
		// take once you have seen the first — "a bit less than that" is the normal follow-up, and
		// rebuilding the generator to change a const is not the way to answer it.
		var fraction = keepPercent.HasValue
			? Math.Clamp( keepPercent.Value / 100f, 0.0001f, 1f )
			: BudgetFraction;
		var budget = Math.Max( 4, (int)MathF.Round( total * fraction ) );

		Console.WriteLine( $"read {Path.GetFileName( sourcePath )} in {clock.ElapsedMilliseconds} ms" );
		Console.WriteLine( $"  {pieces.Count} pieces, {pieces.Sum( p => p.Mesh.VertexCount ):N0} vertices, "
			+ $"{total:N0} triangles" );
		Console.WriteLine( $"  budget {fraction * 100f:0.###}% of {total:N0} = {budget:N0} triangles, "
			+ "split proportionally to each part's size" );
		Console.WriteLine();

		Console.WriteLine( $"  {"part",-12} {"in",9} {"out",9} {"reached",8} {"closed",7} {"err",10} {"ms",7}" );

		var reduced = new List<ObjReader.ObjPiece>();
		var allReached = true;
		var allClosed = true;
		var totalIn = 0;
		var totalOut = 0;
		var worstRel = 0f;

		foreach ( var piece in pieces )
		{
			var before = Decimate.TriangleCount( piece.Mesh );
			var target = Math.Max( 4, (int)MathF.Round( budget * ( before / (float)total ) ) );
			var diagonal = piece.Mesh.BoundsDiagonal;

			var sw = Stopwatch.StartNew();
			var result = Decimate.Run( piece.Mesh, new Decimate.Options { TargetTriangles = target } );
			sw.Stop();

			var validation = MeshValidator.Validate( result.Mesh );

			// WorstError is squared distance in model units, so it is only meaningful against the
			// model's own size. Dividing by the diagonal squared makes it a dimensionless "how far
			// did the worst collapse move a vertex, as a fraction of the whole model".
			var rel = diagonal > 1e-6f ? result.WorstError / ( diagonal * diagonal ) : 0f;

			reduced.Add( new ObjReader.ObjPiece( piece.Name, result.Mesh ) );
			allReached &= result.ReachedTarget;
			allClosed &= validation.IsClosed;
			totalIn += before;
			totalOut += result.ToTriangles;
			worstRel = MathF.Max( worstRel, rel );

			Console.WriteLine( $"  {piece.Name,-12} {before,9:N0} {result.ToTriangles,9:N0} "
				+ $"{( result.ReachedTarget ? "yes" : "NO" ),8} {( validation.IsClosed ? "yes" : "NO" ),7} "
				+ $"{rel,10:0.###e+0} {sw.ElapsedMilliseconds,7}" );
		}

		Console.WriteLine();
		Console.WriteLine( $"  {totalIn:N0} -> {totalOut:N0} triangles ({totalOut * 100f / totalIn:0.#}%), "
			+ $"worst error {worstRel:0.###e+0} of the bounds diagonal" );

		if ( !allReached )
			Console.WriteLine( "  note: a part stopped before its target — see the table" );
		if ( !allClosed )
			Console.WriteLine( "  note: a part came out open — see the table" );

		// --- the outputs ---------------------------------------------------------------------

		// FOURTEEN OBJECTS, NOT ONE. This used to Combine() the pieces into a single mesh before
		// writing, which threw away the segmentation that was the whole reason for using a
		// part-segmentation export: the file went in as fourteen parts and came back as one lump,
		// with nothing left to rig, hide or budget per part.
		var objPath = Path.Combine( outDir, "remesh_all.obj" );
		ObjWriter.WriteFile( reduced, objPath );

		// AND A .vmdl BESIDE IT, because an OBJ on its own is not something the engine will show
		// you. Making one by hand in ModelDoc is what people did instead, and ModelDoc defaults
		// import_rotation to zero — which for an OBJ is the cyclic axis permutation described in
		// VmdlDocument.ImportRotation, i.e. the character compiles lying on its side. Writing it
		// here means the correction comes along with the mesh.
		var vmdlPath = Path.Combine( outDir, "remesh_all.vmdl" );
		File.WriteAllText( vmdlPath, VmdlDocument.Static( "models/effigy/remesh_all.obj",
			VmdlPhysics.MeshFromRender() ) );

		// Before and after, side by side, for the two parts whose size is the whole point. The
		// caption under each tile carries its vertex/face counts, so the reduction is visible even
		// where the two shapes look the same — which, for a good decimation, is exactly what they
		// should do.
		var big = pieces
			.Zip( reduced, ( original, reduced ) => (original, reduced) )
			.OrderByDescending( t => Decimate.TriangleCount( t.original.Mesh ) )
			.Take( 2 )
			.ToList();

		var tiles = new List<PngPreview.Tile>();

		foreach ( var (original, shrunk) in big )
		{
			tiles.Add( new PngPreview.Tile( original.Mesh, $"{original.Name} in" ) );
			tiles.Add( new PngPreview.Tile( shrunk.Mesh, $"{original.Name} out" ) );
		}

		tiles.Add( new PngPreview.Tile( big[0].reduced.Mesh, "out, wireframe", wireframe: true ) );

		var pngPath = Path.Combine( outDir, "remesh_preview.png" );
		PngPreview.WriteSheet( tiles, pngPath, columns: 2, tileSize: 400 );

		Console.WriteLine();
		Console.WriteLine( $"OBJ {objPath} ({reduced.Count} objects)" );
		Console.WriteLine( "VMDL " + vmdlPath );
		Console.WriteLine( "PNG " + pngPath );
		Console.WriteLine( "DONE" );
		return 0;
	}

}