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