Editor/Output/ArchBake.cs
using System.Collections.Generic;
using System.Linq;
using Editor;
using MapDoor = Sandbox.Mapping.Door;
using Sandbox;

namespace Sunless.Architecture;

// A BAKE STANDS BESIDE WHAT IT MERGED, never on top of it. Both copies are kept and one of them is switched off, so
// the two can be measured against each other in the same scene and the merge can be dropped without a rebuild.
//
// The switch is the MeshComponent's, not the GameObject's: a door hangs off the wall it is cut into, and disabling
// the wall's object would take the door with it.
public static class ArchBake {
	public const string BakedTag = "arch_baked";

	// What a merged part carries afterwards. It is what tells the toggle which meshes the bake speaks for - deriving
	// it again from the bucket rules would drift the moment those rules change.
	public const string SourceTag = "arch_baked_source";

	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() ) {
			Unbake( root );

			foreach ( var room in Rooms( root ).ToList() ) {
				merged += MergeGroup( room );
			}
		}

		return merged;
	}

	// Which of the two copies renders. Asked with `baked` false whenever no merged copy is standing, which is what
	// hands the sources back after a rebuild has pruned one - the tag outlives the merge that wrote it, because the
	// node carrying it is reused by path and a build never clears it.
	public static void Show( GameObject root, bool baked ) {
		if ( !root.IsValid() ) {
			return;
		}

		foreach ( var mesh in root.Components.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants ) ) {
			if ( IsBaked( mesh.GameObject ) ) {
				mesh.Enabled = baked;
			} else if ( mesh.GameObject.Tags.Has( SourceTag ) ) {
				mesh.Enabled = !baked;
			}
		}
	}

	// Drops the merged copies and hands every source back, without going near the plan - so a bake can be thrown
	// away and re-taken without the cold rebuild that a plan-side answer would need.
	public static int Unbake( GameObject root ) {
		if ( !root.IsValid() ) {
			return 0;
		}

		var dropped = 0;

		foreach ( var mesh in root.Components.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants ).ToList() ) {
			if ( IsBaked( mesh.GameObject ) ) {
				mesh.GameObject.Destroy();
				dropped++;

				continue;
			}

			if ( mesh.GameObject.Tags.Has( SourceTag ) ) {
				mesh.GameObject.Tags.Remove( SourceTag );
				mesh.Enabled = true;
			}
		}

		return dropped;
	}

	// A baked node is always a direct child of the group it merged, so this stops three ranks down rather than
	// walking a whole building to answer "is there anything to toggle".
	public static bool Standing( GameObject root ) {
		if ( !root.IsValid() ) {
			return false;
		}

		foreach ( var building in root.Children ) {
			foreach ( var group in building.Children ) {
				foreach ( var child in group.Children ) {
					if ( IsBaked( child ) ) {
						return true;
					}
				}
			}
		}

		return false;
	}

	// The mesh only, never the object: a door hangs off the wall it was cut into, and the wall's node is also the
	// door's parent. What is left behind is an empty node carrying whatever a merge could not speak for.
	static int StripSources( GameObject root ) {
		var stripped = 0;

		foreach ( var mesh in root.Components.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants ).ToList() ) {
			if ( !mesh.GameObject.Tags.Has( SourceTag ) ) {
				continue;
			}

			mesh.GameObject.Tags.Remove( SourceTag );
			mesh.Destroy();
			stripped++;
		}

		return stripped;
	}

	static bool IsBaked( GameObject node ) => node.Tags.Has( BakedTag );

	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 );

		// A map wants the merged copy and nothing else. The switched-off sources are what the A/B toggle swaps to,
		// which is an editing convenience - shipping them would write every wall into the file twice.
		using ( SceneEditorSession.Active.UndoScope( "Strip Baked Sources" ).WithGameObjectChanges( root, GameObjectUndoFlags.All ).Push() ) {
			StripSources( root );
		}

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

	// A merge is only allowed to change how many OBJECTS the renderer walks, never what the author said about any of
	// them - so sources are bucketed by the pair they were built with and each bucket bakes into its own node.
	readonly record struct ArchBakeBucket( MeshComponent.CollisionType Collision, ModelRenderer.ShadowRenderType Shadows );

	static int MergeGroup( GameObject group ) {
		var merged = 0;

		foreach ( var bucket in Buckets( group ) ) {
			merged += MergeBucket( group, bucket.Key, bucket.Value );
		}

		return merged;
	}

	static Dictionary<ArchBakeBucket, List<MeshComponent>> Buckets( GameObject group ) {
		var buckets = new Dictionary<ArchBakeBucket, List<MeshComponent>>();

		var sources = group.Components
			.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants )
			.Where( mesh => mesh.Mesh is not null )
			.Where( mesh => !IsBaked( mesh.GameObject ) )
			.Where( mesh => !HasDoorAncestor( mesh.GameObject, group ) )
			.Where( mesh => !CarriesSolids( mesh.GameObject ) );

		foreach ( var source in sources ) {
			var key = new ArchBakeBucket( source.Collision, source.RenderType );

			if ( !buckets.TryGetValue( key, out var filed ) ) {
				filed = new List<MeshComponent>();
				buckets[key] = filed;
			}

			filed.Add( source );
		}

		return buckets;
	}

	// A part collided as Solids carries its shapes as HullColliders on its own node, and a hull has a centre but no
	// rotation - so a merged copy of a turned column cannot be authored at all. Left standing, it keeps both its
	// collision and its own draw.
	static bool CarriesSolids( GameObject node ) {
		return node.Components.GetAll<HullCollider>( FindMode.EverythingInSelf ).Any();
	}

	static int MergeBucket( GameObject group, ArchBakeBucket bucket, List<MeshComponent> sources ) {
		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 );

		foreach ( var source in sources ) {
			source.GameObject.Tags.Add( SourceTag );
			source.Enabled = false;
		}

		var baked = group.Scene.CreateObject();
		baked.Name = Named( bucket );
		baked.SetParent( group, false );
		baked.Tags.Add( ArchScene.GeneratedTag );
		baked.Tags.Add( BakedTag );

		// Set on the baked node and never on the root: a door hanging off the same root moves, and IsStatic reads
		// through ancestors, so one flag up there would freeze every one of them.
		baked.IsStatic = true;

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

		return sources.Count;
	}

	static string Named( ArchBakeBucket bucket ) {
		var shadows = bucket.Shadows == ModelRenderer.ShadowRenderType.Off ? "_Unlit" : "";

		return $"Baked_{bucket.Collision}{shadows}";
	}

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