Editor/Output/ArchExport.cs
using System.Globalization;
using System.Text;
using HalfEdgeMesh;

namespace Sunless.Architecture;

// What a unit of Source means wherever the mesh is going. An engine unit IS an inch, so a 600mm module lands in a
// metre-scaled package as a 24 METRE cabinet unless it is told otherwise.
public enum ArchExportUnits {
	SourceUnits,
	Metres,
	Centimetres
}

public sealed record ArchExportSettings( ArchExportUnits Units, bool YUp ) {
	public const float UnitsPerMetre = 39.3701f;

	public float Scale => Units switch {
		ArchExportUnits.Metres => 1f / UnitsPerMetre,
		ArchExportUnits.Centimetres => 100f / UnitsPerMetre,
		_ => 1f
	};

	// Source units first ask, because that is the export that needs no import scale anywhere. A project working in
	// metres says so once and the cookie remembers it - the default is for the first time, not for the tenth.
	public static ArchExportSettings Remembered() {
		return new ArchExportSettings(
			(ArchExportUnits)EditorCookie.Get( "arch.export.units", (int)ArchExportUnits.SourceUnits ),
			EditorCookie.Get( "arch.export.yup", true ) );
	}

	public void Remember() {
		EditorCookie.Set( "arch.export.units", (int)Units );
		EditorCookie.Set( "arch.export.yup", YUp );
	}

	public static string Named( ArchExportUnits units ) => units switch {
		ArchExportUnits.Metres => "metres",
		ArchExportUnits.Centimetres => "centimetres",
		_ => "source units"
	};
}

// ANY STAGED PRESET, written out as a mesh an author can model over. The blockout IS the brief: it already carries
// the sizes the plan will stand the finished art at, so a model built onto it comes back right with no measuring.
//
// It knows nothing about what it is exporting - a preset browser already holds a Recipe that stages one, which is
// the same recipe its card art is drawn from, so a cabinet, a door and a window all export through this.
//
// OBJ rather than FBX because the engine ships no FBX writer, and OBJ is the one interchange format that needs
// none - Blender reads it natively, and the material names come across as the arch surface roles.
public static class ArchExport {
	public const string Directory = "dev/export";

	// Prefixed by KIND, so one flat export folder holding a door, a window and three cabinets sorts into what each
	// of them is rather than into whatever the author happened to call them.
	public static string Write( string kind, string name, Func<ArchStaged> recipe, ArchKit kit, ArchExportSettings settings ) {
		if ( recipe is null || string.IsNullOrWhiteSpace( name ) ) {
			return null;
		}

		var staged = recipe();

		if ( staged.Plan is null ) {
			Log.Warning( $"Architecture: '{name}' staged nothing to export." );

			return null;
		}

		var scene = Scene.CreateEditorScene();

		try {
			using ( scene.Push() ) {
				ArchScene.Generate( scene, staged.Plan, kit );
			}

			return Written( Filed( kind, name ), Body( scene, name, settings ) );
		} finally {
			scene.Destroy();
		}
	}

	// Source is Z-up and OBJ is usually read Y-up, so the mesh can turn a quarter about X on the way out. A
	// ROTATION, never a mirror - swapping two axes instead would invert every face's winding.
	static Vector3 Turned( Vector3 point, ArchExportSettings settings ) {
		var scaled = point * settings.Scale;

		return settings.YUp ? new Vector3( scaled.x, scaled.z, -scaled.y ) : scaled;
	}

	// One object per generated part and one group per material, so the piece names the plan uses - Carcass, Fronts,
	// Leaf, Casing, Sill - survive into whatever the author opens this in.
	static string Body( Scene scene, string name, ArchExportSettings settings ) {
		var text = new StringBuilder();
		var written = 0;

		text.AppendLine( $"# Sunless Arch blockout: {name}" );
		text.AppendLine( $"# {(settings.YUp ? "Y-up" : "Z-up")}, {ArchExportSettings.Named( settings.Units )}."
			+ " One object per generated piece." );

		foreach ( var node in scene.GetAllObjects( true ) ) {
			if ( node.Components.Get<MeshComponent>() is not { Mesh: not null } renderer ) {
				continue;
			}

			written += Part( text, node, renderer.Mesh, written, settings );
		}

		return written == 0 ? null : text.ToString();
	}

	// OBJ indexes vertices from 1 and counts them across the WHOLE file, so each part is written knowing how many
	// stand before it.
	static int Part( StringBuilder text, GameObject node, PolygonMesh mesh, int before, ArchExportSettings settings ) {
		var faces = mesh.FaceHandles.ToList();

		if ( faces.Count == 0 ) {
			return 0;
		}

		var order = new Dictionary<VertexHandle, int>();
		var points = new List<Vector3>();

		foreach ( var face in faces ) {
			if ( !mesh.GetVerticesConnectedToFace( face, out var corners ) ) {
				continue;
			}

			foreach ( var corner in corners ) {
				if ( order.ContainsKey( corner ) ) {
					continue;
				}

				order[corner] = points.Count;
				points.Add( Turned( node.WorldTransform.PointToWorld( mesh.GetVertexPosition( corner ) ), settings ) );
			}
		}

		if ( points.Count == 0 ) {
			return 0;
		}

		text.AppendLine();
		text.AppendLine( $"o {Slug( node.Name )}" );

		foreach ( var point in points ) {
			text.AppendLine( $"v {Number( point.x )} {Number( point.y )} {Number( point.z )}" );
		}

		var standing = "";

		foreach ( var face in faces ) {
			if ( !mesh.GetVerticesConnectedToFace( face, out var corners ) || corners.Length < 3 ) {
				continue;
			}

			var material = Slug( mesh.GetFaceMaterial( face )?.ResourceName ?? "none" );

			if ( material != standing ) {
				standing = material;

				text.AppendLine( $"usemtl {material}" );
			}

			text.Append( 'f' );

			foreach ( var corner in corners ) {
				text.Append( ' ' ).Append( before + order[corner] + 1 );
			}

			text.AppendLine();
		}

		return points.Count;
	}

	static string Written( string name, string body ) {
		if ( string.IsNullOrWhiteSpace( body ) ) {
			Log.Warning( $"Architecture: '{name}' generated no geometry to export." );

			return null;
		}

		if ( Project.Current?.GetAssetsPath() is not { Length: > 0 } assets ) {
			Log.Warning( "Architecture: no open project to export into." );

			return null;
		}

		var folder = System.IO.Path.Combine( assets, Directory.Replace( '/', System.IO.Path.DirectorySeparatorChar ) );
		var path = System.IO.Path.Combine( folder, $"{Slug( name )}.obj" );

		try {
			System.IO.Directory.CreateDirectory( folder );
			System.IO.File.WriteAllText( path, body );
		} catch ( Exception fault ) {
			Log.Warning( $"Architecture: could not export '{name}' — {fault.Message}" );

			return null;
		}

		Log.Info( $"Architecture: exported the '{name}' blockout to {path}" );

		return path;
	}

	// A kind the caller already spelled out is not repeated: exporting a preset somebody called "door_oak" from the
	// door shelf writes door_oak.obj, not door_door_oak.obj.
	static string Filed( string kind, string name ) {
		var slug = Slug( name );

		if ( string.IsNullOrWhiteSpace( kind ) ) {
			return slug;
		}

		var prefix = Slug( kind ).ToLowerInvariant();

		return slug.ToLowerInvariant().StartsWith( prefix, StringComparison.OrdinalIgnoreCase )
			? slug
			: $"{prefix}_{slug}";
	}

	// The invariant culture on purpose: an OBJ written on a machine whose decimal separator is a comma is one no
	// importer anywhere can read.
	static string Number( float value ) => value.ToString( "0.####", CultureInfo.InvariantCulture );

	static string Slug( string name ) {
		if ( string.IsNullOrWhiteSpace( name ) ) {
			return "part";
		}

		var cleaned = new string( name.Select( letter => char.IsLetterOrDigit( letter ) ? letter : '_' ).ToArray() );

		return cleaned.Trim( '_' ) is { Length: > 0 } trimmed ? trimmed : "part";
	}
}