Editor/Output/ArchBake.cs

Editor utility that bakes generated architecture in a scene. It finds the generated root, merges mesh components per room into single polygon meshes, removes source objects, and can write the baked scene to a maps/raids directory via ArchStorage.

File Access
using System.Collections.Generic;
using System.Linq;
using Editor;
using MapDoor = Sandbox.Mapping.Door;
using Sandbox;

namespace Sunless.Architecture;

public static class ArchBake
{
	public static int MergeScene( Scene scene )
	{
		var root = ArchScene.FindRoot( scene );

		if ( !root.IsValid() )
		{
			Log.Warning( "Architecture: nothing to bake - no generated root in this scene." );
			return 0;
		}

		var merged = 0;

		using ( SceneEditorSession.Active.UndoScope( "Bake Architecture" ).WithGameObjectChanges( root, GameObjectUndoFlags.All ).Push() )
		{
			foreach ( var room in Rooms( root ).ToList() )
			{
				merged += MergeGroup( room );
			}
		}

		return merged;
	}

	public const string MapDirectory = "maps/raids";

	public static void BakeToMap( Scene scene )
	{
		var root = ArchScene.FindRoot( scene );

		if ( !root.IsValid() )
		{
			Log.Warning( "Architecture: nothing to bake - no generated root in this scene." );
			return;
		}

		MergeScene( scene );

		var name = System.IO.Path.GetFileNameWithoutExtension( scene.Source?.ResourcePath ?? "untitled" );
		var target = $"{MapDirectory}/{name}.scene";
		var file = new SceneFile { GameObjects = new[] { root.Serialize() } };

		// Editor.FileSystem.Content is read-only and throws on every write, so this has to go through ArchStorage.
		if ( ArchStorage.WriteAsset( target, Json.Serialize( file ) ) )
		{
			Log.Info( $"Architecture: wrote baked map to {target}" );
		}
	}

	static IEnumerable<GameObject> Rooms( GameObject root )
	{
		foreach ( var building in root.Children )
		{
			foreach ( var child in building.Children )
			{
				yield return child;
			}
		}
	}

	static int MergeGroup( GameObject group )
	{
		var sources = group.Components
			.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants )
			.Where( mesh => mesh.Mesh is not null )
			.Where( mesh => !HasDoorAncestor( mesh.GameObject, group ) )
			.ToList();

		if ( sources.Count < 2 )
		{
			return 0;
		}

		var target = new PolygonMesh();
		target.SetTransform( Transform.Zero );

		foreach ( var source in sources )
		{
			var relative = group.WorldTransform.ToLocal( source.WorldTransform );
			target.MergeMesh( source.Mesh, relative, out _, out _, out _ );
		}

		target.SetSmoothingAngle( 0f );

		var owners = sources.Select( source => source.GameObject ).Distinct().ToList();

		foreach ( var source in sources )
		{
			source.Destroy();
		}

		foreach ( var owner in owners )
		{
			if ( owner != group && !owner.Children.Any() && !owner.Components.GetAll().Any() )
			{
				owner.Destroy();
			}
		}

		var baked = group.Scene.CreateObject();
		baked.Name = "Baked";
		baked.SetParent( group, false );
		baked.Tags.Add( ArchScene.GeneratedTag );

		var renderer = baked.Components.GetOrCreate<MeshComponent>();
		renderer.Mesh = target;
		renderer.Color = Color.White;
		renderer.SmoothingAngle = 0f;
		renderer.Collision = MeshComponent.CollisionType.Mesh;
		renderer.RebuildMesh();

		return sources.Count;
	}

	static bool HasDoorAncestor( GameObject node, GameObject stopAt )
	{
		for ( var current = node; current.IsValid() && current != stopAt; current = current.Parent )
		{
			if ( current.Components.Get<MapDoor>() is not null )
			{
				return true;
			}
		}

		return false;
	}
}