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