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.
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 );
}
}
}