Editor-side utility for generating and reconciling a hierarchy of GameObjects that represent procedural architecture. It builds or reconciles a generated root, walks and indexes existing nodes, applies meshes and collision, creates doors and glass panes, prunes unneeded objects, and names/generated object helpers.
using System;
using System.Collections.Generic;
using System.Linq;
using MapDoor = Sandbox.Mapping.Door;
using Sandbox;
namespace Sunless.Architecture;
public static class ArchScene
{
public const string RootName = "Generated Architecture";
public const string GeneratedTag = "arch_generated";
public static GameObject FindRoot( Scene scene )
{
if ( scene is null )
{
return null;
}
return scene.Children.FirstOrDefault( child => child.Name == RootName );
}
public static List<GameObject> FindRoots( Scene scene )
{
return scene?.Children.Where( child => child.Name == RootName ).ToList() ?? new List<GameObject>();
}
public static void Clear( Scene scene )
{
foreach ( var root in FindRoots( scene ) )
{
root.DestroyImmediate();
}
}
// A cache means the scene is RECONCILED rather than rebuilt: the nodes whose geometry did not move keep the
// model they already have, and only what actually changed is handed back to the engine. Without one this is
// the cold build it always was - destroy everything, emit everything - which is what a designer stage, a
// preview and a test all want.
public static GameObject Generate( Scene scene, ArchPlan plan, ArchKit kit, ArchGenerationServices services = null,
ArchLayerTree layers = null, ArchBuildCache cache = null, ArchDoorFitters fitters = null )
{
if ( scene is null || plan is null )
{
return null;
}
fitters ??= ArchDoorFitters.Load();
if ( cache is null )
{
Clear( scene );
}
var root = FindRoot( scene );
if ( !root.IsValid() )
{
root = scene.CreateObject();
root.Name = RootName;
root.Tags.Add( GeneratedTag );
}
var gate = cache?.Gate( plan, kit, ArchTerrain.Stamped );
var build = Built( scene, plan, kit, services, layers, gate );
// Settled BEFORE the cache is asked anything: the answer is folded into every part's key, so a layer's mode
// changing re-cooks the children that were following it as well as the layer itself.
foreach ( var part in build.Parts )
{
part.Resolved = ArchCollision.Resolve( part, kit, layers );
}
var settled = ArchBuildCache.Settle( build, cache, gate );
// The gate hid half of a coplanar pair from the contact pass. It has been refused, so this build is the
// cold one - and the refusal is remembered, so it cannot happen twice for the same scope.
if ( settled.Escaped )
{
Clear( scene );
cache.Restart();
return Generate( scene, plan, kit, services, layers, cache, fitters );
}
var nodes = Indexed( root );
// An unwritten part's canvas was never finished or contact-tested, so it cannot be handed to the engine. If its
// node has gone missing under us - the menu's clear, a hand-delete - the only honest answer is a cold build.
if ( Vanished( nodes, build, settled ) )
{
Clear( scene );
cache.Restart();
return Generate( scene, plan, kit, services, layers, cache, fitters );
}
var live = Living( settled.Kept );
for ( var index = 0; index < build.Parts.Count; index++ )
{
var part = build.Parts[index];
var node = EnsurePath( root, nodes, part.Path, live );
if ( !settled.Write.Contains( index ) )
{
Claim( live, part );
continue;
}
if ( node.LocalTransform != part.NodeTransform )
{
node.LocalTransform = part.NodeTransform;
}
Apply( node, part.Canvas, part.Resolved );
foreach ( var request in part.Doors )
{
BuildDoor( node, request, fitters );
}
// A pane is a flat slab, so one hull IS its exact shape - and it keeps the collider a breakable pane has
// to be hit on.
foreach ( var request in part.Glass )
{
var pane = Child( node, ArchNames.Glass( request.Opening ) );
pane.Tags.Add( "glass" );
Apply( pane, request.Canvas, ArchCollisionMode.Convex );
}
Claim( live, part );
}
foreach ( var rootTransform in build.RootTransforms )
{
var node = EnsurePath( root, nodes, rootTransform.Key, live );
if ( node.LocalTransform != rootTransform.Value )
{
node.LocalTransform = rootTransform.Value;
}
}
foreach ( var child in root.Children.ToList() )
{
Prune( child, child.Name, live );
}
return root;
}
static ArchArchitectureBuild Built( Scene scene, ArchPlan plan, ArchKit kit, ArchGenerationServices services,
ArchLayerTree layers, ArchBuildGate gate )
{
// The layer gate is open for the whole build, not handed down it: a disabled opening has to stop cutting
// inside the wall generator, far below anything that could carry a tree.
using ( ArchLayerGate.Begin( layers ) )
{
var builder = new ArchArchitectureBuilder( plan, kit, scene, services ).Through( gate ).WithConnections();
// Opened only now: resolving connections normalizes the plan, and an answer held across that is stale.
using ( ArchBuildMemo.Begin( plan ) )
{
return builder.WithBuildings().WithRoads().Create();
}
}
}
// The standing tree, walked ONCE. Resolving each part's path by scanning its parent's children is quadratic in
// the parts filed under one room, and a rebuild resolves every path in the plan.
static Dictionary<string, GameObject> Indexed( GameObject root )
{
var nodes = new Dictionary<string, GameObject>();
void Walk( GameObject node, string path )
{
nodes[path] = node;
foreach ( var child in node.Children )
{
Walk( child, path.Length == 0 ? child.Name : $"{path}/{child.Name}" );
}
}
foreach ( var child in root.Children )
{
Walk( child, child.Name );
}
return nodes;
}
// A kept path keeps its ancestors with it, or the prune reaches it through a parent it has already destroyed.
static HashSet<string> Living( IReadOnlySet<string> kept )
{
var live = new HashSet<string>();
foreach ( var path in kept )
{
var walked = "";
foreach ( var name in path.Split( '/', StringSplitOptions.RemoveEmptyEntries ) )
{
walked = walked.Length == 0 ? name : $"{walked}/{name}";
live.Add( walked );
}
}
return live;
}
// Whether anything the cache says is standing has actually gone. Checked BEFORE a single node is written, because
// the answer is all-or-nothing: half a reconcile over a scene somebody emptied is worse than rebuilding it.
static bool Vanished( Dictionary<string, GameObject> nodes, ArchArchitectureBuild build, ArchBuildSettlement settled )
{
for ( var index = 0; index < build.Parts.Count; index++ )
{
if ( !settled.Write.Contains( index ) && !Standing( nodes, build.Parts[index].Path ) )
{
return true;
}
}
return settled.Kept.Any( path => !Standing( nodes, path ) );
}
// A reused node has to actually BE there, with a model on it. The generated root can be deleted from under the
// cache - by the menu action, by a hand-delete in the hierarchy - and a key that says "unchanged" about
// something that is gone is the one way this could leave a hole in the scene.
static bool Standing( Dictionary<string, GameObject> nodes, string path )
{
return nodes.TryGetValue( path, out var node ) && node.IsValid() && !node.IsDestroyed
&& node.Components.Get<MeshComponent>( FindMode.EverythingInSelf ) is { Mesh: not null };
}
// The part's own path, its ancestors, and the door and pane names hanging off it - everything the prune must
// leave alone. Anything generated and unclaimed belonged to a part that is no longer emitted.
static void Claim( HashSet<string> live, ArchBuiltPart part )
{
foreach ( var request in part.Doors )
{
live.Add( $"{part.Path}/{ArchNames.Door( request.Opening )}" );
}
foreach ( var request in part.Glass )
{
live.Add( $"{part.Path}/{ArchNames.Glass( request.Opening )}" );
}
}
static GameObject EnsurePath( GameObject root, Dictionary<string, GameObject> nodes, string path, HashSet<string> live )
{
var node = root;
var walked = "";
foreach ( var name in path.Split( '/', StringSplitOptions.RemoveEmptyEntries ) )
{
walked = walked.Length == 0 ? name : $"{walked}/{name}";
live.Add( walked );
if ( nodes.TryGetValue( walked, out var held ) && held.IsValid() && !held.IsDestroyed )
{
node = held;
continue;
}
node = nodes[walked] = Child( node, name );
}
return node;
}
// Unclaimed means the part that made it is no longer emitted, so it goes whatever it holds - children first,
// and a live path carries every one of its ancestors, so nothing live is ever reached through a dead parent.
static void Prune( GameObject node, string path, HashSet<string> live )
{
foreach ( var child in node.Children.ToList() )
{
Prune( child, $"{path}/{child.Name}", live );
}
if ( !live.Contains( path ) )
{
node.DestroyImmediate();
}
}
// Pre-order walk including the root itself - shared by target resolution and shot isolation.
public static IEnumerable<GameObject> Descendants( GameObject root )
{
if ( !root.IsValid() || root.IsDestroyed )
{
yield break;
}
yield return root;
foreach ( var child in root.Children )
{
foreach ( var nested in Descendants( child ) )
{
yield return nested;
}
}
}
public static void LinkDoublePairs( GameObject root )
{
var doors = root.Components.GetAll<MapDoor>( FindMode.EverythingInSelfAndDescendants ).ToList();
foreach ( var group in doors.GroupBy( door => door.GameObject.Name ) )
{
var pair = group.ToList();
if ( pair.Count != 2 )
{
continue;
}
pair[0].LinkedDoor = pair[1];
pair[1].LinkedDoor = pair[0];
}
}
static void BuildDoor( GameObject parent, ArchDoorRequest request, ArchDoorFitters fitters )
{
var node = Child( parent, ArchNames.Door( request.Opening ) );
node.LocalTransform = new Transform( request.HingeLocal, request.Rotation );
var canvas = new ArchMesh( node.LocalTransform );
var half = request.LeafThickness * 0.5f;
var width = request.Mirrored ? -request.LeafWidth : request.LeafWidth;
canvas.Box(
new Vector3( MathF.Min( 0f, width ), -half, 0f ),
new Vector3( MathF.Max( 0f, width ), half, request.LeafHeight ),
request.Brush );
// A leaf is one box, so its hull is its exact shape - and the collider stays on the mesh component, where the
// door the engine drives expects to find it.
Apply( node, canvas, ArchCollisionMode.Convex );
var door = node.Components.GetOrCreate<MapDoor>();
door.Speed = request.Opening.Kind == OpeningKind.Garage ? 90f : 220f;
door.OpenAwayFromPlayer = true;
door.IsUsable = true;
door.StartOpen = request.Opening.StartOpen;
if ( request.Opening.Kind == OpeningKind.Garage )
{
door.Mode = MapDoor.DoorMode.Sliding;
door.SlideOffset = Vector3.Up * (request.LeafHeight - 2f);
}
else
{
door.Mode = MapDoor.DoorMode.Rotating;
door.TargetAngle = request.Mirrored ? -95f : 95f;
}
fitters.Fit( node, door );
}
// By name, not blindly: a reconciled build rewrites the part that changed, and a second Door_7 beside the
// first is what creating one outright leaves behind.
static GameObject Child( GameObject parent, string name )
{
if ( parent.Children.FirstOrDefault( child => child.Name == name ) is { } standing )
{
return standing;
}
var created = parent.Scene.CreateObject();
created.Name = name;
created.SetParent( parent, false );
created.Tags.Add( GeneratedTag );
return created;
}
// Tint, smoothing and COLLISION first, so assigning the mesh is the one thing that triggers a rebuild - and that
// rebuild cooks a collision hull, a physics mesh and a trace mesh and uploads two buffers.
static void Apply( GameObject node, ArchMesh canvas, ArchCollisionMode collision )
{
if ( canvas is null || canvas.IsEmpty )
{
return;
}
var renderer = node.Components.GetOrCreate<MeshComponent>();
renderer.Color = Color.White;
renderer.SmoothingAngle = 0f;
ArchCollision.Write( node, renderer, canvas, collision );
renderer.Mesh = canvas.Finish();
}
}
public static class ArchNames
{
public static string Building( ArchBuilding building ) => $"Building_{building.Id}_{building.Name}";
// Every generated name carries its source layer's plan id as its second field, which is how the
// editor maps output back to the layer that authored it. A storey is numbered by level, not by
// id, so it is the one name this cannot read.
public static bool TrySourceId( string name, out int id )
{
id = 0;
if ( name is null || name.StartsWith( "Floor_", StringComparison.Ordinal ) )
{
return false;
}
var first = name.IndexOf( '_' );
if ( first < 0 )
{
return false;
}
var second = name.IndexOf( '_', first + 1 );
var field = second < 0 ? name[(first + 1)..] : name[(first + 1)..second];
return int.TryParse( field, out id );
}
public static string Room( ArchRoom room ) => $"Room_{room.Id}_{room.Name}";
public static string Floor( ArchFloorGroup group ) => $"Floor_{group.Level}_{group.Lead.Name}";
public static string Wall( ArchWall wall ) => $"Wall_{wall.Id}";
public static string Door( ArchOpening opening ) => $"Door_{opening.Id}";
public static string Glass( ArchOpening opening ) => $"Glass_{opening.Id}";
public static string Roof( ArchRoofPart roof ) => $"Roof_{roof.Id}_{roof.Name}";
public static string Stair( ArchStairPart stair ) => $"Stair_{stair.Id}_{stair.Name}";
public static string Trim( ArchTrimPart trim ) => $"Trim_{trim.Id}_{trim.Name}";
public static string Pillar( ArchPillarPart pillar ) => $"Pillar_{pillar.Id}_{pillar.Name}";
public static string Span( ArchSpanPart span ) => $"Span_{span.Id}_{span.Name}";
public static string Beam( ArchBeamPart beam ) => $"Beam_{beam.Id}_{beam.Name}";
public static string Downpipe( ArchDownpipePart pipe ) => $"Downpipe_{pipe.Id}_{pipe.Name}";
public static string Pipe( ArchPipePart run ) => $"PipeRun_{run.Id}_{run.Name}";
public static string Bracket( ArchPipeBracketPart bracket ) => $"Brackets_{bracket.Id}_{bracket.Name}";
public static string Ladder( ArchLadderPart ladder ) => $"Ladder_{ladder.Id}_{ladder.Name}";
public static string Balcony( ArchBalconyPart balcony ) => $"Balcony_{balcony.Id}_{balcony.Name}";
public static string ExteriorStair( ArchExteriorStairPart flight ) => $"Escape_{flight.Id}_{flight.Name}";
public static string Porch( ArchPorchPart porch ) => $"Porch_{porch.Id}_{porch.Name}";
public static string Fence( ArchFencePart fence ) => $"Fence_{fence.Id}_{fence.Name}";
public static string Platform( ArchPlatformPart platform ) => $"Platform_{platform.Id}_{platform.Name}";
public static string Cut( ArchCutPart cut ) => $"Cut_{cut.Id}_{cut.Name}";
public static string Approach( ArchApproachPart approach ) => $"Approach_{approach.Id}_{approach.Name}";
public static string Road( ArchRoadPart road ) => $"Road_{road.Id}_{road.Name}";
public static string Bridge( ArchBridgePart bridge ) => $"Bridge_{bridge.Id}_{bridge.Name}";
public static string Tunnel( ArchTunnelPart tunnel ) => $"Tunnel_{tunnel.Id}_{tunnel.Name}";
public static bool TryParseId( string name, string prefix, out int id )
{
id = 0;
if ( string.IsNullOrEmpty( name ) || !name.StartsWith( prefix + "_", StringComparison.Ordinal ) )
{
return false;
}
var rest = name[(prefix.Length + 1)..];
var end = rest.IndexOf( '_' );
var token = end < 0 ? rest : rest[..end];
return int.TryParse( token, out id );
}
}