Editor subtool for placing and editing roofs and roof lights in the architecture tool. Draws previews, builds roof or roof-light geometry on drag, validates placement, and exposes UI options for roof style, eave drop, ridge run, and light form.
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
[Title( "Roof" ), Icon( "roofing" ), Group( "08" )]
public sealed class ArchRoofSubtool( ArchTool owner ) : ArchSubtool( owner )
{
protected override ArchKind? DraftKind => ArchKind.Roof;
public override ArchSurface[] Surfaces => new[] { ArchSurface.Roof, ArchSurface.RoofEdge, ArchSurface.Soffit, ArchSurface.Gutter, ArchSurface.WallCap, ArchSurface.Plant };
readonly ArchRoofPart draft = new() { Style = RoofStyle.Hip };
readonly ArchRoofLightPart lightDraft = new();
RidgeRun ridge = RidgeRun.Auto;
float eaveDrop;
// A fitting goes on a deck already there rather than standing a new section - explicit so a miss says so.
bool fitting;
protected override string Title() => fitting ? lightDraft.Form.Label() : "Roof";
protected override string Advice()
{
return fitting
? lightDraft.Form.Advice()
: "Drag the roof footprint. Drop the eave to step a section down under a taller one.";
}
protected override void DrawPreview()
{
if ( fitting )
{
DrawLightPreview();
return;
}
var min = Min( DragStart, DragCurrent );
var max = Max( DragStart, DragCurrent );
var eave = Owner.LevelHeight + MathF.Max( 32f, Owner.Kit.WallHeight - MathF.Max( 0f, eaveDrop ) );
ArchGhost.Pitch( min, max, eave, Owner.Kit.RoofPitch, draft.Style, ArchBuild.Ridged( ridge, min, max ) );
ArchGhost.Note( new Vector3( max.x, max.y, eave ), $"{draft.Style} eave {eave:0}" );
}
void DrawLightPreview()
{
var min = Min( DragStart, DragCurrent );
var max = Max( DragStart, DragCurrent );
var roof = ArchRoofLight.Under( Owner.Plan, Owner.Level, DragStart, lightDraft.Form );
if ( roof is null )
{
var over = ArchRoofLight.Over( Owner.Plan, Owner.Level, DragStart );
ArchGhost.Plate( min, max, Owner.LevelHeight, 8 );
ArchGhost.Note( new Vector3( max.x, max.y, Owner.LevelHeight ),
over is null
? $"no deck here for a {lightDraft.Form.Label().ToLowerInvariant()}"
: $"{over.Name} is a {over.Style} — {Carried()}" );
return;
}
if ( Levelled( roof ) )
{
ArchGhost.Plate( min, max, Owner.LevelHeight, 8 );
ArchGhost.Note( new Vector3( max.x, max.y, Owner.LevelHeight ),
$"{roof.Name} is flat — a dormer needs a slope to break out of" );
return;
}
// Off the deck plane under the drag, never the eave: a plant item on a pitch reads where it will stand.
var deck = ArchRoofGen.Seat( roof, Owner.Kit, min, max, lightDraft.CurbWidth );
var top = deck + ArchRoofGen.Stack( lightDraft, Owner.Kit ).Top;
ArchGhost.Ring( roof.Outline(), deck );
ArchGhost.Volume( min, max, deck, top );
var note = $"{lightDraft.Form.Label()} {max.x - min.x:0} x {max.y - min.y:0} on {roof.Name} at deck {deck:0}";
if ( roof.Style == RoofStyle.Gable )
{
note += " — across the ridge gives one per slope";
}
ArchGhost.Note( new Vector3( max.x, max.y, top ), note );
}
protected override void OnDrag( Vector2 from, Vector2 to )
{
if ( fitting )
{
CarveLight( from, to );
return;
}
var min = Min( from, to );
var max = Max( from, to );
if ( (max - min).Length < 8f )
{
return;
}
var building = Owner.ActiveBuilding();
var height = Owner.LevelHeight + MathF.Max( 32f, Owner.Kit.WallHeight - MathF.Max( 0f, eaveDrop ) );
var roof = ArchBuild.Roof( Owner.Plan, Owner.Kit, Owner.Level, draft.Style, min, max, height, draft.Gutters, draft.Parapet, ridge );
roof.Name = $"Roof{building.Roofs.Count + 1}";
building.Roofs.Add( roof );
FinishDraft( roof.Id );
Owner.Commit();
}
void CarveLight( Vector2 from, Vector2 to )
{
var roof = ArchRoofLight.Under( Owner.Plan, Owner.Level, from, lightDraft.Form );
if ( roof is null )
{
var over = ArchRoofLight.Over( Owner.Plan, Owner.Level, from );
Log.Info( over is null
? "Architecture: no roof section under that drag at all. A light is cut out of a deck already there, and plant stands on one."
: $"Architecture: {over.Name} is a {over.Style}, and a {lightDraft.Form.Label().ToLowerInvariant()} is not carried by one. {Carried()}." );
return;
}
if ( Levelled( roof ) )
{
Log.Info( $"Architecture: {roof.Name} is flat, and a dormer breaks OUT of a slope — a level deck has no"
+ " downhill for it to face. Put one on a Shed or a Gable, or use a Monitor for a raised glazed box on the flat." );
return;
}
if ( ArchRoofLight.Add( Owner.Plan, roof, from, to, lightDraft ) is not { } light )
{
Log.Info( $"Architecture: that drag is smaller than {ArchRoofLight.MinSize} inches on a side, so there is no light in it." );
return;
}
FinishDraft( light.Id );
Owner.Commit( $"Add {light.Form.Label()}" );
}
// A dormer is the one form with a direction, and it takes it from the fall of the deck it stands on.
bool Levelled( ArchRoofPart roof ) => lightDraft.Form.Roofed() && roof.Style == RoofStyle.Flat;
// What the draft's own form is carried by, so a refusal names the sections that would take it rather than
// reciting the same list of three whatever was picked.
string Carried()
{
return lightDraft.Form.Roofed()
? "a dormer wants a slope, so Shed, Gable and Hip carry one and a Flat does not"
: "Flat, Shed and Gable carry one; a Hip is a skeleton of triangles and trapezoids, which the carve grid cannot hold";
}
// Every form comes off its own stock - a kerb is a flashing upstand, a housing is a low wall, a flue and a tank
// come off the kit's plant sizes - so switching form restocks it. An unchanged pick keeps whatever was typed.
void Restock( RoofLightForm form )
{
if ( lightDraft.Form == form )
{
return;
}
lightDraft.Form = form;
lightDraft.Curb = form.RiseStock( Owner.Kit );
lightDraft.CurbWidth = form.ReachStock( Owner.Kit );
lightDraft.PaneSpan = form.PaneStock();
}
protected override void BuildOptions( ToolSidebarWidget panel )
{
ArchSidebarSection.Show( panel, Scope( "kind" ), "Kind", group =>
{
using var kind = ArchIconGrid.In( group );
kind.Pick( "Section — a roof over the footprint you drag", "roof_hip", "roofing", !fitting,
() => { fitting = false; Refresh(); } );
foreach ( var value in Enum.GetValues<RoofLightForm>() )
{
var form = value;
kind.Pick( $"{form.Label()} — {form.Advice()}", form.Slug(), form.Glyph(), fitting && lightDraft.Form == form,
() => { fitting = true; Restock( form ); Refresh(); } );
}
} );
if ( fitting )
{
ArchRoofUi.Light( panel, lightDraft, Owner.Kit, Refresh, null );
panel.Layout.Add( ArchSidebarLayout.Advice( "Stands on the deck plane under it, never a typed height, so it follows the pitch — and clips to the deck it lands on." ) );
return;
}
ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
{
group.Add( ArchPartUi.Number( "Eave drop", eaveDrop, 0f, value => eaveDrop = value ) );
// A hip or a flat has no ridge to run, so the row decides nothing on them.
if ( !ArchRoofPlane.Ridged( draft.Style ) )
{
return;
}
using var grid = ArchIconGrid.In( group );
foreach ( var value in Enum.GetValues<RidgeRun>() )
{
var captured = value;
grid.Pick( ArchIcons.RidgeAdvice( captured ), ArchIcons.RidgeSlug( captured ),
ArchIcons.RidgeGlyph( captured ), ridge == captured, () => ridge = captured );
}
} );
ArchRoofUi.Build( panel, draft, Refresh, null );
}
}