An editor subtool for placing exterior wall fixtures (ladders, balconies, exterior stairs) in the architecture editor. It handles mode switching, preview/hover sketches, adopting existing parts, placing new parts on drag/release, and building the sidebar UI for placement, fittings and dimensions.
using System;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
public enum ArchFixtureMode
{
Ladder,
Balcony,
Escape
}
// Everything that hangs off an exterior wall is one tool, because the gesture is one gesture: click the
// face, and the part takes its anchor, its grade and its head from the wall it landed on. A shelf entry
// each for ladder, balcony and fire escape would be three tools that differ only in what they emit.
[Title( "Wall Fixtures" ), Icon( "table_rows" ), Group( "15" )]
public sealed class ArchFixtureSubtool( ArchTool owner, Action mergedRefresh = null ) : ArchSubtool( owner )
{
public ArchFixtureSubtool( ArchTool owner ) : this( owner, null )
{
}
protected override ArchKind? DraftKind => mode switch
{
ArchFixtureMode.Balcony => ArchKind.Balcony,
ArchFixtureMode.Escape => ArchKind.ExteriorStair,
_ => ArchKind.Ladder
};
public override ArchSurface[] Surfaces => new[] { ArchSurface.Railing, ArchSurface.Deck, ArchSurface.Soffit };
readonly ArchLadderPart ladder = new();
readonly ArchBalconyPart balcony = new();
readonly ArchExteriorStairPart flight = new();
ArchFixtureMode mode = ArchFixtureMode.Ladder;
object adopted;
bool seeded;
public object LastPlaced { get; private set; }
public ArchKind? MergedDraftKind => DraftKind;
public string MergedAdvice => Advice();
public void MergedEnable()
{
if ( seeded )
{
return;
}
seeded = true;
balcony.Reach = Owner.Kit.BalconyReach;
}
public void MergedAdopt( ArchSelection picked ) => Adopt( picked );
public void MergedHover( Vector2 point ) => Sketch( Station( point, point ) );
public void MergedPreview( Vector2 from, Vector2 to ) => Sketch( Station( from, to ) );
public object MergedPlace( Vector2 from, Vector2 to )
{
LastPlaced = null;
OnDrag( from, to );
return LastPlaced;
}
public void MergedOptions( ToolSidebarWidget panel ) => BuildOptions( panel );
// Extent is DRAGGED, so the widget owns it once a part is standing; typed boxes only seed the next one.
bool Seeding => adopted is null;
ArchLadderPart Ladder => adopted as ArchLadderPart ?? ladder;
ArchBalconyPart Balcony => adopted as ArchBalconyPart ?? balcony;
ArchExteriorStairPart Flight => adopted as ArchExteriorStairPart ?? flight;
// Dragged along the elevation, like every other placement tool: the drag names the station and, for the two
// fixtures whose width runs along the wall, the width too. A drag too short to have been meant as one is
// still a click at the typed width, so the old single-click gesture is intact.
protected override bool UsesDrag => true;
// Placed and then pulled about without leaving the tool: a balcony's reach is a face of its own box.
protected override bool Adjusts => true;
public override bool Adopts => true;
protected override void Adopt( ArchSelection picked )
{
adopted = picked?.Item is ArchLadderPart or ArchBalconyPart or ArchExteriorStairPart ? picked.Item : null;
mode = adopted switch
{
ArchBalconyPart => ArchFixtureMode.Balcony,
ArchExteriorStairPart => ArchFixtureMode.Escape,
ArchLadderPart => ArchFixtureMode.Ladder,
_ => mode
};
}
protected override string Title() => mode switch
{
ArchFixtureMode.Balcony => "Balcony",
ArchFixtureMode.Escape => "Exterior Stair",
_ => "Ladder"
};
protected override string Advice() => mode switch
{
ArchFixtureMode.Balcony =>
"Drag along an exterior wall to set the width, or click for the typed one. Reach out projects a deck, zero is a Juliet, reach in bites a loggia.",
ArchFixtureMode.Escape =>
"Drag along an exterior wall to place the foot. It lands on grade and climbs to this storey's plate — step the level up to climb further.",
_ => "Drag along an exterior wall to set the width, or click for the typed one. The top comes off its deck, parapet or wall plate."
};
// The stock comes from the kit, and a reach of zero is a Juliet rather than "not chosen yet", so the one
// extent that cannot answer for itself is seeded here.
public override void OnEnabled()
{
base.OnEnabled();
if ( seeded )
{
return;
}
seeded = true;
balcony.Reach = Owner.Kit.BalconyReach;
}
void Update()
{
if ( mergedRefresh is not null )
{
mergedRefresh();
return;
}
Refresh();
}
protected override void DrawHover( Vector2 point )
{
base.DrawHover( point );
Sketch( Station( point, point ) );
}
// The same resolve the release will place from, so the ghost under a live drag is the unit that lands.
protected override void DrawPreview()
{
base.DrawPreview();
Sketch( Station( DragStart, DragCurrent ) );
}
ArchFixtureStation? Station( Vector2 from, Vector2 to )
{
// A ladder is not scoped to a storey - it climbs the whole elevation and takes its top from whatever
// deck, parapet or plate it reaches.
var level = mode == ArchFixtureMode.Ladder ? (int?)null : Owner.Level;
return ArchFixtureAnchor.Station( Owner.Plan.Buildings, level, Owner.Grid, from, to, Typed() );
}
float Typed() => mode switch
{
ArchFixtureMode.Balcony => balcony.Width,
ArchFixtureMode.Escape => flight.Width,
_ => ladder.Width
};
void Sketch( ArchFixtureStation? at )
{
switch ( mode )
{
case ArchFixtureMode.Balcony:
HoverBalcony( at );
break;
case ArchFixtureMode.Escape:
HoverFlight( at );
break;
default:
HoverLadder( at );
break;
}
}
void HoverLadder( ArchFixtureStation? at ) => ArchLadders.Preview( Owner.Plan, Owner.Kit, at, ladder );
void HoverBalcony( ArchFixtureStation? at ) => ArchBalconies.Preview( Owner.Plan, Owner.Kit, at, balcony );
void HoverFlight( ArchFixtureStation? at )
{
ArchExteriorStairs.Preview( Owner.Plan, Owner.Kit, at, flight );
}
protected override void OnDrag( Vector2 from, Vector2 to )
{
var at = Station( from, to );
switch ( mode )
{
case ArchFixtureMode.Balcony:
PlaceBalcony( at );
break;
case ArchFixtureMode.Escape:
PlaceFlight( at );
break;
default:
PlaceLadder( at );
break;
}
}
void PlaceLadder( ArchFixtureStation? at )
{
if ( ArchLadders.Place( Owner.Plan, Owner.Kit, at, ladder, out var host ) is not { } placed )
{
Log.Info( "Architecture: no exterior wall near that for a ladder to climb — a partition has no outside to hang one on." );
return;
}
host.Ladders.Add( placed );
LastPlaced = placed;
Drew( placed );
FinishDraft( placed.Id );
Owner.Commit( "Place Ladder" );
}
void PlaceBalcony( ArchFixtureStation? at )
{
if ( ArchBalconies.Place( Owner.Plan, Owner.Kit, at, balcony, out var host ) is not { } placed )
{
Log.Info( "Architecture: no exterior wall on this storey near that for a balcony to hang off." );
return;
}
host.Balconies.Add( placed );
LastPlaced = placed;
Drew( placed );
FinishDraft( placed.Id );
Owner.Commit( "Place Balcony" );
}
void PlaceFlight( ArchFixtureStation? at )
{
if ( ArchExteriorStairs.Place( Owner.Plan, Owner.Kit, at, flight, out var host ) is not { } placed )
{
Log.Info( "Architecture: no exterior wall on this storey near that for an exterior stair to hang on." );
return;
}
host.ExteriorStairs.Add( placed );
LastPlaced = placed;
Drew( placed );
FinishDraft( placed.Id );
Owner.Commit( "Place Exterior Stair" );
}
protected override void BuildOptions( ToolSidebarWidget panel )
{
BuildFixture( panel );
ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", Seeding, BuildPlacement );
ArchSidebarSection.Show( panel, Scope( "fittings" ), "Fittings", BuildFittings );
ArchSidebarSection.Disclosure( panel, Scope( "dimensions" ), "Dimensions", true, BuildDimensions );
}
// One grid, three modes: a balcony and a fire escape are what this tool emits, not tools of their own.
void BuildFixture( ToolSidebarWidget panel )
{
ArchSidebarSection.Show( panel, Scope( "fixture" ), "Fixture", group =>
{
using var grid = ArchIconGrid.In( group );
grid.Pick( "Ladder — stiles and rungs held off the face, cage optional", "fixture_ladder", "reorder",
mode == ArchFixtureMode.Ladder, () => Switch( ArchFixtureMode.Ladder ) );
grid.Pick( "Balcony — a hung deck with a balustrade; no reach is a Juliet, reach inward is a loggia", "fixture_balcony", "crop_square",
mode == ArchFixtureMode.Balcony, () => Switch( ArchFixtureMode.Balcony ) );
grid.Pick( "Exterior stair — a switchback fire escape landing once per storey its rise passes", "fixture_escape", "stairs",
mode == ArchFixtureMode.Escape, () => Switch( ArchFixtureMode.Escape ) );
} );
}
void Switch( ArchFixtureMode wanted )
{
mode = wanted;
adopted = null;
Update();
}
// Width is what a CLICK falls back to - a drag along the wall overrides it - so it is labelled as the
// fallback rather than as the authority, or the panel reads as though the drag were being ignored.
void BuildPlacement( Layout group )
{
switch ( mode )
{
case ArchFixtureMode.Balcony:
group.Add( ArchPartUi.Number( "Click width", balcony.Width, 96f, value => balcony.Width = value ) );
group.Add( ArchPartUi.Number( "Reach", balcony.Reach, Owner.Kit.BalconyReach, value => { balcony.Reach = value; Update(); } ) );
group.Add( ArchPartUi.Number( "Lift off the floor", balcony.Lift, 0f, value => balcony.Lift = value ) );
break;
case ArchFixtureMode.Escape:
// Its Width measures off the FACE, not along the wall, so no drag sets it.
group.Add( ArchPartUi.Number( "Standoff", flight.Width, Owner.Kit.EscapeWidth, value => flight.Width = value ) );
break;
default:
group.Add( ArchPartUi.Number( "Click width", ladder.Width, 18f, value => ladder.Width = value ) );
break;
}
}
void BuildFittings( Layout group )
{
using ( var grid = ArchIconGrid.In( group ) )
{
switch ( mode )
{
case ArchFixtureMode.Balcony:
grid.Toggle( "Balustrade round the free edges", "opt_balcony_rail", "fence", Balcony.Rail,
value => { Balcony.Rail = value; Changed(); Update(); } );
grid.Toggle( "Coping nosing round the deck", "opt_balcony_coping", "layers", Balcony.Coping,
value => { Balcony.Coping = value; Changed(); } );
break;
case ArchFixtureMode.Escape:
grid.Toggle( "Handrail up the rake and round every landing", "opt_escape_rail", "fence", Flight.Rail,
value => { Flight.Rail = value; Changed(); } );
break;
default:
grid.Toggle( "Hooped safety cage over the upper climb", "opt_ladder_cage", "grid_3x3", Ladder.Cage,
value => { Ladder.Cage = value; Changed(); Update(); } );
grid.Toggle( "Grab rails returning over the head onto the deck", "opt_ladder_return", "u_turn_left", Ladder.Return,
value => { Ladder.Return = value; Changed(); } );
break;
}
}
if ( mode == ArchFixtureMode.Ladder && Ladder.Cage )
{
group.Add( ArchPartUi.Number( "Cage starts at", Ladder.CageFrom, 84f, value => Ladder.CageFrom = value, Changed ) );
}
}
void BuildDimensions( Layout group )
{
switch ( mode )
{
case ArchFixtureMode.Balcony:
group.Add( ArchPartUi.Number( "Slab", Balcony.Thickness, Owner.Kit.BalconySlab, value => Balcony.Thickness = value, Changed ) );
group.Add( ArchPartUi.Number( "Rail height", Balcony.RailHeight, Owner.Kit.BalconyRail, value => Balcony.RailHeight = value, Changed ) );
// On a standing balcony the width is its own, not the seed the next drag falls back to.
if ( !Seeding )
{
group.Add( ArchPartUi.Number( "Width", Balcony.Width, 96f, value => Balcony.Width = value, Changed ) );
group.Add( ArchPartUi.Number( "Lift off the floor", Balcony.Lift, 0f, value => Balcony.Lift = value, Changed ) );
}
break;
case ArchFixtureMode.Escape:
group.Add( ArchPartUi.Number( "Landing", Flight.Reach, Owner.Kit.EscapeLanding, value => Flight.Reach = value, Changed ) );
group.Add( ArchPartUi.Number( "Storey", Flight.StoreyHeight, Owner.Kit.WallHeight + Owner.Kit.FloorThickness,
value => Flight.StoreyHeight = value, Changed ) );
break;
default:
group.Add( ArchPartUi.Number( "Standoff", Ladder.Standoff, 7f, value => Ladder.Standoff = value, Changed ) );
group.Add( ArchPartUi.Number( "Rung pitch", Ladder.RungSpacing, 12f, value => Ladder.RungSpacing = value, Changed ) );
break;
}
}
// A control pointed at the SELECTION has to apply; one pointed at the seed has nothing to apply to.
void Changed()
{
if ( adopted is not null )
{
Owner.Touch( "Edit Wall Fixture" );
}
}
}