Editor/Stair/ArchStairWindow.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
namespace Sunless.Architecture;
public sealed class ArchStairWindow : Window {
static ArchStairWindow standing;
readonly ArchTool tool;
readonly int stairId;
const string LiveKey = "Arch.Stair.Live";
const string WalkKey = "Arch.Stair.OpenOnWalk";
const string Climb = "The climb";
const string Whole = "The stair";
const int PanelWidth = 260;
ArchStairCanvas canvas;
Widget climbing;
ScrollArea settings;
ToolSidebarWidget sheet;
Layout steps;
Label caption;
SegmentedControl turns;
ArchNumberEdit angle;
Button build;
bool live = EditorCookie.Get( LiveKey, false );
int segments = 6;
float sweep = 90f;
public static void Open( ArchTool tool, ArchStairPart stair ) {
if ( tool is null || stair is null ) {
return;
}
standing?.Close();
standing = new ArchStairWindow( tool, stair );
standing.Show();
}
public static void OpenOnWalk( ArchTool tool, ArchStairPart stair ) {
if ( !EditorCookie.Get( WalkKey, true ) ) {
return;
}
Open( tool, stair );
}
ArchStairWindow( ArchTool tool, ArchStairPart stair ) {
this.tool = tool;
stairId = stair.Id;
DeleteOnClose = true;
WindowTitle = $"Stair — {stair.Name}";
Size = new Vector2( 980, 640 );
MinimumSize = new Vector2( 760, 480 );
Canvas = new Widget( this ) { Layout = Layout.Row() };
Canvas.Layout.Margin = 8;
Canvas.Layout.Spacing = 8;
BuildDrawing();
BuildPanel();
Rebuild();
}
ArchStairPart Subject() {
return tool?.Plan?.Parts<ArchStairPart>().FirstOrDefault( part => part.Id == stairId );
}
void BuildDrawing() {
var column = Canvas.Layout.AddColumn( 1 );
column.Spacing = 6;
var views = column.AddRow();
views.Spacing = 4;
var panes = new SegmentedControl( this );
foreach ( var view in Enum.GetValues<ArchStairView>() ) {
panes.AddOption( Describe( view ), Glyph( view ) );
}
panes.OnSelectedChanged = name => {
canvas.View = Enum.GetValues<ArchStairView>().First( view => Describe( view ) == name );
canvas.Update();
};
views.Add( panes );
views.AddStretchCell();
var profile = new Checkbox( "Profile" ) { Value = true };
profile.Toggled += () => {
canvas.Profile = profile.Value;
canvas.Update();
};
views.Add( profile );
var surrounds = new Checkbox( "Building" ) { Value = true };
surrounds.Toggled += () => {
canvas.Surrounds = surrounds.Value;
canvas.Update();
};
views.Add( surrounds );
var walked = new Checkbox( "Opens on a walk" ) { Value = EditorCookie.Get( WalkKey, true ) };
walked.Toggled += () => EditorCookie.Set( WalkKey, walked.Value );
views.Add( walked );
var living = new Checkbox( "Live" ) { Value = live };
living.Toggled += () => {
live = living.Value;
EditorCookie.Set( LiveKey, live );
if ( live ) {
Build();
}
};
views.Add( living );
build = new Button.Primary( "Build", "refresh" ) { Clicked = Build };
views.Add( build );
caption = new Label( "" );
views.Add( caption );
canvas = new ArchStairCanvas( this, tool, Subject, Preview, Commit ) {
Selected = _ => RebuildStack()
};
column.Add( canvas, 1 );
column.Add( new Label( "Drag a step's middle to move it — the steps around it give way — its head to lengthen "
+ "it, a flank to widen it, the pink pin past its head to aim it, and the green pin in an elevation to pin "
+ "its climb. Click a step in the plan or the profile to work on it." ) { WordWrap = true } );
}
static string Describe( ArchStairView view ) => view switch {
ArchStairView.Along => "Elevation",
ArchStairView.Across => "Section",
_ => "Plan"
};
static string Glyph( ArchStairView view ) => view switch {
ArchStairView.Along => "crop_landscape",
ArchStairView.Across => "flip",
_ => "grid_on"
};
static string Describe( StairTurn turn ) => turn switch {
StairTurn.Left => "Left",
StairTurn.Right => "Right",
StairTurn.Back => "Back",
_ => "Straight"
};
static string Glyph( StairTurn turn ) => turn switch {
StairTurn.Left => "turn_left",
StairTurn.Right => "turn_right",
StairTurn.Back => "u_turn_left",
_ => "straight"
};
void BuildPanel() {
var column = Canvas.Layout.AddColumn();
column.Spacing = 6;
var pages = new SegmentedControl( this );
pages.AddOption( Climb, "stairs" );
pages.AddOption( Whole, "tune" );
pages.OnSelectedChanged = name => Show( name == Climb );
column.Add( pages );
climbing = new Widget( this ) { Layout = Layout.Column() };
climbing.Layout.Spacing = 6;
climbing.MinimumWidth = PanelWidth;
climbing.MaximumWidth = PanelWidth;
BuildClimb( climbing.Layout );
column.Add( climbing, 1 );
settings = new ScrollArea( this );
sheet = new ToolSidebarWidget( settings );
settings.Canvas = sheet;
settings.MinimumWidth = PanelWidth;
settings.MaximumWidth = PanelWidth;
settings.Visible = false;
column.Add( settings, 1 );
}
void Show( bool climb ) {
climbing.Visible = climb;
settings.Visible = !climb;
if ( !climb ) {
BuildSheet();
}
}
// Same sheet the sidebar uses — one set of controls, two places to stand
void BuildSheet() {
if ( Subject() is not { } stair ) {
return;
}
sheet.Layout.Clear( true );
ArchStairUi.Build( sheet, stair, BuildSheet, Commit );
ArchStairUi.Sizes( sheet, stair, Commit );
sheet.Layout.AddStretchCell();
}
void BuildClimb( Layout column ) {
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll ) { Layout = Layout.Column() };
scroll.Canvas.Layout.Spacing = 2;
steps = scroll.Canvas.Layout;
column.Add( scroll, 1 );
var actions = column.AddRow();
actions.Spacing = 4;
actions.Add( new Button( "Flight", "stairs" ) { Clicked = AddFlight } );
actions.Add( new Button( "Platform", "horizontal_rule" ) { Clicked = AddPlatform } );
actions.Add( new Button( "Railing", "fence" ) { Clicked = AddRailing } );
column.Add( new Label( "Turn this step off the one below it" ) );
turns = new SegmentedControl( this );
foreach ( var turn in Enum.GetValues<StairTurn>() ) {
turns.AddOption( Describe( turn ), Glyph( turn ) );
}
turns.OnSelectedChanged = name =>
Turn( Enum.GetValues<StairTurn>().First( turn => Describe( turn ) == name ) );
column.Add( turns );
var aiming = column.AddRow();
aiming.Spacing = 4;
aiming.Add( new Label( "Angle" ) );
// Under the list, not in the row — row rebuilds on click would eat the caret
angle = new ArchNumberEdit( "", ArchGridService.FinestSize, 1f, "0.##", Bend ) {
PlaceholderText = "0"
};
angle.TextEdited += text => Bend( float.TryParse( text, out var typed ) ? typed : 0f );
aiming.Add( angle );
var cutting = column.AddRow();
cutting.Spacing = 4;
cutting.Add( new Button( "Segment", "content_cut" ) { Clicked = SegmentFlight } );
cutting.Add( new Button( "Delete", "backspace" ) { Clicked = DeleteStep } );
var curving = column.AddRow();
curving.Spacing = 4;
curving.Add( ArchPartUi.Integer( "Curve", segments, 6, value => segments = Math.Clamp( value, 2, 32 ) ) );
curving.Add( ArchPartUi.Number( "°", sweep, 90f, value => sweep = value ) );
curving.Add( new Button( "Curve", "turn_slight_right" ) { Clicked = CurveFlight } );
}
void RebuildStack() {
steps.Clear( true );
if ( Subject() is not { } stair ) {
return;
}
var climbs = ArchStairLanes.Climbs( stair.Core, stair.Lanes );
for ( var index = stair.Lanes.Count - 1; index >= 0; index-- ) {
steps.Add( Row( stair, index, climbs[index] ) );
}
steps.AddStretchCell();
ShowStep( stair );
}
void ShowStep( ArchStairPart stair ) {
var index = canvas.Picked;
var joint = index > 0 && index < stair.Lanes.Count;
turns.Enabled = joint;
if ( joint ) {
turns.Selected = Describe( stair.Lanes[index - 1].Walk.Between( stair.Lanes[index].Walk ) );
}
var flight = Held( stair ) is { Climbs: true } lane ? lane : null;
angle.Enabled = flight is not null;
angle.Text = flight is not null && MathF.Abs( flight.Bearing ) > 0.005f ? flight.Bearing.ToString( "0.##" ) : "";
}
Widget Row( ArchStairPart stair, int index, float climb ) {
var lane = stair.Lanes[index];
var picked = index == canvas.Picked;
var row = new Widget( this ) { Layout = Layout.Column() };
row.Layout.Margin = 6;
row.Layout.Spacing = 2;
var head = row.Layout.AddRow();
head.Spacing = 4;
head.Add( new Label( lane.Climbs
? $"Flight {ArchStairSteps.Ordinal( stair, lane )}"
: $"Platform {ArchStairSteps.Ordinal( stair, lane )}" ) );
head.AddStretchCell();
head.Add( new Label( lane.Climbs ? $"{climb:0} climb" : lane.Rise > 0.05f ? $"+{lane.Rise:0}" : "level" ) );
row.Layout.Add( new Label( $"{lane.Length:0} × {lane.Width:0}"
+ (MathF.Abs( lane.Bearing ) > 0.05f ? $" · {lane.Bearing:0}°" : "")
+ (lane.Guards.Count > 0 ? $" · {lane.Guards.Count} railing{(lane.Guards.Count == 1 ? "" : "s")}" : "") ) );
row.MouseLeftPress += () => {
canvas.Picked = index;
canvas.Update();
RebuildStack();
};
row.SetStyles( picked
? "background-color: #3b6ea5; border-radius: 3px;"
: "background-color: #2a2a2e; border-radius: 3px;" );
return row;
}
void Rebuild() {
if ( Subject() is null ) {
Close();
return;
}
Retitle();
RebuildStack();
canvas.Update();
}
void Retitle() {
if ( Subject() is not { } stair ) {
return;
}
var flights = stair.Lanes.Count( lane => lane.Climbs );
var pads = stair.Lanes.Count - flights;
var waiting = tool is { Unbuilt: true };
caption.Text = $"{flights} flight{(flights == 1 ? "" : "s")}"
+ (pads > 0 ? $" · {pads} platform{(pads == 1 ? "" : "s")}" : "")
+ $" · {stair.TotalRise:0} climb"
+ (waiting ? " · unbuilt" : "");
build.Enabled = waiting;
}
void Build() {
tool?.Build();
Retitle();
}
// Repaints drawing only — rebuilding the step list per mouse-move kills drag perf
void Preview() {
Retitle();
canvas.Update();
}
void Commit() {
Write();
Rebuild();
}
void Write() {
if ( tool?.Plan is null ) {
return;
}
Pierce();
if ( live ) {
tool.Commit( "Edit Stair" );
} else {
tool.Bank( "Edit Stair" );
}
}
void Bend( float degrees ) {
if ( Subject() is not { } stair || Held( stair ) is not { Climbs: true } lane ) {
return;
}
ArchStairSteps.Bend( stair, lane, degrees );
ArchStairLanes.Fit( stair.Core, stair.Lanes );
Write();
Preview();
}
void Pierce() {
if ( Subject() is not { } stair || ArchStairSteps.Home( tool.Plan, stair ) is not { } room ) {
return;
}
ArchPiercers.Pierce( tool.Plan, tool.Plan.OwnerOf( room ), room, stair, tool.Kit );
}
void AddFlight() {
if ( Subject() is not { } stair ) {
return;
}
var after = Held( stair ) ?? stair.Lanes.LastOrDefault();
if ( ArchStairSteps.Add( tool.Plan, stair, after ) is not { } flight ) {
return;
}
canvas.Picked = stair.Lanes.IndexOf( flight );
Commit();
}
void SegmentFlight() {
if ( Subject() is not { } stair || Held( stair ) is not { } lane ) {
return;
}
if ( !ArchStairSteps.Segment( tool.Plan, stair, lane, 2 ) ) {
Log.Info( "Architecture: only a flight with the run for two segments can be cut — lengthen it first." );
return;
}
Commit();
}
void CurveFlight() {
if ( Subject() is not { } stair || Held( stair ) is not { } lane ) {
return;
}
if ( !ArchStairSteps.Curve( tool.Plan, stair, lane, segments, sweep ) ) {
Log.Info( $"Architecture: a flight needs the run for {segments} segments to curve — lengthen it, or ask for fewer." );
return;
}
Commit();
}
void Turn( StairTurn turn ) {
if ( Subject() is not { } stair || Held( stair ) is not { } lane ) {
return;
}
if ( !ArchStairSteps.Aim( stair, lane, turn ) ) {
Log.Info( "Architecture: the first step aims the whole stair — turn one standing above it." );
return;
}
Commit();
}
void AddPlatform() {
if ( Subject() is not { } stair || Held( stair ) is not { } lane ) {
return;
}
if ( !lane.Climbs ) {
Log.Info( "Architecture: a platform is taken out of a FLIGHT — pick one and the space comes out of its run." );
return;
}
if ( ArchStairSteps.AddLanding( tool.Plan, stair, lane ) is null ) {
Log.Info( "Architecture: that flight has no run to spare — lengthen it first." );
return;
}
canvas.Picked = stair.Lanes.IndexOf( lane ) + 1;
Commit();
}
void AddRailing() {
if ( Subject() is not { } stair || Held( stair ) is not { } lane ) {
return;
}
lane.Guards.Add( new ArchStairGuardPart {
Id = tool.Plan.AllocateId(),
Edge = lane.Guarding( StairEdge.Left ) ? StairEdge.Right : StairEdge.Left
} );
canvas.PickedGuard = lane.Guards.Count - 1;
Commit();
}
void DeleteStep() {
if ( Subject() is not { } stair || Held( stair ) is not { } lane ) {
return;
}
if ( !ArchStairSteps.Remove( stair, lane ) ) {
Log.Info( "Architecture: a stair has to keep one step." );
return;
}
canvas.Picked = Math.Min( canvas.Picked, stair.Lanes.Count - 1 );
Commit();
}
ArchStairLane Held( ArchStairPart stair ) {
return canvas.Picked >= 0 && canvas.Picked < stair.Lanes.Count ? stair.Lanes[canvas.Picked] : null;
}
protected override void OnKeyPress( KeyEvent e ) {
if ( !e.HasCtrl || SceneEditorSession.Active is not { } session ) {
base.OnKeyPress( e );
return;
}
if ( e.Key == KeyCode.Z && !e.HasShift ) {
session.UndoSystem.Undo();
} else if ( e.Key == KeyCode.Y || (e.Key == KeyCode.Z && e.HasShift) ) {
session.UndoSystem.Redo();
} else {
base.OnKeyPress( e );
return;
}
e.Accepted = true;
canvas.Picked = Math.Min( canvas.Picked, (Subject()?.Lanes.Count ?? 0) - 1 );
Rebuild();
}
protected override void OnClosed() {
base.OnClosed();
if ( standing == this ) {
standing = null;
}
}
}