Editor/Stair/ArchStairUi.cs
using System;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
public static class ArchStairUi {
// Every group here folds, and remembers being folded (SidebarGroupWidget keys its own cookie off the title). A
// stair carries five kinds of setting and an author is working on one of them, so the sheet opens as a list of
// headings rather than as a page you have to scroll to the end of to find out what is on it. The Stair tool
// asks the same five as five steps, which is why each is reachable on its own.
public static void Build( ToolSidebarWidget panel, ArchStairPart stair, Action refresh, Action changed ) {
Carriage( panel.AddGroup( "Carriage", collapsible: true ), stair, refresh, changed );
var balustrade = panel.AddGroup( "Balustrade", collapsible: true );
Guard( balustrade, stair, refresh, changed );
Rails( balustrade, stair, refresh, changed );
Wrap( panel.AddGroup( "Wall wrap", collapsible: true ), stair, refresh, changed );
BuiltOf( panel.AddGroup( "Built of", collapsible: true ), stair, refresh, changed );
if ( Solid( stair ) ) {
return;
}
Underneath( panel.AddGroup( "Under the stairs", collapsible: true ), stair, refresh, changed );
}
// A stepped mass has no soffit to open, which is what makes the question below it absent rather than dead.
public static bool Solid( ArchStairPart stair ) => stair.Core?.Fill == StairFill.Solid;
public static void Carriage( Layout group, ArchStairPart stair, Action refresh, Action changed ) {
using var grid = ArchIconGrid.In( group );
foreach ( var value in Enum.GetValues<StairSupport>() ) {
var captured = value;
grid.Pick( Describe( captured ), $"stair_{captured}".ToLowerInvariant(), Fallback( captured ),
stair.Support == captured, () => { stair.Support = captured; changed?.Invoke(); refresh?.Invoke(); } );
}
}
// Flanks on one row, the opt-out under them: the enum's own order strands Both alone below None, Left and Right.
static readonly StairGuard[] Guards = { StairGuard.Both, StairGuard.Left, StairGuard.Right, StairGuard.None };
// The one exclusive answer - which flanks are guarded - kept apart from what those flanks WEAR, so a sequence
// can advance on the first and leave the second on a plain relayout.
public static void Guard( Layout group, ArchStairPart stair, Action refresh, Action changed ) {
using var grid = ArchIconGrid.In( group );
foreach ( var value in Guards ) {
var captured = value;
grid.Pick( Describe( captured ), $"guard_{captured}".ToLowerInvariant(), Fallback( captured ),
stair.Guard == captured, () => { stair.Guard = captured; changed?.Invoke(); refresh?.Invoke(); } );
}
}
public static void Rails( Layout group, ArchStairPart stair, Action refresh, Action changed ) {
if ( stair.Guard is StairGuard.Left or StairGuard.Both ) {
Railing( group, "left", stair.LeftRailing, value => { stair.LeftRailing = value; changed?.Invoke(); } );
}
if ( stair.Guard is StairGuard.Right or StairGuard.Both ) {
Railing( group, "right", stair.RightRailing, value => { stair.RightRailing = value; changed?.Invoke(); } );
}
using var rails = ArchIconGrid.In( group );
rails.Toggle( "Handrail on walled sides — the rail bolted to the plaster where no balustrade stands", "opt_wall_rail", "handyman",
stair.WallRail, value => { stair.WallRail = value; changed?.Invoke(); } );
// Off, the two settings above stop deriving anything and only the railings actually placed on a step
// stand - which is what makes each of them a thing you put somewhere and drag.
rails.Toggle( "Automatic railings — off, only the railings you place by hand stand", "opt_auto_rails", "auto_fix_high",
stair.AutoRailings, value => { stair.AutoRailings = value; changed?.Invoke(); refresh?.Invoke(); } );
}
public static void Wrap( Layout group, ArchStairPart stair, Action refresh, Action changed ) {
using ( var wrap = ArchIconGrid.In( group ) ) {
wrap.Pick( "Open — the flight stands in the room it was drawn in", "wrap_open", "crop_free",
stair.Wrap == StairWrap.None, () => { stair.Wrap = StairWrap.None; changed?.Invoke(); refresh?.Invoke(); } );
wrap.Pick( "Walls round the well — the flight carries its own core past whatever it started in", "wrap_walls", "square_foot",
stair.Wrap == StairWrap.Walls, () => { stair.Wrap = StairWrap.Walls; changed?.Invoke(); refresh?.Invoke(); } );
}
if ( stair.Wrap != StairWrap.Walls ) {
return;
}
group.Add( ArchPartUi.Number( "Wall thickness", stair.WrapThickness, 8f, value => stair.WrapThickness = value, changed ) );
}
public static void BuiltOf( Layout group, ArchStairPart stair, Action refresh, Action changed ) {
using var fill = ArchIconGrid.In( group );
fill.Pick( "Open — treads on stringers over an open raked soffit, the joinery stair", "fill_open", "stairs",
!Solid( stair ), () => { Filled( stair, StairFill.Open ); changed?.Invoke(); refresh?.Invoke(); } );
fill.Pick( "Solid — every flight a stepped mass standing on the shaft floor, the concrete stair", "fill_solid", "foundation",
Solid( stair ), () => { Filled( stair, StairFill.Solid ); changed?.Invoke(); refresh?.Invoke(); } );
}
public static void Underneath( Layout group, ArchStairPart stair, Action refresh, Action changed ) {
using var under = ArchIconGrid.In( group );
foreach ( var value in Enum.GetValues<StairUnder>() ) {
var captured = value;
under.Pick( Describe( captured ), $"under_{captured}".ToLowerInvariant(), Fallback( captured ),
stair.Under == captured, () => { stair.Under = captured; changed?.Invoke(); refresh?.Invoke(); } );
}
}
// What the guard on one side IS, once that side has one - the pair of choices only stands where the balustrade
// setting above has already opened that flank, so a stair guarded on one side is never asked about the other.
static void Railing( Layout group, string side, StairRailing railing, Action<StairRailing> assign ) {
using var grid = ArchIconGrid.In( group );
foreach ( var value in Enum.GetValues<StairRailing>() ) {
var captured = value;
grid.Pick( Describe( captured, side ), $"railing_{side}_{captured}".ToLowerInvariant(), Fallback( captured ),
railing == captured, () => assign( captured ) );
}
}
static void Filled( ArchStairPart stair, StairFill fill ) {
stair.Core ??= new ArchStairCore();
stair.Core.Fill = fill;
}
public static void Sizes( ToolSidebarWidget panel, ArchStairPart stair, Action changed ) {
var core = stair.Core ??= new ArchStairCore();
var group = panel.AddGroup( "Shaft", collapsible: true );
// The shaft's footprint is dragged on its own box and has no number here, but the climb keeps one: a
// stair told to reach exactly one storey is a figure you type, not a height you aim at.
group.Add( ArchPartUi.Number( "Climb", core.Rise, 128f, value => core.Rise = MathF.Max( 4f, value ), changed ) );
group.Add( ArchPartUi.Number( "Flight width", stair.Width, 48f, value => stair.Width = value, changed ) );
group.Add( ArchPartUi.Number( "Step rise", stair.StepRise, 8f, value => stair.StepRise = value, changed ) );
group.Add( ArchPartUi.Number( "Step going", stair.StepGoing, 12f, value => stair.StepGoing = value, changed ) );
group.Add( ArchPartUi.Number( "Nosing", stair.TreadNosing, 1f, value => stair.TreadNosing = value, changed ) );
group.Add( ArchPartUi.Number( "Well gap", stair.WellGap, 4f, value => stair.WellGap = value, changed ) );
group.Add( ArchPartUi.Number( "Arrival depth", stair.TopLandingDepth, 48f, value => stair.TopLandingDepth = value, changed ) );
using ( var grid = ArchIconGrid.In( panel.AddGroup( "Fit", collapsible: true ) ) ) {
grid.Toggle( "Railing round the well on the storey above", "opt_well_guard", "vertical_align_top", stair.WellGuard,
value => { stair.WellGuard = value; changed?.Invoke(); } );
grid.Toggle( "Landing at the top", "opt_top_landing", "horizontal_rule", stair.TopLanding,
value => { stair.TopLanding = value; changed?.Invoke(); } );
}
// No per-flight rows here. A step's climb is its lift pin, its row in the drawing and its own sheet - three
// ways in already - and a list that grows a field and a button per step turns a stair of eight segments
// into a sheet nobody can scroll to the end of.
var flow = panel.AddGroup( "Flow", collapsible: true ).AddRow();
flow.Spacing = 4;
flow.Add( new Button( "Invert climb", "swap_vert" ) { Clicked = () => { stair.ReverseChain(); changed?.Invoke(); } } );
flow.Add( new Button( "Reset top opening", "restart_alt" ) { Clicked = () => { stair.TopOpening = null; stair.TopOpeningSnap = null; changed?.Invoke(); } } );
}
public static void Approach( ToolSidebarWidget panel, ArchApproachPart approach, ArchGridService gridService, Action refresh, Action changed ) {
if ( !approach.IsSpline ) {
using var grid = ArchIconGrid.In( panel.AddGroup( "Kind" ) );
grid.Pick( "Foundation steps — climb from grade onto the plinth at a door", "approach_steps", "stairs_2",
approach.Kind == ApproachKind.Steps, () => { approach.Kind = ApproachKind.Steps; refresh?.Invoke(); changed?.Invoke(); } );
grid.Pick( "Driveway ramp — slope from grade onto the plinth at a garage", "approach_ramp", "trending_up",
approach.Kind == ApproachKind.Ramp, () => { approach.Kind = ApproachKind.Ramp; refresh?.Invoke(); changed?.Invoke(); } );
}
Fittings( panel, approach, changed );
var group = panel.AddGroup( "Reach" );
if ( approach.IsSpline ) {
for ( var index = 0; index < approach.Nodes.Count; index++ ) {
var captured = index;
var label = index == 0 ? "Start width" : index == approach.Nodes.Count - 1 ? "End width" : $"Middle {index} width";
var width = ArchApproachAxes.WidthAt( approach, index );
group.Add( ArchPartUi.Number( label, width, approach.Width, value => {
var snapped = MathF.Max( gridService.SubgridSize( 4 ), gridService.Subgrid( value, 4 ) );
approach.Nodes[captured].WidthScale = snapped / MathF.Max( 12f, approach.Width );
}, changed ) );
}
} else {
group.Add( ArchPartUi.Number( "Width", approach.Width, 48f,
value => approach.Width = gridService.Subgrid( value, 4 ), changed ) );
group.Add( ArchPartUi.Number( "Run", approach.Run, 0f,
value => approach.Run = gridService.Subgrid( value, 4 ), changed ) );
}
if ( approach.Kind != ApproachKind.Ramp ) {
return;
}
// The MATERIAL's question, not the geometry's: cast concrete follows the rake, brick is laid level.
using ( var mapping = ArchIconGrid.In( panel.AddGroup( "UV Options" ) ) ) {
mapping.Pick( "Raked — courses follow the drive's own top line, the way the trim over them does",
"uv_raked", "trending_down", approach.Mapping == ApproachMapping.Raked,
() => { approach.Mapping = ApproachMapping.Raked; changed?.Invoke(); } );
mapping.Pick( "Level — courses stay horizontal, as a bricklayer would lay them",
"uv_level", "horizontal_rule", approach.Mapping == ApproachMapping.Level,
() => { approach.Mapping = ApproachMapping.Level; changed?.Invoke(); } );
}
var crossover = panel.AddGroup( "Crossover" );
crossover.Add( ArchPartUi.Number( "Splay", approach.Splay, 36f,
value => approach.Splay = gridService.Subgrid( value, 4 ), changed ) );
crossover.Add( ArchPartUi.Number( "Segments", approach.SplaySegments, 0f,
value => approach.SplaySegments = (int)MathF.Round( value ), changed ) );
}
public static void Fittings( ToolSidebarWidget panel, ArchApproachPart approach, Action changed ) {
using var grid = ArchIconGrid.In( panel.AddGroup( approach.Kind == ApproachKind.Ramp ? "Ramp" : "Steps" ) );
if ( approach.Kind == ApproachKind.Ramp ) {
grid.Toggle( "Kerbs down both sides", "opt_kerbs", "align_horizontal_center", approach.Kerbs,
value => { approach.Kerbs = value; changed?.Invoke(); } );
grid.Toggle( "Skirted flanks — the sides wear the skirt of whatever it ran off, not the drive's own surface",
"opt_skirted", "layers", approach.Skirted,
value => { approach.Skirted = value; changed?.Invoke(); } );
return;
}
grid.Toggle( "Handrails", "opt_rails", "fence", approach.Rails,
value => { approach.Rails = value; changed?.Invoke(); } );
}
public static string Describe( StairSupport value ) => value switch {
StairSupport.OpenString => "Open string — stringers, no risers",
_ => "Closed string — stringers and risers"
};
public static string Describe( StairUnder value ) => value switch {
StairUnder.Filled => "Filled — a solid raked wall under the flight, faced both sides",
StairUnder.Cupboard => "Cupboard — filled, with a door into the space under the stairs",
_ => "Open underneath"
};
public static string Describe( StairGuard value ) => value switch {
StairGuard.Left => "Balustrade on the left going up",
StairGuard.Right => "Balustrade on the right going up",
StairGuard.None => "No balustrade",
_ => "Balustrade both sides"
};
public static string Describe( StairRailing value, string side ) => value switch {
StairRailing.Handrail => $"{Title( side )} rake — the handrail on its newels, nothing under it",
_ => $"{Title( side )} balustrade — newels, balusters and the handrail over them"
};
static string Title( string side ) => char.ToUpperInvariant( side[0] ) + side[1..];
public static string Fallback( StairRailing value ) => value switch {
StairRailing.Handrail => "remove",
_ => "fence"
};
public static string Fallback( StairSupport value ) => value switch {
StairSupport.OpenString => "density_small",
_ => "stairs"
};
public static string Fallback( StairUnder value ) => value switch {
StairUnder.Filled => "square",
StairUnder.Cupboard => "door_front",
_ => "check_box_outline_blank"
};
public static string Fallback( StairGuard value ) => value switch {
StairGuard.Left => "align_horizontal_left",
StairGuard.Right => "align_horizontal_right",
StairGuard.None => "block",
_ => "fence"
};
}