Editor/Porch/ArchPorch.cs

Editor utility for porch placement and management in the architecture editor. It computes claimed porch legs from a drag, decides whether a drag extends existing porches, creates and files new ArchPorchPart instances, furnishes steps/cover, computes footprints and bounds, and removes porches and their dependent layers.

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

namespace Sunless.Architecture;

public static class ArchPorch
{
	const float MinDepth = 18f;

	public sealed class Placement
	{
		public ArchPorchPart Porch { get; init; }
		public ArchRoofPart Roof { get; init; }
		public int Legs { get; init; }
		public bool Joined { get; init; }
	}

	readonly record struct Claim( ArchRoom Room, ArchPorchPart Porch );

	// Options ride a draft part, not an argument list - a new switch stays a field.
	public static Placement Attach(
		ArchPlan plan,
		ArchBuilding building,
		ArchRoom room,
		ArchKit kit,
		Vector2 min,
		Vector2 max,
		ArchPorchPart options )
	{
		if ( room is null || building is null )
		{
			return null;
		}

		var legs = Claimed( building, room, kit, min, max, options.Standing, out var standing );

		if ( legs.Count == 0 )
		{
			return null;
		}

		var joined = Joined( building, room, legs, standing );

		if ( joined.Count > 0 )
		{
			return Extend( plan, building, kit, joined, legs, options );
		}

		var kinds = ArchKinds.Load();

		var porch = new ArchPorchPart
		{
			Id = plan.AllocateId(),
			Name = $"Porch{plan.CountFiled( ArchKind.Porch, room, kinds ) + 1}",
			Legs = legs,
			Standing = standing,
			BaseHeight = room.BaseHeight,
			GradeHeight = room.BaseHeight - Drop( building, room, kit, options, plan ),
			HeadHeight = MathF.Max( 60f, options.HeadHeight ),
			PostSpacing = MathF.Max( 32f, kit.PorchPostSize * 8f ),
			Deck = options.Deck,
			Plinth = options.Plinth,
			Posts = options.Posts,
			Beam = options.Beam,
			Rafters = options.Rafters,
			Braces = options.Braces,
			Pitch = options.Pitch,
			Railings = options.Railings,
			Steps = options.Steps,
			Door = options.Door,
			DoorPreset = options.DoorPreset,
			Roofed = options.Roofed
		};

		plan.File( ArchKind.Porch, room, porch, kinds );

		Furnish( plan, building, room, kit, porch );

		return Result( building, porch, false );
	}

	// The children a placement owes the porch, through the same passes that keep them honest afterwards -
	// a second copy of "where do the steps go" is the defect this exists to prevent.
	static void Furnish( ArchPlan plan, ArchBuilding building, ArchRoom room, ArchKit kit, ArchPorchPart porch )
	{
		ArchPorchCover.Resolve( plan, building, room, kit, porch );

		if ( porch.Steps && porch.Stairs.Count == 0 )
		{
			ArchPorchSteps.Stand( plan, porch, ArchPorchShape.Resolve( porch, room, building, kit ), kit );
		}
	}

	// A drag touching a porch already there extends it, not a neighbour of it.
	static Placement Extend(
		ArchPlan plan,
		ArchBuilding building,
		ArchKit kit,
		List<Claim> joined,
		List<ArchPorchLeg> legs,
		ArchPorchPart options )
	{
		var host = joined[0];
		var kinds = ArchKinds.Load();

		foreach ( var claim in joined )
		{
			legs.AddRange( claim.Porch.Legs );

			if ( claim.Porch == host.Porch )
			{
				continue;
			}

			// The absorbed porch's own flights, columns and runs come with it - they stand on deck that is
			// now this porch's, and orphaning them would leave steps in mid air under nobody's row.
			host.Porch.Stairs.AddRange( claim.Porch.Stairs );
			host.Porch.Pillars.AddRange( claim.Porch.Pillars );
			host.Porch.Trims.AddRange( claim.Porch.Trims );

			plan.Unfile( claim.Porch, kinds );
			ArchPorchCover.Remove( plan, building, claim.Porch );
		}

		host.Porch.Legs = Combine( legs );
		host.Porch.HeadHeight = MathF.Max( 60f, options.HeadHeight );

		Furnish( plan, building, host.Room, kit, host.Porch );

		Log.Info( $"Architecture: that drag joined {host.Porch.Name} rather than standing a second deck beside it." );

		return Result( building, host.Porch, true );
	}

	// Absorbing one porch can pull the next within reach, so it sweeps until nothing joins.
	static List<Claim> Joined( ArchBuilding building, ArchRoom room, IReadOnlyList<ArchPorchLeg> legs, PorchStanding standing )
	{
		var kinds = ArchKinds.Load();

		var candidates = building.Rooms
			.Where( other => other.Floor == room.Floor && MathF.Abs( other.BaseHeight - room.BaseHeight ) < 1f )
			.SelectMany( other => ArchPlanStore.FiledOn( ArchKind.Porch, other, kinds )
				.OfType<ArchPorchPart>()
				.Select( porch => new Claim( other, porch ) ) )
			.Where( claim => claim.Porch.Standing == standing )
			.ToList();

		var claimed = new List<Claim>();
		var region = legs.ToList();
		var sweeping = true;

		while ( sweeping )
		{
			sweeping = false;

			foreach ( var candidate in candidates )
			{
				if ( claimed.Contains( candidate ) || !Continues( region, candidate.Porch ) )
				{
					continue;
				}

				claimed.Add( candidate );
				region.AddRange( candidate.Porch.Legs );
				sweeping = true;
			}
		}

		return claimed;
	}

	static bool Continues( IEnumerable<ArchPorchLeg> legs, ArchPorchPart porch )
	{
		var rects = legs.Concat( porch.Legs ).Select( leg => ArchFootprint.Rect( leg.Min, leg.Max ) );

		return ArchFootprint.Union( rects ).Count == 1;
	}

	// Back to rectangles, so an extended porch matches one drawn in a single drag.
	static List<ArchPorchLeg> Combine( IEnumerable<ArchPorchLeg> legs )
	{
		var region = ArchFootprint.Union( legs.Select( leg => ArchFootprint.Rect( leg.Min, leg.Max ) ) );

		return ArchFootprint.Cells( region, null )
			.Select( cell => new ArchPorchLeg { Min = cell.Min, Max = cell.Max } )
			.ToList();
	}

	static Placement Result( ArchBuilding building, ArchPorchPart porch, bool joined )
	{
		return new Placement
		{
			Porch = porch,
			Roof = building.Roofs.FirstOrDefault( part => part.Id == porch.RoofId ),
			Legs = porch.Legs.Count,
			Joined = joined
		};
	}

	// What a drag would claim without touching the plan.
	public static List<ArchPorchLeg> Preview(
		ArchBuilding building,
		ArchRoom room,
		ArchKit kit,
		Vector2 min,
		Vector2 max,
		PorchStanding wanted,
		out PorchStanding standing,
		out ArchPorchPart joining )
	{
		joining = null;
		standing = wanted;

		if ( building is null || room is null )
		{
			return new List<ArchPorchLeg>();
		}

		var legs = Claimed( building, room, kit, min, max, wanted, out standing );

		if ( legs.Count == 0 )
		{
			return legs;
		}

		var joined = Joined( building, room, legs, standing );

		if ( joined.Count == 0 )
		{
			return legs;
		}

		joining = joined[0].Porch;

		return Combine( joined.SelectMany( claim => claim.Porch.Legs ).Concat( legs ) );
	}

	// A deck against a house is the shell subtracted out of the drag; a free one IS the drag. Asked for
	// Against, a drag with no apron left in it falls to Free rather than being refused - which is what makes
	// the same gesture work inside a room, where there is no outside for the subtraction to leave.
	static List<ArchPorchLeg> Claimed(
		ArchBuilding building,
		ArchRoom room,
		ArchKit kit,
		Vector2 min,
		Vector2 max,
		PorchStanding wanted,
		out PorchStanding standing )
	{
		standing = wanted;

		if ( wanted == PorchStanding.Against )
		{
			var apron = Legs( building, room, kit, min, max );

			if ( apron.Count > 0 )
			{
				return apron;
			}

			standing = PorchStanding.Free;
		}

		return new List<ArchPorchLeg> { new() { Min = min, Max = max } };
	}

	// One subtraction is the whole wrap-around - the corner square belongs to no wall.
	static List<ArchPorchLeg> Legs( ArchBuilding building, ArchRoom room, ArchKit kit, Vector2 min, Vector2 max )
	{
		var shell = Shell( building, room, kit );

		if ( shell.Count == 0 )
		{
			return new List<ArchPorchLeg>();
		}

		var apron = ArchRegion.Minus( ArchFootprint.Rect( min, max ), shell );

		// Shrink the whole apron, not each cell - per-cell punched holes in a real deck.
		if ( ArchFootprint.Shrink( apron, MinDepth * 0.5f ).Count == 0 )
		{
			return new List<ArchPorchLeg>();
		}

		return ArchFootprint.Cells( apron, null )
			.Select( cell => new ArchPorchLeg { Min = cell.Min, Max = cell.Max } )
			.ToList();
	}

	// The shell is the whole storey, not the room - wrap-arounds turn where wings meet.
	public static List<List<Vector2>> Shell( ArchBuilding building, ArchRoom room, ArchKit kit )
	{
		return ArchRegion.Shell( ArchRegion.Storey( building, room.Floor ), Thickness( building, kit ) );
	}

	static float Thickness( ArchBuilding building, ArchKit kit )
	{
		var walls = building.Rooms.SelectMany( room => room.Walls ).Where( wall => wall.Thickness > 0f ).ToList();

		return walls.Count > 0 ? walls.Max( wall => wall.Thickness ) : kit.WallThickness;
	}

	// A deck stops short of grade where a canopy goes all the way to it, but BOTH are bounded by the same
	// number: how high the floor actually stands, which is its own datum plus the plinth under it. Bounding
	// the deck by kit.GroundClearance instead measured it against the two-unit lift that keeps a slab off the
	// terrain, so a house on a full foundation still got a deck two inches down - and no flight ever fits
	// under two inches, which is most of why a porch appeared to have no steps at all.
	static float Drop( ArchBuilding building, ArchRoom room, ArchKit kit, ArchPorchPart options, ArchPlan plan )
	{
		var standing = Grade( building, room, kit, plan );

		return options.Deck
			? ArchClearance.Fits( options.DeckDrop, standing, 0f, "porch deck drop", "the room's floor only stands that far above grade" )
			: standing;
	}

	// The plinth is a building transform, so it is added back here.
	static float Grade( ArchBuilding building, ArchRoom room, ArchKit kit, ArchPlan plan )
	{
		return room.BaseHeight + ArchAsks.Lift( plan, building, kit );
	}

	public static List<Vector2> Footprint( ArchPorchPart porch )
	{
		var rects = porch.Legs
			.Where( leg => leg.Max.x - leg.Min.x > 1f && leg.Max.y - leg.Min.y > 1f )
			.Select( leg => ArchFootprint.Rect( leg.Min, leg.Max ) )
			.ToList();

		if ( rects.Count == 0 )
		{
			return new List<Vector2>();
		}

		var merged = ArchFootprint.Union( rects );

		return merged.Count == 1 ? ArchFootprint.Wind( merged[0] ) : ArchFootprint.Wind( rects[0] );
	}

	public static void Bounds( ArchPorchPart porch, out Vector2 min, out Vector2 max )
	{
		min = new Vector2( float.MaxValue, float.MaxValue );
		max = new Vector2( float.MinValue, float.MinValue );

		foreach ( var leg in porch.Legs )
		{
			min = Vector2.Min( min, leg.Min );
			max = Vector2.Max( max, leg.Max );
		}

		if ( porch.Legs.Count == 0 )
		{
			min = max = Vector2.Zero;
		}
	}

	// Deleting the porch takes its roof, its door and every child layer standing on its deck.
	public static void Remove( ArchPlan plan, ArchBuilding building, ArchRoom room, ArchPorchPart porch )
	{
		plan.Unfile( porch );

		if ( building is null )
		{
			return;
		}

		ArchPorchAffector.Close( building, porch );
		ArchPorchCover.Remove( plan, building, porch );
	}
}