Editor/Porch/ArchPorchAffector.cs

Editor-side static class that manages porch affector logic for architectural plans. It resolves porch covers, steps and doors: determines whether a porch should place or remove an opening on the wall it fronts, computes frontage/station against wall geometry, picks an opening preset, and attaches or clears openings owned by the porch.

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

namespace Sunless.Architecture;

// A porch is an ENTRANCE, and an entrance with no door in the wall behind it is a deck you cannot get off.
// The door was a separate drag you had to remember, aimed past the porch geometry now standing over the wall
// it belongs in - so the porch stands it, and the porch owns it.
//
// It lands on a host the porch does not own - the wall - so it is an affector and obeys the contract: the
// elevation it fronts is re-derived from where the legs stand NOW on every commit, so reshaping the deck moves
// the door with it, and turning the option off closes the hole rather than orphaning it.
//
// Owned by the PORCH's id, not the wall's, which is what keeps it out of ArchOpeningRunAffector's hands: that
// pass closes only its own units, and its Clear test reads anything owned by somebody else as hand-placed and
// parts the rhythm around it. A porch door and a bay rhythm on one elevation therefore need no code between them.
public static class ArchPorchAffector
{
	// Below this overlap the deck is beside the elevation rather than fronting it, and a door would be squeezed
	// into masonry that has to hold the wall up.
	const float LeastFrontage = 36f;

	const float Tolerance = 12f;

	public static int Resolve( ArchPlan plan, ArchKit kit )
	{
		var stood = 0;
		var kinds = ArchKinds.Load();

		foreach ( var building in plan.Buildings.ToList() )
		{
			foreach ( var room in building.Rooms.ToList() )
			{
				foreach ( var porch in plan.Filed<ArchPorchPart>( room, kinds ).ToList() )
				{
					// The section over it and the flights off it land on hosts the porch does not own - the
					// building's roof list, and grade - so both are re-derived from where the legs stand NOW,
					// exactly as the door is. The cover goes first: the head every flight climbs to is the one
					// the roof was just seated on.
					var recovered = ArchPorchCover.Resolve( plan, building, room, kit, porch );

					ArchPorchSteps.Settle( porch, ArchPorchShape.Resolve( porch, room, building, kit ), kit );

					if ( Restand( plan, kit, building, room, porch ) || recovered )
					{
						stood++;
					}
				}
			}
		}

		return stood;
	}

	static bool Restand( ArchPlan plan, ArchKit kit, ArchBuilding building, ArchRoom room, ArchPorchPart porch )
	{
		var wanted = porch.Door ? Wanted( kit, building, room, porch ) : null;

		if ( Standing( building, porch ) == Signature( wanted ) )
		{
			return false;
		}

		Close( building, porch );

		if ( wanted is not { } door )
		{
			return true;
		}

		var opening = door.Preset.Spawn( plan.AllocateId(), door.Shape.Offset );

		opening.OwnerId = porch.Id;
		opening.Width = door.Shape.Width;
		opening.Height = door.Shape.Height;
		opening.SillHeight = door.Shape.SillHeight;

		door.Wall.Openings.Add( opening );

		return true;
	}

	readonly record struct Door( ArchWall Wall, ArchOpeningPreset Preset, ArchOpeningShape Shape );

	// The elevation the deck fronts WIDEST, over every leg and every wall on the storey - a wrap-around turns a
	// corner, and the door belongs on the side it fronts most of, not on whichever wall a leg happened to list first.
	static Door? Wanted( ArchKit kit, ArchBuilding building, ArchRoom room, ArchPorchPart porch )
	{
		if ( Leaf( kit, porch ) is not { } preset )
		{
			return null;
		}

		ArchWall best = null;
		ArchRoom host = null;
		var widest = LeastFrontage;
		var station = 0f;

		foreach ( var storey in building.Rooms.Where( entry => entry.Floor == room.Floor ) )
		{
			foreach ( var wall in storey.Walls )
			{
				foreach ( var leg in porch.Legs )
				{
					if ( !Fronts( wall, leg, ArchWallSection.Thickness( wall, kit ), out var from, out var to ) || to - from <= widest )
					{
						continue;
					}

					best = wall;
					host = storey;
					widest = to - from;
					station = (from + to) * 0.5f;
				}
			}
		}

		if ( best is null || Already( best, porch, station, widest ) )
		{
			return null;
		}

		return new Door( best, preset,
			ArchOpeningSeats.Stationed( best, preset, kit, new ArchOpeningRun(), station,
				ArchWallSection.Height( best, host, kit ) ) );
	}

	// A hand-placed door in the stretch the deck fronts IS the way in, so the porch stands nothing beside it.
	// Without this, switching the option on - and it defaults on, so every porch already authored - punched a
	// second door alongside the one the author had already drawn.
	static bool Already( ArchWall wall, ArchPorchPart porch, float station, float frontage )
	{
		var from = station - frontage * 0.5f;
		var to = station + frontage * 0.5f;

		return wall.Openings.Any( opening =>
			opening.OwnerId != porch.Id && opening.Kind.Hangs()
			&& opening.Right > from && opening.Left < to );
	}

	// How much of this wall's run the leg stands across, and whether it is against its face at all. A deck lying
	// outside the elevation touches the outer face; one straddling the centreline is a leg drawn over the wall.
	static bool Fronts( ArchWall wall, ArchPorchLeg leg, float thickness, out float from, out float to )
	{
		from = 0f;
		to = 0f;

		var length = wall.Length;

		if ( length < 1f )
		{
			return false;
		}

		var corners = new[] { leg.Min, new Vector2( leg.Max.x, leg.Min.y ), leg.Max, new Vector2( leg.Min.x, leg.Max.y ) };
		var runs = corners.Select( corner => Vector2.Dot( corner - wall.Start, wall.Direction ) ).ToList();
		var sides = corners.Select( corner => Vector2.Dot( corner - wall.Start, wall.Normal ) ).ToList();

		from = Math.Clamp( runs.Min(), 0f, length );
		to = Math.Clamp( runs.Max(), 0f, length );

		var near = sides.Min() < 0f && sides.Max() > 0f
			? 0f
			: MathF.Min( MathF.Abs( sides.Min() ), MathF.Abs( sides.Max() ) );

		return near <= thickness * 0.5f + Tolerance;
	}

	// Named, else the kit's first door. A leaf is Door/DoubleDoor/Garage only, and ArchOpeningKinds is the one
	// place that is asked - a porch fronted by an archway would carry nothing to open.
	static ArchOpeningPreset Leaf( ArchKit kit, ArchPorchPart porch )
	{
		if ( porch.DoorPreset is { Length: > 0 } named )
		{
			return kit.Openings.FirstOrDefault( preset =>
				string.Equals( preset.Name, named, StringComparison.OrdinalIgnoreCase ) && preset.Kind.Hangs() );
		}

		return kit.Openings.FirstOrDefault( preset => preset.Kind == OpeningKind.Door );
	}

	static string Standing( ArchBuilding building, ArchPorchPart porch )
	{
		foreach ( var wall in building.Rooms.SelectMany( room => room.Walls ) )
		{
			var mine = wall.Openings.FirstOrDefault( opening => opening.OwnerId == porch.Id );

			if ( mine is not null )
			{
				return $"{wall.Id}:{mine.Preset}:{mine.Offset:0.#}:{mine.Width:0.#}:{mine.SillHeight:0.#}:{mine.Height:0.#}";
			}
		}

		return "";
	}

	static string Signature( Door? door )
	{
		return door is not { } wanted
			? ""
			: $"{wanted.Wall.Id}:{wanted.Preset.Name}:{wanted.Shape.Offset:0.#}:{wanted.Shape.Width:0.#}:{wanted.Shape.SillHeight:0.#}:{wanted.Shape.Height:0.#}";
	}

	// Plan-wide whatever the scope, or a reshaped porch leaves its old hole standing in the old elevation.
	public static void Close( ArchBuilding building, ArchPorchPart porch )
	{
		foreach ( var wall in building.Rooms.SelectMany( room => room.Walls ) )
		{
			wall.Openings.RemoveAll( opening => opening.OwnerId == porch.Id );
		}
	}
}