Editor/Porch/ArchPorchCover.cs

Editor-side static helper for porch roof handling. It computes pitch and headroom, creates/updates or removes a roof ArchRoofPart for a porch, nests the roof layer under the porch, and contains utility checks for overlaps and underside clearance.

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

namespace Sunless.Architecture;

// The porch's roof. It is filed on the BUILDING, because that is where the roof generator reads it, but it
// belongs to the porch - so its row is nested under the porch by a layer record, exactly as a walkway's deck
// is, and it is re-derived from where the legs stand NOW on every commit rather than remembered from the drag
// that placed it. That is what makes "give it a roof" a tick on the porch instead of a decision you could only
// make once, while the tool was still holding the mouse.
public static class ArchPorchCover
{
	const float DefaultPitch = 12f;
	const float LeastHead = 78f;

	public static float Pitched( ArchPorchPart porch ) => porch.Pitch > 0.5f ? porch.Pitch : DefaultPitch;

	// The head height a porch may actually stand at. Its roof has to tuck under whatever hangs off the eave
	// above it, so the authored number is a REQUEST. Clamped here rather than written back, or a porch that
	// once stood under a low eave could never be raised again after the eave moved.
	public static float Headroom( ArchPorchPart porch, ArchBuilding building, ArchKit kit, float depth )
	{
		var wanted = MathF.Max( 60f, porch.HeadHeight );

		if ( building is null )
		{
			return wanted;
		}

		ArchPorch.Bounds( porch, out var min, out var max );

		var limit = float.MaxValue;

		foreach ( var above in building.Roofs )
		{
			if ( above.BaseHeight > porch.BaseHeight && !IsPorchRoof( building, above ) && Overlaps( above, min, max ) )
			{
				limit = MathF.Min( limit, Underside( above, kit ) );
			}
		}

		if ( limit == float.MaxValue )
		{
			return wanted;
		}

		// Rise is over the deck's depth from the wall, not the leg's length.
		var rise = MathF.Tan( Pitched( porch ).DegreeToRadian() ) * (depth + kit.RoofOverhang * 0.75f);
		var stack = rise + kit.RoofThickness + kit.PorchFlashingHeight + MathF.Max( 3f, kit.PorchBeamDepth ) + 2f;

		return ArchClearance.Fits( wanted, limit - porch.BaseHeight - stack, LeastHead,
			"porch head height", "its roof has to tuck under the eave above it" );
	}

	// A real hip sheds the L, so a wrap-around needs no second roof.
	public static bool Resolve( ArchPlan plan, ArchBuilding building, ArchRoom room, ArchKit kit, ArchPorchPart porch )
	{
		var standing = building.Roofs.FirstOrDefault( part => part.Id == porch.RoofId );
		var footprint = ArchPorch.Footprint( porch );
		var wanted = porch.Roofed && footprint.Count >= 4;

		if ( !wanted )
		{
			if ( standing is null )
			{
				return false;
			}

			Remove( plan, building, porch );

			return true;
		}

		var shape = ArchPorchShape.Resolve( porch, room, building, kit );
		var cover = standing;

		if ( cover is null )
		{
			cover = new ArchRoofPart
			{
				Id = plan.AllocateId(),
				Name = $"{porch.Name} Roof",
				Level = room.Floor,
				Style = RoofStyle.Hip,
				Overhang = kit.RoofOverhang * 0.75f,
				Thickness = kit.RoofThickness,
				Gutters = true,
				Fascia = true,
				Soffit = true,
				EndWalls = false,
				Ceiling = false
			};

			plan.File( ArchKind.Roof, building, cover );
			porch.RoofId = cover.Id;
		}

		var before = $"{cover.Pitch:0.##}:{cover.BaseHeight:0.##}:{cover.Min}:{cover.Max}";

		cover.Pitch = Pitched( porch );
		cover.Reshape( footprint );
		cover.BaseHeight = shape.Head + kit.PorchBeamDepth;

		Nest( plan, cover, porch );

		return standing is null || before != $"{cover.Pitch:0.##}:{cover.BaseHeight:0.##}:{cover.Min}:{cover.Max}";
	}

	// The section is the building's, which is where the generator wants it. A layer record moves only its
	// ROW, so the stack shows it as part of the porch it covers.
	static void Nest( ArchPlan plan, ArchRoofPart cover, ArchPorchPart porch )
	{
		ArchLayerGroups.Leave( plan, cover.Id );

		var record = plan.Layers.FirstOrDefault( entry => entry.ItemId == cover.Id );

		if ( record is null )
		{
			record = new ArchLayerRecord { ItemId = cover.Id, Kind = ArchKind.Roof, Stage = ArchLayerStage.Structure };
			plan.Layers.Add( record );
		}

		record.ParentId = porch.Id;
	}

	public static void Remove( ArchPlan plan, ArchBuilding building, ArchPorchPart porch )
	{
		if ( building is null || porch.RoofId == 0 )
		{
			return;
		}

		plan.RemoveAll( ArchKind.Roof, building, roof => ArchPlanStore.IdOf( roof ) == porch.RoofId );
		plan?.Layers.RemoveAll( record => record.ItemId == porch.RoofId );

		porch.RoofId = 0;
	}

	// A porch roof is not an eave to tuck under.
	static bool IsPorchRoof( ArchBuilding building, ArchRoofPart roof )
	{
		var kinds = ArchKinds.Load();

		return building.Rooms
			.SelectMany( room => ArchPlanStore.FiledOn( ArchKind.Porch, room, kinds ) )
			.OfType<ArchPorchPart>()
			.Any( porch => porch.RoofId == roof.Id );
	}

	// The line to stay under is the gutter's bottom, not the eave itself.
	static float Underside( ArchRoofPart roof, ArchKit kit )
	{
		var profile = roof.Gutters && !roof.Parapet ? kit.FindProfile( "gutter" ) : null;

		if ( profile is null || !profile.IsUsable )
		{
			return roof.BaseHeight - kit.FasciaHeight;
		}

		return roof.BaseHeight - profile.Max.y + profile.Min.y;
	}

	static bool Overlaps( ArchRoofPart roof, Vector2 min, Vector2 max )
	{
		var reach = new Vector2( roof.Overhang, roof.Overhang );

		return ArchFootprint.Overlaps(
			new ArchBox { Min = min, Max = max },
			new ArchBox { Min = roof.Min - reach, Max = roof.Max + reach } );
	}
}