Editor/Tool/Subtools/ArchRoofSubtool.cs
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.";
}
public override void OnEnabled() {
base.OnEnabled();
ArchWorkflow.Restart( Scope( "flow" ) );
}
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 stack
// 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();
}
// The tool draws two different things, so the first step asks WHICH - and the rest of the sequence is the one
// that thing has. A section is styled, run and dressed; a fitting is glazed or not and sized, and everything
// about the deck it lands on is answered by the drag rather than by a control.
protected override void BuildOptions( ToolSidebarWidget panel ) {
if ( fitting ) {
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." ) );
}
using var flow = ArchWorkflow.In( panel, Scope( "flow" ), Refresh );
flow.Step( "Kind", KindName, KindGlyph, PickKind );
if ( fitting ) {
if ( lightDraft.Form.Glazes() ) {
flow.Step( "Glazing", GlazingName, GlazingGlyph, PickGlazing );
}
flow.Step( "Size", SizeName, "straighten", BuildSize );
return;
}
flow.Step( "Style", draft.Style.ToString(), ArchIcons.RoofStyleGlyph( draft.Style ), PickStyle );
if ( ArchRoofPlane.Ridged( draft.Style ) ) {
flow.Step( "Ridge", RidgeName, ArchIcons.RidgeGlyph( ridge ), PickRidge );
}
flow.Step( "Cornice", CorniceName, "view_stream", step => PickCornice( panel, step ) );
flow.Step( "Edges", EdgesName, "crop_din", BuildEdges );
flow.Step( "Eave", $"{eaveDrop:0} down", "vertical_align_bottom", BuildEave );
}
string KindName => fitting ? lightDraft.Form.Label() : "Section";
string KindGlyph => fitting ? lightDraft.Form.Glyph() : "roofing";
void PickKind( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Section — a roof over the footprint you drag", "roof_hip", "roofing", !fitting, () => Draw( step ) );
foreach ( var value in Enum.GetValues<RoofLightForm>() ) {
var form = value;
grid.Pick( $"{form.Label()} — {form.Advice()}", form.Slug(), form.Glyph(), fitting && lightDraft.Form == form,
() => Fit( form, step ) );
}
}
void Draw( ArchWorkflowStep step ) {
fitting = false;
step.Chose();
}
void Fit( RoofLightForm form, ArchWorkflowStep step ) {
fitting = true;
Restock( form );
step.Chose();
}
void PickStyle( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
foreach ( var value in Enum.GetValues<RoofStyle>() ) {
var captured = value;
grid.Pick( captured.ToString(), ArchIcons.RoofStyleSlug( captured ), ArchIcons.RoofStyleGlyph( captured ),
draft.Style == captured, () => Shape( captured, step ) );
}
}
void Shape( RoofStyle style, ArchWorkflowStep step ) {
draft.Style = style;
step.Chose();
}
string RidgeName => ridge.ToString();
void PickRidge( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
foreach ( var value in Enum.GetValues<RidgeRun>() ) {
var captured = value;
grid.Pick( ArchIcons.RidgeAdvice( captured ), ArchIcons.RidgeSlug( captured ),
ArchIcons.RidgeGlyph( captured ), ridge == captured, () => Run( captured, step ) );
}
}
void Run( RidgeRun run, ArchWorkflowStep step ) {
ridge = run;
step.Chose();
}
string CorniceName => Owner.Kit.FindCornice( draft.Cornice ) is { } style ? style.Name : "None";
// The same shelf a wall head crowns itself from, hung off the SIDEBAR so its designer survives the refresh
// that picking a style triggers.
void PickCornice( ToolSidebarWidget panel, ArchWorkflowStep step ) {
step.Add( ArchCorniceUi.Picker( panel, Owner.Kit, Scope( "cornices" ), () => draft.Cornice,
name => Crown( name, step ) ) );
}
void Crown( string cornice, ArchWorkflowStep step ) {
draft.Cornice = cornice;
step.Chose();
}
string EdgesName {
get {
var worn = new List<string> { draft.Parapet ? "Parapet" : draft.Gutters ? "Gutters" : "Bare" };
if ( draft.Guardrail ) {
worn.Add( "guardrail" );
}
return string.Join( ", ", worn );
}
}
void BuildEdges( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Toggle( "Parapet wall cap", "opt_parapet", "crop_din", draft.Parapet,
value => { draft.Parapet = value; Refresh(); } );
// A parapet stands where the gutter would hang, so the deck drains inside it and ArchBuild.Roof refuses
// gutters outright - offering the toggle would be offering nothing.
if ( !draft.Parapet ) {
grid.Toggle( "Gutters", "opt_gutters", "water_damage", draft.Gutters, value => draft.Gutters = value );
}
grid.Toggle( "Guardrail round the roof edge", "opt_guardrail", "fence", draft.Guardrail,
value => draft.Guardrail = value );
}
// Dropping the eave is how a section steps down under a taller one, so it reads as a fall rather than a height.
void BuildEave( ArchWorkflowStep step ) {
step.Layout.Add( ArchPartUi.Number( "Eave drop", eaveDrop, 0f, value => eaveDrop = value ) );
}
string GlazingName => lightDraft.Glazed ? "Glazed" : "Open";
string GlazingGlyph => lightDraft.Glazed ? "window" : "crop_free";
void PickGlazing( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Glazed — a pane in its head", "opt_glazed", "window", lightDraft.Glazed, () => Glaze( true, step ) );
grid.Pick( "Open — the hole left as it is", "opt_unglazed", "crop_free", !lightDraft.Glazed, () => Glaze( false, step ) );
}
void Glaze( bool glazed, ArchWorkflowStep step ) {
lightDraft.Glazed = glazed;
step.Chose();
}
string SizeName {
get {
var form = lightDraft.Form;
var rise = lightDraft.Curb > 0f ? lightDraft.Curb : form.RiseStock( Owner.Kit );
var reach = lightDraft.CurbWidth > 0f ? lightDraft.CurbWidth : form.ReachStock( Owner.Kit );
return $"{rise:0} up, {reach:0} thick";
}
}
// The footprint is never here - it is dragged on the deck. What the two numbers MEAN on this form is
// ArchRoofLightKinds' answer, so a chimney is never offered a pane width it would ignore.
void BuildSize( ArchWorkflowStep step ) {
var form = lightDraft.Form;
step.Layout.Add( ArchPartUi.Number( form.RiseLabel(), lightDraft.Curb, form.RiseStock( Owner.Kit ),
value => lightDraft.Curb = value ) );
step.Layout.Add( ArchPartUi.Number( form.ReachLabel(), lightDraft.CurbWidth, form.ReachStock( Owner.Kit ),
value => lightDraft.CurbWidth = value ) );
if ( form.Glazes() && lightDraft.Glazed ) {
step.Layout.Add( ArchPartUi.Number( "Widest pane", lightDraft.PaneSpan, form.PaneStock(),
value => lightDraft.PaneSpan = value ) );
}
}
}