Editor/Output/ArchScene.cs

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.

Native InteropFile Access
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 );
	}
}