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