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