Editor/Porch/ArchPorchShape.cs

Editor class that resolves porch geometry for architecture tooling. It computes the porch footprint, open runs, perimeter walls, and dimensional properties like grade, deck height, head clearance and ceiling based on porch legs, room, building and kit data.

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

namespace Sunless.Architecture;

// House-facing pieces read Footprint; garden-facing pieces walk Open - no special case at the corner.
public sealed class ArchPorchShape
{
	public List<Vector2> Footprint { get; init; }
	// Runs broken wherever the perimeter meets the house.
	public List<ArchRunPath> Open { get; init; }
	public List<ArchPorchWall> Walls { get; init; }

	public float Grade { get; init; }
	public float Deck { get; init; }
	public float Head { get; init; }
	public float Ceiling { get; init; }

	public bool IsUsable => Footprint is { Count: >= 4 } && Open.Count > 0;

	public float Reach => ArchBays.Reach( Open );

	// Depth is what the roof rises over - the flashing here has to know it.
	public readonly struct ArchPorchWall
	{
		public Vector2 From { get; init; }
		public Vector2 To { get; init; }
		public Vector2 Outward { get; init; }
		public float Depth { get; init; }
	}

	public static ArchPorchShape Resolve( ArchPorchPart porch, ArchRoom room, ArchBuilding building, ArchKit kit )
	{
		var legs = porch.Legs?.Where( leg => leg.Max.x - leg.Min.x > 1f && leg.Max.y - leg.Min.y > 1f ).ToList();

		if ( legs is null || legs.Count == 0 )
		{
			return new ArchPorchShape { Footprint = new List<Vector2>(), Open = new List<ArchRunPath>(), Walls = new List<ArchPorchWall>() };
		}

		var merged = ArchFootprint.Union( legs.Select( leg => ArchFootprint.Rect( leg.Min, leg.Max ) ) );

		if ( merged.Count != 1 )
		{
			Log.Info( $"Architecture: porch {porch.Name} has legs that do not join into one deck, so only the first is built." );
			merged = new List<List<Vector2>> { ArchFootprint.Rect( legs[0].Min, legs[0].Max ) };
		}

		var footprint = ArchFootprint.Wind( merged[0] );
		// Free leans on nothing, so nothing is subtracted and every edge comes back open - which is the only
		// way a deck stands inside a room, where the shell subtraction leaves no apron at all.
		var house = porch.Standing == PorchStanding.Against
			? ArchPorch.Shell( building, room, kit )
			: new List<List<Vector2>>();

		ArchRegion.Split( footprint, house, out var open, out var against );

		var walls = against.Select( edge => new ArchPorchWall
		{
			From = edge.From,
			To = edge.To,
			Outward = edge.Outward,
			Depth = LegDepth( legs, edge.Midpoint, edge.Outward )
		} ).ToList();

		// The clamp reads the walls it just resolved rather than resolving the porch a second time, so the
		// head the generator builds to, the head the ghost draws and the head the roof seats on are one number.
		var head = porch.BaseHeight + ArchPorchCover.Headroom( porch, building, kit,
			walls.Count > 0 ? walls.Max( wall => wall.Depth ) : 0f );

		return new ArchPorchShape
		{
			Footprint = footprint,
			Open = Perimeter( open, porch.BaseHeight ),
			Walls = walls,
			Grade = porch.GradeHeight,
			Deck = porch.BaseHeight,
			Head = head,
			Ceiling = head + MathF.Max( 3f, kit.PorchBeamDepth )
		};
	}

	// Leaning on nothing, the run returns to its own start and closes like any other.
	static List<ArchRunPath> Perimeter( IEnumerable<List<Vector2>> open, float height )
	{
		var runs = new List<ArchRunPath>();

		foreach ( var run in open )
		{
			runs.Add( ArchRunPath.Normalized( run, height ) );
		}

		return runs;
	}

	static float LegDepth( IReadOnlyList<ArchPorchLeg> legs, Vector2 midpoint, Vector2 outward )
	{
		var inward = -outward;
		var best = 0f;

		foreach ( var leg in legs )
		{
			var probe = midpoint + inward * ArchProbe.Step;

			if ( probe.x < leg.Min.x || probe.x > leg.Max.x || probe.y < leg.Min.y || probe.y > leg.Max.y )
			{
				continue;
			}

			var size = leg.Max - leg.Min;

			best = MathF.Max( best, MathF.Abs( inward.x ) > MathF.Abs( inward.y ) ? size.x : size.y );
		}

		return best;
	}
}