Editor/Output/ArchCull.Interiors.cs

Editor-side code for generating and placing 'false interiors' behind façade openings. It finds unenterable buildings, computes shallow boxes behind openings, builds mesh quads for those boxes and creates scene GameObjects with mesh renderers and collision disabled.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// One shallow room behind one opening on a facade nobody can walk into. DERIVED, so it carries no id and no
// layer record - exactly as slabs, ceilings, roof decks and platforms carry none.
public sealed class ArchFalseInterior
{
	public ArchBuilding Building { get; init; }
	public ArchRoom Room { get; init; }
	public ArchWall Wall { get; init; }
	public ArchOpening Opening { get; init; }
	// The room face of the wall, in the wall's own frame - the plane the box is measured off.
	public float Face { get; init; }
	public float Depth { get; init; }

	public float Back => Face + Depth;
}

public static partial class ArchCull
{
	// No way in and nothing above, asked of a whole unit rather than one of its roofs: a shell with no door
	// and no archway, capped everywhere, is a facade. Anything else is a room, or becoming one.
	public static bool Unenterable( ArchBuilding building, ArchPlan plan, ArchKit kit )
	{
		if ( building is null )
		{
			return false;
		}

		var walls = building.Rooms.SelectMany( room => room.Walls ).ToList();

		if ( walls.Count == 0 )
		{
			return false;
		}

		// A single-sided shell emits no room face at all, so there is nothing in there to be in.
		if ( walls.All( wall => wall.SingleSided ) )
		{
			return true;
		}

		if ( walls.SelectMany( wall => wall.Openings ).Any( Entered ) )
		{
			return false;
		}

		return building.Roofs.Count > 0 && building.Roofs.All( roof => Sealed( roof, building, plan, kit ) );
	}

	// A leaf hangs in a way in and an archway is a hole walked straight through. A window is neither.
	static bool Entered( ArchOpening opening )
	{
		return opening.Kind.Hangs() || opening.Kind == OpeningKind.Archway;
	}

	// Every box the plan asks for, resolved without a scene, so the pass, the report and the test all read one
	// answer. A unit ArchCull calls enterable contributes none: behind a real room the box would z-fight the
	// room it is standing inside.
	public static List<ArchFalseInterior> Behind( ArchPlan plan, ArchKit kit )
	{
		var found = new List<ArchFalseInterior>();

		if ( plan is null )
		{
			return found;
		}

		var depth = MathF.Max( 1f, kit.FalseInteriorDepth );

		foreach ( var building in plan.Buildings.Where( unit => Unenterable( unit, plan, kit ) ) )
		{
			foreach ( var room in building.Rooms )
			{
				foreach ( var wall in room.Walls.Where( wall => !ArchWallJoins.CoveredBy( building, room, wall ) ) )
				{
					var face = ArchWallSection.Thickness( wall, kit ) * 0.5f;

					found.AddRange( Showing( wall ).Select( opening => new ArchFalseInterior
					{
						Building = building,
						Room = room,
						Wall = wall,
						Opening = opening,
						Face = face,
						Depth = depth
					} ) );
				}
			}
		}

		return found;
	}

	// The units the wall generator actually cut a hole for - the same filter it runs, because a box behind a
	// hole nothing opened is a box hanging in a solid wall.
	static IEnumerable<ArchOpening> Showing( ArchWall wall )
	{
		return wall.Openings
			.Where( opening => opening.Width > 0.5f && opening.Height > 0.5f )
			.Where( opening => ArchLayerGate.On( opening ) && ArchLayerGate.Owned( opening.OwnerId ) )
			.Where( opening => opening.SwallowedBy == 0 || !ArchLayerGate.Owned( opening.SwallowedBy ) );
	}

	// The box, in the wall's own frame, wound to face the hole. Its front IS the hole: a face on the wall's
	// room plane would be the sticker this exists to remove.
	public static void Line( ArchMesh canvas, ArchFalseInterior interior, ArchBrush brush )
	{
		var opening = interior.Opening;
		var left = opening.Left;
		var right = opening.Right;
		var bottom = MathF.Max( 0f, opening.SillHeight );
		var top = opening.Top;
		var face = interior.Face;
		var back = interior.Back;

		if ( right - left < 0.5f || top - bottom < 0.5f || interior.Depth < 0.5f )
		{
			return;
		}

		canvas.Quad(
			new Vector3( left, back, bottom ),
			new Vector3( right, back, bottom ),
			new Vector3( right, back, top ),
			new Vector3( left, back, top ),
			brush );

		canvas.Quad(
			new Vector3( left, face, bottom ),
			new Vector3( left, back, bottom ),
			new Vector3( left, back, top ),
			new Vector3( left, face, top ),
			brush );

		canvas.Quad(
			new Vector3( right, back, bottom ),
			new Vector3( right, face, bottom ),
			new Vector3( right, face, top ),
			new Vector3( right, back, top ),
			brush );

		canvas.Quad(
			new Vector3( left, face, top ),
			new Vector3( left, back, top ),
			new Vector3( right, back, top ),
			new Vector3( right, face, top ),
			brush );

		canvas.Quad(
			new Vector3( left, back, bottom ),
			new Vector3( left, face, bottom ),
			new Vector3( right, face, bottom ),
			new Vector3( right, back, bottom ),
			brush );
	}

	// The pass on its own, for when the faces have already been cleaned. A rebuild prunes what it emits, the
	// same way it takes back the faces Clean removed.
	public static int Interiors( Scene scene, ArchPlan plan, ArchKit kit )
	{
		var root = ArchScene.FindRoot( scene );

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

			return 0;
		}

		using ( SceneEditorSession.Active.UndoScope( "Line False Interiors" ).WithGameObjectChanges( root, GameObjectUndoFlags.All ).Push() )
		{
			return Lined( root, plan, kit );
		}
	}

	// Stood from nothing every run: a door added to a facade makes it a room, and the box behind its window
	// has to GO rather than be left standing inside it.
	static int Lined( GameObject root, ArchPlan plan, ArchKit kit )
	{
		var nodes = Walls( root );
		var style = new ArchStyle( kit );
		var lined = 0;

		foreach ( var wall in nodes.Values )
		{
			if ( wall.Children.FirstOrDefault( child => child.Name == ArchPieces.Interior ) is { } standing )
			{
				standing.DestroyImmediate();
			}
		}

		foreach ( var group in Behind( plan, kit ).GroupBy( interior => interior.Wall.Id ) )
		{
			if ( !nodes.TryGetValue( group.Key, out var node ) )
			{
				continue;
			}

			// The wall's own frame, so the boxes are drawn in the coordinates the wall generator uses and the
			// projection puts them on the same grain as the room face they hang behind.
			var canvas = new ArchMesh( node.WorldTransform );

			foreach ( var interior in group )
			{
				Line( canvas, interior, style.Brush(
					ArchSurface.WallInterior, interior.Wall.Palette, interior.Room.Palette, interior.Building.Palette ) );

				lined++;
			}

			Fit( node, canvas );
		}

		return lined;
	}

	static Dictionary<int, GameObject> Walls( GameObject root )
	{
		var nodes = new Dictionary<int, GameObject>();

		foreach ( var node in ArchScene.Descendants( root ) )
		{
			if ( ArchNames.TryParseId( node.Name, "Wall", out var id ) )
			{
				nodes[id] = node;
			}
		}

		return nodes;
	}

	static void Fit( GameObject wall, ArchMesh canvas )
	{
		if ( canvas.IsEmpty )
		{
			return;
		}

		var node = wall.Scene.CreateObject();

		node.Name = ArchPieces.Interior;
		node.SetParent( wall, false );
		node.Tags.Add( ArchScene.GeneratedTag );

		var renderer = node.Components.GetOrCreate<MeshComponent>();

		renderer.Color = Color.White;
		renderer.SmoothingAngle = 0f;

		// It must not be traceable: the cleaning pass reads cover by ray, and a box behind a window it could
		// hit would have the elevation in front of it stripped as buried.
		ArchCollision.Write( node, renderer, canvas, ArchCollisionMode.None );

		renderer.Mesh = canvas.Finish();
	}
}