Editor UI builder for stair-related parts. It constructs sidebar groups and icon grids for stair settings (support, guard, railings, wrap, fill, under, sizes, approach and fittings) and wires UI controls to modify ArchStairPart and ArchApproachPart properties, invoking refresh/changed callbacks.
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.
public static void Build( ToolSidebarWidget panel, ArchStairPart stair, Action refresh, Action changed )
{
using ( var grid = ArchIconGrid.In( panel.AddGroup( "Carriage", collapsible: true ) ) )
{
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; refresh?.Invoke(); changed?.Invoke(); } );
}
}
var balustrade = panel.AddGroup( "Balustrade", collapsible: true );
using ( var grid = ArchIconGrid.In( balustrade ) )
{
foreach ( var value in Enum.GetValues<StairGuard>() )
{
var captured = value;
grid.Pick( Describe( captured ), $"guard_{captured}".ToLowerInvariant(), Fallback( captured ),
stair.Guard == captured, () => { stair.Guard = captured; refresh?.Invoke(); changed?.Invoke(); } );
}
}
if ( stair.Guard is StairGuard.Left or StairGuard.Both )
{
Railing( balustrade, "left", stair.LeftRailing, value => { stair.LeftRailing = value; changed?.Invoke(); } );
}
if ( stair.Guard is StairGuard.Right or StairGuard.Both )
{
Railing( balustrade, "right", stair.RightRailing, value => { stair.RightRailing = value; changed?.Invoke(); } );
}
using ( var rails = ArchIconGrid.In( balustrade ) )
{
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; refresh?.Invoke(); changed?.Invoke(); } );
}
using ( var wrap = ArchIconGrid.In( panel.AddGroup( "Wall wrap", collapsible: true ) ) )
{
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; refresh?.Invoke(); changed?.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; refresh?.Invoke(); changed?.Invoke(); } );
}
if ( stair.Wrap == StairWrap.Walls )
{
panel.AddGroup( "Wrap", collapsible: true ).Add( ArchPartUi.Number( "Wall thickness", stair.WrapThickness, 8f,
value => stair.WrapThickness = value, changed ) );
}
using ( var fill = ArchIconGrid.In( panel.AddGroup( "Built of", collapsible: true ) ) )
{
fill.Pick( "Open — treads on stringers over an open raked soffit, the joinery stair", "fill_open", "stairs",
stair.Core?.Fill != StairFill.Solid, () => { Filled( stair, StairFill.Open ); refresh?.Invoke(); changed?.Invoke(); } );
fill.Pick( "Solid — every flight a stepped mass standing on the shaft floor, the concrete stair", "fill_solid", "foundation",
stair.Core?.Fill == StairFill.Solid, () => { Filled( stair, StairFill.Solid ); refresh?.Invoke(); changed?.Invoke(); } );
}
if ( stair.Core?.Fill == StairFill.Solid )
{
return;
}
using var under = ArchIconGrid.In( panel.AddGroup( "Under the stairs", collapsible: true ) );
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(); } );
}
}
// 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(); } } );
}
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;
}
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(); } );
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"
};
}