Editor/Tool/Subtools/ArchStairSubtool.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
[Title( "Stair" ), Icon( "stairs" ), Group( "10" )]
public sealed class ArchStairSubtool( ArchTool owner ) : ArchSubtool( owner ) {
protected override ArchKind? DraftKind => ArchKind.Stair;
enum DrivewayConnectionKind {
Wall,
Street
}
sealed class DrivewayConnection {
public DrivewayConnectionKind Kind { get; init; }
public Vector2 Position { get; init; }
public Vector2 Direction { get; init; }
public float Gap { get; init; }
public ArchBuilding Building { get; init; }
public ArchRoom Room { get; init; }
public ArchWall Wall { get; init; }
public float Along { get; init; }
public ArchRoadPart Road { get; init; }
public ArchFrame RoadFrame { get; init; }
}
public override ArchSurface[] Surfaces => new[] { ArchSurface.StairTread, ArchSurface.StairRiser, ArchSurface.StairStringer, ArchSurface.Railing };
public override ArchViewAxis[] Works => new[] { ArchViewAxis.Free, ArchViewAxis.Top, ArchViewAxis.Front, ArchViewAxis.Side };
// Only adjust this tool's own kinds — other selections' gizmos would eat placement gestures
protected override bool Adjusts => Owner.Picked?.Item is ArchStairPart or ArchApproachPart;
readonly ArchStairPart draft = new();
readonly ArchApproachPart approachDraft = new();
readonly List<ArchCurveNode> drivewayNodes = new();
readonly List<Vector3> walk = new();
bool splineDriveway;
DrivewayConnection drivewayStart;
DrivewayConnection drivewayEnd;
bool enterDown;
bool escapeDown;
ApproachKind? approach;
bool carving;
bool walking = true;
ArchStairPart continuing;
StairFill fill;
bool snapStoreys = true;
int stories = 1;
bool cutStairwell = true;
bool seededRise;
float flightRise = 128f;
protected override bool UsesDrag => !walking && (approach != ApproachKind.Ramp || !splineDriveway);
protected override bool TakesFreeClick => walking;
public override void OnEnabled() {
base.OnEnabled();
ArchWorkflow.Restart( Scope( "flow" ) );
}
public override void OnUpdate() {
base.OnUpdate();
var enter = global::Editor.Application.IsKeyDown( KeyCode.Enter ) || global::Editor.Application.IsKeyDown( KeyCode.Return );
var escape = global::Editor.Application.IsKeyDown( KeyCode.Escape );
if ( walking ) {
if ( enter && !enterDown ) {
FinishWalk();
}
if ( escape && !escapeDown ) {
CancelWalk();
}
}
if ( approach == ApproachKind.Ramp && splineDriveway ) {
if ( enter && !enterDown ) {
TryFinishSplineDriveway();
}
if ( escape && !escapeDown ) {
CancelSplineDriveway();
}
}
enterDown = enter;
escapeDown = escape;
}
protected override string Title() => walking ? continuing is null ? "Stair" : "Continue Stair" : carving ? "Concrete Steps" : approach switch {
ApproachKind.Steps => "Foundation Steps",
ApproachKind.Ramp when splineDriveway => "Spline Driveway",
ApproachKind.Ramp => "Driveway Ramp",
_ => "Stair"
};
protected override ArchRunState Run() {
if ( walking ) {
return new ArchRunState( walk.Count > 0, $"{walk.Count} point{(walk.Count == 1 ? "" : "s")}" );
}
var spline = approach == ApproachKind.Ramp && splineDriveway;
return new ArchRunState( spline && drivewayNodes.Count > 0,
$"{drivewayNodes.Count} point{(drivewayNodes.Count == 1 ? "" : "s")}" );
}
protected override void FinishRun() {
if ( walking ) {
FinishWalk();
return;
}
TryFinishSplineDriveway();
}
protected override void CancelRun() {
if ( walking ) {
CancelWalk();
return;
}
CancelSplineDriveway();
}
protected override string Advice() => walking
? WalkAdvice()
: carving
? "Drag across a platform to cut the flight out of it."
: approach switch {
ApproachKind.Steps => "Drag the footprint outside a door; it snaps onto the nearest wall.",
ApproachKind.Ramp when splineDriveway => "Click control points from one connection to the other.",
ApproachKind.Ramp => "Drag the footprint at a garage; it snaps square onto the nearest wall.",
_ => "Drag the stair's shaft the way you drag a building."
};
void DrawApproachPreview() {
var drag = Draft( DragStart, DragCurrent );
if ( drag.Placed is not { } draft ) {
ArchGhost.Note( new Vector3( DragCurrent.x, DragCurrent.y, Owner.LevelHeight ), drag.Refused );
return;
}
var room = drag.Room ?? new ArchRoom { Floor = Owner.Level, BaseHeight = Owner.LevelHeight };
var building = Owner.Plan.OwnerOf( drag.Room ) ?? drag.On ?? Owner.ActiveBuilding();
var rise = ArchApproachGen.Rise( room, building, Owner.Kit, Owner.Plan );
var lift = ArchAsks.Lift( Owner.Plan, building, Owner.Kit );
var outward = ArchApproachAxes.Outward( draft );
var across = new Vector2( -outward.y, outward.x ) * (draft.Width * 0.5f);
var foot = draft.Origin + outward * draft.Run;
ArchGhost.Ring( new List<Vector2>
{
draft.Origin + across,
foot + across,
foot - across,
draft.Origin - across
}, room.BaseHeight + lift - rise );
var change = rise < 0f ? $"drops {MathF.Abs( rise ):0}" : $"rises {rise:0}";
ArchGhost.Note( new Vector3( foot.x, foot.y, room.BaseHeight + lift ),
$"{draft.Kind} · {change} · run {draft.Run:0} · {draft.Width:0} wide" );
}
ArchApproachDrag Draft( Vector2 from, Vector2 to ) {
var splay = Owner.FindArchetype( Owner.ActiveBuilding()?.Archetype )?.DriveSplay ?? approachDraft.Splay;
return ArchApproachPlacement.Dragged( Owner.Plan, Owner.Kit, Owner.Level, from, to,
approach ?? ApproachKind.Steps, approachDraft.Width, approachDraft.Kerbs, approachDraft.Rails, splay,
approachDraft.Against );
}
bool PlaceApproach( Vector2 from, Vector2 to ) {
var drag = Draft( from, to );
if ( drag.Placed is not { } draft ) {
Log.Info( $"Architecture: {drag.Refused}." );
return true;
}
var room = drag.Room ?? Owner.EnsureRoom( drag.On );
draft.Id = Owner.Plan.AllocateId();
draft.Name = $"{draft.Kind}{room.Approaches.Count + 1}";
room.Approaches.Add( draft );
FinishDraft( draft.Id );
Owner.Commit( $"Place {draft.Kind}" );
return true;
}
float ChainRise() => snapStoreys ? stories * Owner.StoreyHeight : MathF.Max( 1f, flightRise );
ArchStairPart FlightPart( ArchStairCore core, List<ArchStairLane> lanes, ArchRoom room ) {
return new ArchStairPart {
BaseHeight = room?.BaseHeight ?? Owner.LevelHeight,
Core = core,
Lanes = lanes,
Width = draft.Width,
StepRise = draft.StepRise,
StepGoing = draft.StepGoing,
WellGap = draft.WellGap,
Support = draft.Support,
AutoRailings = draft.AutoRailings,
LeftRailing = draft.LeftRailing,
RightRailing = draft.RightRailing,
Under = draft.Under,
Guard = draft.Guard,
WallRail = draft.WallRail,
Wrap = draft.Wrap,
WrapThickness = draft.WrapThickness
};
}
bool Aimed( out Vector3 point ) {
var ray = Gizmo.CurrentRay;
if ( Owner.FacesUnder( ray ).FirstOrDefault() is { } face ) {
var facing = Vector3.Dot( face.Normal, ray.Forward );
if ( MathF.Abs( facing ) > 0.0001f ) {
var along = Vector3.Dot( face.Centre - ray.Position, face.Normal ) / facing;
return Seated( ray.Position + ray.Forward * along, out point );
}
}
var trace = Owner.Scene.Trace.Ray( ray.Position, ray.Position + ray.Forward * 32768f ).Run();
if ( trace.Hit ) {
return Seated( trace.EndPosition, out point );
}
if ( Owner.Cursor( out var cursor ) ) {
point = new Vector3( cursor.Plan.x, cursor.Plan.y, cursor.Height );
return true;
}
point = default;
return false;
}
bool Seated( Vector3 hit, out Vector3 point ) {
var flat = Owner.Snap( new Vector2( hit.x, hit.y ) );
var storey = Owner.Plan.AllRooms().Where( entry => entry.Floor == Owner.Level );
if ( Owner.SnapsToWalls
&& ArchWallSnap.NearestWallFace( storey, flat, Owner.Kit.WallThickness, out var distance, out var onFace ) is not null
&& distance < Owner.SnapReach().Face ) {
flat = onFace;
}
point = new Vector3( flat.x, flat.y, ArchGridService.Fine( hit.z ) );
return true;
}
ArchStairPart Continued( Vector3 point ) {
var reach = MathF.Max( 24f, Owner.Kit.GridSize );
foreach ( var room in Owner.Plan.AllRooms() ) {
foreach ( var stair in room.Stairs ) {
if ( ArchStairWalk.Head( stair, out var head ) && (head - point).Length <= reach ) {
return stair;
}
}
}
return null;
}
ArchStairPart Previewed( IReadOnlyList<Vector3> line, ArchRoom room ) {
if ( continuing is null ) {
var walked = ArchStairWalk.Sketch( line, draft.Width, ChainRise() );
return walked.Stands ? FlightPart( walked.Core, walked.Lanes, room ) : null;
}
var copy = FlightPart( continuing.Core.Copy(), continuing.Lanes.Select( lane => lane.Copy() ).ToList(), room );
copy.BaseHeight = continuing.BaseHeight;
return ArchStairWalk.Extend( copy, line, draft.Width ) ? copy : null;
}
protected override void DrawFreeHover() {
if ( !Aimed( out var point ) ) {
return;
}
var line = new List<Vector3>( walk ) { point };
var marker = MathF.Max( 6f, draft.Width * 0.12f );
for ( var index = 0; index < line.Count; index++ ) {
var node = line[index];
var flat = new Vector2( node.x, node.y );
ArchGhost.Quad(
ArchFootprint.Rect( flat - new Vector2( marker, marker ), flat + new Vector2( marker, marker ) ),
node.z,
ArchGhost.Nth( index ) );
}
if ( walk.Count == 0 ) {
ArchGhost.Note( point, Continued( point ) is { } stair ? $"continue {stair.Name}" : "click the foot of the stair" );
return;
}
var room = HostRoom( new Vector2( line[0].x, line[0].y ) );
if ( room is null || Previewed( line, room ) is not { } preview ) {
ArchGhost.Note( point, room is null
? "no building here — walk the stair inside or against one"
: "walk further — a flight needs a run" );
return;
}
var shape = ArchStairShape.Resolve( preview, room, Owner.Kit );
var flights = preview.Lanes.Count( lane => lane.Climbs );
var pads = preview.Lanes.Count - flights;
ArchStairGhost.Blueprint( preview );
Gizmo.Draw.Color = ArchGhost.Ground;
foreach ( var well in shape.Levels ) {
foreach ( var outline in well.Loops ) {
ArchGhost.Ring( outline, well.Height );
}
}
Gizmo.Draw.Color = ArchGhost.Line;
ArchGhost.Note( point, $"{flights} flight{(flights == 1 ? "" : "s")}"
+ (pads > 0 ? $" · {pads} platform{(pads == 1 ? "" : "s")}" : "")
+ $" · {preview.TotalRise:0} climb" );
}
protected override void OnFreeClick() {
if ( !Aimed( out var point ) ) {
return;
}
if ( walk.Count == 0 ) {
continuing = Continued( point );
if ( continuing is not null && ArchStairWalk.Head( continuing, out var head ) ) {
walk.Add( head );
return;
}
}
Owner.EnsureTarget();
walk.Add( point );
}
void FinishWalk() {
if ( walk.Count < 2 ) {
CancelWalk();
return;
}
if ( continuing is not null ) {
ExtendWalk();
return;
}
if ( HostRoom( new Vector2( walk[0].x, walk[0].y ) ) is not { } room ) {
Log.Info( "Architecture: the walk is outside every building — start it inside or against one so the stair has a room to stand in." );
CancelWalk();
return;
}
if ( Previewed( walk, room ) is not { } stair ) {
Log.Info( $"Architecture: that walk is too short — a flight needs {ArchStairLanes.MinLane:0} inches of run." );
CancelWalk();
return;
}
stair.Id = Owner.Plan.AllocateId();
stair.Name = $"Stair{room.Stairs.Count + 1}";
stair.Core.Fill = fill;
ArchStairLanes.Number( Owner.Plan, stair );
room.Stairs.Add( stair );
if ( cutStairwell ) {
ArchStairShape.Pierce( Owner.Plan, Owner.Plan.OwnerOf( room ) ?? Owner.ActiveBuilding(), room, stair, Owner.Kit );
}
FinishDraft( stair.Id );
Owner.Picked = new ArchSelection { Item = stair, Room = room, Building = Owner.Plan.OwnerOf( room ) };
Drew( stair );
Owner.Commit( "Walk Stair" );
CancelWalk();
ArchStairWindow.OpenOnWalk( Owner, stair );
}
void ExtendWalk() {
if ( !ArchStairWalk.Extend( continuing, walk, draft.Width ) ) {
CancelWalk();
return;
}
ArchStairLanes.Number( Owner.Plan, continuing );
FinishDraft( continuing.Id );
Owner.Commit( "Continue Stair" );
CancelWalk();
}
void CancelWalk() {
walk.Clear();
continuing = null;
}
protected override void OnClick( Vector2 point ) {
if ( approach != ApproachKind.Ramp || !splineDriveway ) {
return;
}
if ( drivewayStart is null ) {
if ( ResolveDrivewayConnection( point, out drivewayStart ) ) {
drivewayNodes.Add( DrivewayNode( drivewayStart.Position ) );
}
return;
}
if ( ResolveDrivewayConnection( point, out var end ) && end.Kind != drivewayStart.Kind ) {
drivewayNodes.Add( DrivewayNode( end.Position ) );
drivewayEnd = end;
return;
}
drivewayNodes.Add( DrivewayNode( point ) );
drivewayEnd = null;
}
protected override void DrawHover( Vector2 point ) {
if ( approach != ApproachKind.Ramp || !splineDriveway ) {
base.DrawHover( point );
return;
}
base.DrawHover( point );
if ( drivewayStart is null ) {
var message = ResolveDrivewayConnection( point, out var connection )
? $"start at {connection.Kind.ToString().ToLowerInvariant()}"
: "start on an exterior wall or street edge";
ArchGhost.Note( new Vector3( point.x, point.y, Owner.LevelHeight ), message );
return;
}
var target = point;
var completing = ResolveDrivewayConnection( point, out var end ) && end.Kind != drivewayStart.Kind;
if ( completing ) {
target = end.Position;
}
var line = drivewayNodes.Select( node => node.Copy() ).ToList();
line.Add( DrivewayNode( target ) );
if ( drivewayStart.Kind == DrivewayConnectionKind.Street ) {
line.Reverse();
}
var curve = ArchCurve.Of( line );
var frames = curve.Walk( MathF.Max( 8f, Owner.Kit.GridSize * 0.5f ) );
ArchGhost.Curve( curve );
DrawDrivewayWidth( frames, approachDraft.Width );
var note = completing ? $"connect to {end.Kind.ToString().ToLowerInvariant()}" : "click a control point";
ArchGhost.Note( new Vector3( target.x, target.y, Owner.LevelHeight ), note );
}
void FinishSplineDriveway( DrivewayConnection end ) {
var wall = drivewayStart.Kind == DrivewayConnectionKind.Wall ? drivewayStart : end;
var street = drivewayStart.Kind == DrivewayConnectionKind.Street ? drivewayStart : end?.Kind == DrivewayConnectionKind.Street ? end : null;
if ( wall is null || drivewayNodes.Count < 2 ) {
return;
}
var line = drivewayNodes.Select( node => node.Copy() ).ToList();
if ( drivewayStart.Kind == DrivewayConnectionKind.Street ) {
line.Reverse();
}
var splay = Owner.FindArchetype( wall.Building?.Archetype )?.DriveSplay ?? approachDraft.Splay;
var placed = ArchApproachPlacement.Seat(
wall.Wall, wall.Room, wall.Building, Owner.Kit, wall.Along, ApproachKind.Ramp,
approachDraft.Width, 0f, approachDraft.Kerbs, false, splay );
line[0].Position = new Vector3( placed.Origin.x, placed.Origin.y, wall.Room.BaseHeight );
if ( street is not null ) {
line[^1].Position = new Vector3( street.Position.x, street.Position.y, 0f );
}
GradeSplineDriveway( line, wall, street );
AlignSplineDrivewayEnds( line, wall.Direction, street?.Direction ?? FreeEndDirection( line ) );
placed.Id = Owner.Plan.AllocateId();
placed.Name = $"SplineRamp{wall.Room.Approaches.Count + 1}";
placed.RoadId = street?.Road.Id ?? 0;
placed.Nodes = line;
placed.Run = placed.Curve().Length;
wall.Room.Approaches.Add( placed );
FinishDraft( placed.Id );
Owner.Commit( "Place Spline Driveway" );
CancelSplineDriveway();
}
void TryFinishSplineDriveway() {
if ( drivewayStart is null || drivewayNodes.Count < 2 ) {
return;
}
if ( drivewayStart.Kind == DrivewayConnectionKind.Street && drivewayEnd?.Kind != DrivewayConnectionKind.Wall ) {
return;
}
FinishSplineDriveway( drivewayEnd );
}
void GradeSplineDriveway( List<ArchCurveNode> line, DrivewayConnection wall, DrivewayConnection street ) {
var lift = ArchAsks.Lift( Owner.Plan, wall.Building, Owner.Kit );
var startHeight = wall.Room.BaseHeight;
var endPoint = Flat( line[^1] );
var endHeight = street is not null
? street.RoadFrame.Position.z + (ArchAnswers.Load().Asks<IArchApproachLanding>()?.Back( street.Road, Owner.Kit ) ?? 0f) - lift
: ArchTerrain.Sample( Owner.Scene, endPoint, out var terrainHeight ) ? terrainHeight - lift : -lift;
var distances = new float[line.Count];
for ( var index = 1; index < line.Count; index++ ) {
distances[index] = distances[index - 1] + (Flat( line[index] ) - Flat( line[index - 1] )).Length;
}
var length = MathF.Max( 1f, distances[^1] );
for ( var index = 0; index < line.Count; index++ ) {
var position = line[index].Position;
position.z = ArchGridService.Fine( MathX.Lerp( startHeight, endHeight, distances[index] / length ) );
line[index].Position = position;
}
}
void AlignSplineDrivewayEnds( List<ArchCurveNode> line, Vector2 wallDirection, Vector2 streetDirection ) {
if ( line.Count < 2 ) {
return;
}
var grid = Owner.Grid;
var firstReach = MathF.Max( grid.SubgridSize( 4 ), (Flat( line[1] ) - Flat( line[0] )).Length / 3f );
var lastReach = MathF.Max( grid.SubgridSize( 4 ), (Flat( line[^1] ) - Flat( line[^2] )).Length / 3f );
var flatLength = MathF.Max( 1f, line.Zip( line.Skip( 1 ), ( from, to ) => (Flat( to ) - Flat( from )).Length ).Sum() );
var slope = (line[^1].Position.z - line[0].Position.z) / flatLength;
line[0].Mode = ArchTangentMode.Split;
line[0].Out = new Vector3( wallDirection.x * firstReach, wallDirection.y * firstReach, slope * firstReach );
line[^1].Mode = ArchTangentMode.Split;
line[^1].In = new Vector3( -streetDirection.x * lastReach, -streetDirection.y * lastReach, -slope * lastReach );
}
static Vector2 FreeEndDirection( IReadOnlyList<ArchCurveNode> line ) {
var direction = Flat( line[^1] ) - Flat( line[^2] );
return direction.IsNearZeroLength ? new Vector2( 1f, 0f ) : direction.Normal;
}
bool ResolveDrivewayConnection( Vector2 point, out DrivewayConnection connection ) {
var wallFound = ResolveDrivewayWall( point, out var wall );
var streetFound = ResolveDrivewayStreet( point, out var street );
if ( wallFound && (!streetFound || wall.Gap <= street.Gap) ) {
connection = wall;
return true;
}
connection = street;
return streetFound;
}
bool ResolveDrivewayWall( Vector2 point, out DrivewayConnection connection ) {
connection = null;
var wall = ArchApproachPlacement.NearestExteriorWall(
Owner.Plan.Buildings, Owner.Level, point,
out var building, out var room, out var along, out var distance, out var outward );
if ( wall is null || distance > MathF.Max( 48f, Owner.Kit.GridSize ) ) {
return false;
}
var seated = ArchApproachPlacement.Seat(
wall, room, building, Owner.Kit, along, ApproachKind.Ramp,
approachDraft.Width, 0f, approachDraft.Kerbs, false, approachDraft.Splay );
connection = new DrivewayConnection {
Kind = DrivewayConnectionKind.Wall,
Position = seated.Origin,
Direction = outward,
Gap = distance,
Building = building,
Room = room,
Wall = wall,
Along = along
};
return true;
}
bool ResolveDrivewayStreet( Vector2 point, out DrivewayConnection connection ) {
connection = null;
var nearest = float.MaxValue;
foreach ( var road in Owner.Plan.Roads() ) {
var curve = Owner.Resolved( road.Id, road.Curve );
if ( !curve.Nearest( point, out var frame, out var centreGap ) ) {
continue;
}
var across = new Vector2( frame.Across.x, frame.Across.y );
var right = Vector2.Dot( point - frame.Flat, across ) >= 0f;
var reach = road.Reach( right, frame.WidthScale );
var edgeGap = MathF.Abs( centreGap - reach );
if ( centreGap > reach + MathF.Max( 48f, Owner.Kit.GridSize ) || edgeGap >= nearest ) {
continue;
}
var station = Owner.Grid.Subgrid( frame.Distance, 4 );
if ( !curve.Sample( station, out frame ) ) {
continue;
}
var side = right ? 1f : -1f;
var edge = frame.Side( side * road.Reach( right, frame.WidthScale ) );
nearest = edgeGap;
connection = new DrivewayConnection {
Kind = DrivewayConnectionKind.Street,
Position = ArchGridService.Fine( new Vector2( edge.x, edge.y ) ),
Direction = right ? -across : across,
Gap = edgeGap,
Road = road,
RoadFrame = frame
};
}
return connection is not null;
}
ArchCurveNode DrivewayNode( Vector2 point ) {
var snapped = Owner.Grid.CurveControl( new Vector3( point.x, point.y, Owner.LevelHeight ) );
return ArchCurveNode.At( snapped );
}
void CancelSplineDriveway() {
drivewayNodes.Clear();
drivewayStart = null;
drivewayEnd = null;
}
static Vector2 Flat( ArchCurveNode node ) => new( node.Position.x, node.Position.y );
static void DrawDrivewayWidth( IReadOnlyList<ArchFrame> frames, float width ) {
for ( var index = 0; index < frames.Count - 1; index++ ) {
var fromHalf = MathF.Max( 12f, width * frames[index].WidthScale ) * 0.5f;
var toHalf = MathF.Max( 12f, width * frames[index + 1].WidthScale ) * 0.5f;
Gizmo.Draw.Line( frames[index].Side( -fromHalf ), frames[index + 1].Side( -toHalf ) );
Gizmo.Draw.Line( frames[index].Side( fromHalf ), frames[index + 1].Side( toHalf ) );
}
}
protected override void DrawPreview() {
if ( carving ) {
DrawCarvePreview();
return;
}
DrawApproachPreview();
}
protected override void OnDrag( Vector2 from, Vector2 to ) {
if ( carving ) {
CarveSteps( from, to );
return;
}
PlaceApproach( from, to );
}
void DrawCarvePreview() {
var drag = ArchCarveHosts.Aim( Owner.Plan, Owner.Level, DragStart, DragCurrent );
var note = new Vector3( DragCurrent.x, DragCurrent.y, drag.Host?.TopHeight ?? Owner.LevelHeight );
if ( !drag.Lands ) {
ArchGhost.Plate( Min( DragStart, DragCurrent ), Max( DragStart, DragCurrent ), Owner.LevelHeight, 8 );
ArchGhost.Note( note, "nothing here to carve into — stand a platform first" );
return;
}
ArchGhost.Ring( drag.Host.Outline(), drag.Host.TopHeight );
if ( ArchStairCarve.Sketch( drag, Owner.Kit, draft ) is not { } carved ) {
ArchGhost.Note( note, $"{drag.Host.Name} — drag further to carve steps into it" );
return;
}
ArchStairGhost.Flight( ArchStairCarve.Resolve( drag.Host, carved, Owner.ActiveBuilding(), Owner.Kit ), drag.Host.TopHeight );
ArchGhost.Note( note, $"Concrete steps · {carved.Lanes.Count} flight carved into {drag.Host.Name}" );
}
void CarveSteps( Vector2 from, Vector2 to ) {
var drag = ArchCarveHosts.Aim( Owner.Plan, Owner.Level, from, to );
if ( !drag.Lands ) {
Log.Info( "Architecture: Concrete steps are cut OUT of a platform's concrete — stand one with Boolean Modifiers first, then drag across it." );
return;
}
if ( ArchStairCarve.Cut( Owner.Plan, drag, Owner.Kit, draft ) is not { } carved ) {
Log.Info( $"Architecture: that run is shorter than {ArchStairCarve.MinRun} inches, so there is nothing to cut." );
return;
}
FinishDraft( carved.Id );
Owner.Commit( "Carve Steps" );
}
void PunchToTop() {
var building = Owner.ActiveBuilding();
if ( building is null ) {
return;
}
var top = building.Rooms.Count > 0 ? building.Rooms.Max( entry => entry.Floor ) : Owner.Level;
var head = ArchStairShape.FloorOf( building, Owner.Kit, top + 1 );
flightRise = MathF.Max( 1f, head - Owner.LevelHeight );
stories = Math.Max( 1, (int)MathF.Round( flightRise / MathF.Max( 1f, Owner.StoreyHeight ) ) );
Refresh();
}
ArchRoom HostRoom( Vector2 point ) {
var snap = MathF.Max( 48f, Owner.Kit.GridSize );
if ( ArchApproachPlacement.NearestExteriorWall( Owner.Plan.Buildings, Owner.Level, point,
out var building, out var room, out _, out var distance, out _ ) is not null && distance < snap && room is not null ) {
return room;
}
return Owner.RoomOnLevel( point ) ?? Owner.ActiveRoom();
}
protected override void BuildOptions( ToolSidebarWidget panel ) {
SeedRise();
approachDraft.Kind = approach ?? ApproachKind.Steps;
using var flow = ArchWorkflow.In( panel, Scope( "flow" ), Refresh );
flow.Step( "Kind", KindName, KindGlyph, PickKind );
if ( walking ) {
WalkSteps( flow );
return;
}
if ( carving ) {
flow.Step( "Flight", FlightName, "straighten", BuildCarved );
return;
}
ApproachSteps( flow );
}
string KindName => walking ? "Stair" : carving ? "Concrete steps" : approach == ApproachKind.Ramp ? "Driveway ramp" : "Foundation steps";
string KindGlyph => walking ? "stairs" : carving ? "content_cut" : approach == ApproachKind.Ramp ? "trending_up" : "stairs_2";
void PickKind( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Stair — click the foot, then each point it turns or arrives at; the clicks are the pattern", "stair_straight", "stairs", walking,
() => Mode( () => walking = true, step ) );
grid.Pick( "Concrete steps — cut a flight OUT of a platform you already stood", "stair_carve", "content_cut", carving,
() => Mode( () => carving = true, step ) );
grid.Pick( "Foundation steps — climb from grade onto the plinth at a door", "approach_steps", "stairs_2",
approach == ApproachKind.Steps, () => Mode( () => approach = ApproachKind.Steps, step ) );
grid.Pick( "Driveway ramp — slope from grade onto the plinth at a garage", "approach_ramp", "trending_up",
approach == ApproachKind.Ramp, () => Mode( () => approach = ApproachKind.Ramp, step ) );
}
void Mode( Action chosen, ArchWorkflowStep step ) {
CancelSplineDriveway();
CancelWalk();
walking = false;
carving = false;
approach = null;
chosen();
step.Chose();
}
void WalkSteps( ArchWorkflow flow ) {
flow.Step( "Built of", Solid ? "Solid" : "Open", Solid ? "foundation" : "stairs", PickFill );
flow.Step( "Carriage", CarriageName, ArchStairUi.Fallback( draft.Support ), BuildCarriage );
flow.Step( "Balustrade", GuardName, ArchStairUi.Fallback( draft.Guard ), BuildBalustrade );
// A stepped mass has no soffit to open, which is what makes the row absent rather than dead.
if ( !Solid ) {
flow.Step( "Underside", ArchStairLabels.Describe( draft.Under ), ArchStairLabels.Fallback( draft.Under ), BuildUnderside );
}
flow.Step( "Wrap", draft.Wrap == StairWrap.Walls ? "Walled well" : "Open", draft.Wrap == StairWrap.Walls ? "square_foot" : "crop_free", BuildWrap );
flow.Step( "Climb", ClimbName, snapStoreys ? "stairs" : "straighten", BuildClimb );
flow.Step( "Flight", FlightName, "straighten", BuildFlight );
}
bool Solid => fill == StairFill.Solid;
void PickFill( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Open — treads on stringers over an open raked soffit, the joinery stair", "fill_open", "stairs",
!Solid, () => Built( StairFill.Open, step ) );
grid.Pick( "Solid — every flight a stepped mass standing on the shaft floor, the concrete stair", "fill_solid", "foundation",
Solid, () => Built( StairFill.Solid, step ) );
}
void Built( StairFill wanted, ArchWorkflowStep step ) {
fill = wanted;
step.Chose();
}
string CarriageName => draft.Support == StairSupport.OpenString ? "Open string" : "Closed string";
void BuildCarriage( ArchWorkflowStep step ) => ArchStairUi.Carriage( step.Layout, draft, step.Chose, null );
string GuardName => draft.Guard switch {
StairGuard.None => "None",
StairGuard.Left => $"Left, {WornName}",
StairGuard.Right => $"Right, {WornName}",
_ => $"Both sides, {WornName}"
};
StairRailing Worn => draft.Guard == StairGuard.Right ? draft.RightRailing : draft.LeftRailing;
string WornName => Worn == StairRailing.Handrail ? "rake" : "balustrade";
void BuildBalustrade( ArchWorkflowStep step ) {
ArchStairUi.Guard( step.Layout, draft, step.Chose, null );
if ( draft.Guard == StairGuard.None ) {
return;
}
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Balustrade — newels, balusters and the handrail over them", "railing_balustrade", "fence",
Worn == StairRailing.Balustrade, () => Wears( StairRailing.Balustrade ) );
grid.Pick( "Rake — the handrail on its newels, nothing under it", "railing_handrail", "remove",
Worn == StairRailing.Handrail, () => Wears( StairRailing.Handrail ) );
}
void Wears( StairRailing railing ) {
draft.LeftRailing = railing;
draft.RightRailing = railing;
}
void BuildUnderside( ArchWorkflowStep step ) => ArchStairUi.Underneath( step.Layout, draft, step.Chose, null );
void BuildWrap( ArchWorkflowStep step ) => ArchStairUi.Wrap( step.Layout, draft, step.Chose, null );
string ClimbName => snapStoreys
? $"{stories} floor{(stories == 1 ? "" : "s")}{(cutStairwell ? ", well" : "")}"
: $"{ChainRise():0} climb{(cutStairwell ? ", well" : "")}";
void BuildClimb( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Storeys — the climb is whole floors and lands level with one", "bp_storeys", "stairs",
snapStoreys, () => Measure( true, step ) );
grid.Pick( "To a height — the climb reaches a head you type", "bp_head", "straighten",
!snapStoreys, () => Measure( false, step ) );
grid.Toggle( "Cut a stairwell through every floor the climb reaches", "opt_pierce_floor", "crop_free",
cutStairwell, value => cutStairwell = value );
}
void Measure( bool storeys, ArchWorkflowStep step ) {
snapStoreys = storeys;
step.Chose();
}
string FlightName => $"{Sized( draft.Width, Owner.Kit.StairWidth ):0} wide, {Sized( draft.StepRise, Owner.Kit.StepRise ):0} rise";
static float Sized( float value, float stock ) => value > 0f ? value : stock;
void BuildFlight( ArchWorkflowStep step ) {
if ( snapStoreys ) {
step.Layout.Add( ArchPartUi.Integer( "Climbs floors", stories, 1, value => stories = Math.Clamp( value, 1, 12 ) ) );
} else {
var baseHeight = Owner.LevelHeight;
step.Layout.Add( ArchPartUi.Number( "Head", baseHeight + flightRise, baseHeight + Owner.StoreyHeight,
value => flightRise = MathF.Max( 1f, value - baseHeight ) ) );
}
step.Layout.Add( new Button( "To the top", "vertical_align_top" ) { Clicked = PunchToTop } );
step.Layout.Add( ArchPartUi.Number( "Flight width", draft.Width, Owner.Kit.StairWidth, value => draft.Width = MathF.Max( ArchStairLanes.MinLane, value ) ) );
step.Layout.Add( ArchPartUi.Number( "Riser", draft.StepRise, Owner.Kit.StepRise, value => draft.StepRise = value ) );
step.Layout.Add( ArchPartUi.Number( "Going", draft.StepGoing, Owner.Kit.StepGoing, value => draft.StepGoing = value ) );
}
void BuildCarved( ArchWorkflowStep step ) {
step.Layout.Add( ArchPartUi.Number( "Width", draft.Width, Owner.Kit.StairWidth, value => draft.Width = value ) );
step.Layout.Add( ArchPartUi.Number( "Riser", draft.StepRise, Owner.Kit.StepRise, value => draft.StepRise = value ) );
step.Layout.Add( ArchPartUi.Number( "Going", draft.StepGoing, Owner.Kit.StepGoing, value => draft.StepGoing = value ) );
}
void ApproachSteps( ArchWorkflow flow ) {
if ( approachDraft.Kind == ApproachKind.Ramp ) {
flow.Step( "Path", splineDriveway ? "Spline" : "Straight", splineDriveway ? "gesture" : "horizontal_rule", PickPath );
}
if ( !splineDriveway ) {
flow.Step( "Lands on", AgainstName, AgainstGlyph, PickAgainst );
}
flow.Step( "Fittings", FittingsName, "fence", BuildFittings );
}
void PickPath( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Straight — locked square to the supporting wall", "driveway_straight", "horizontal_rule",
!splineDriveway, () => Path( false, step ) );
grid.Pick( "Spline — snapped control points between a wall and street", "driveway_spline", "gesture",
splineDriveway, () => Path( true, step ) );
}
void Path( bool spline, ArchWorkflowStep step ) {
CancelSplineDriveway();
splineDriveway = spline;
step.Chose();
}
string AgainstName => approachDraft.Against switch {
ApproachAgainst.Foundation => "Foundation",
ApproachAgainst.Platform => "Platform",
_ => "Auto"
};
string AgainstGlyph => approachDraft.Against switch {
ApproachAgainst.Foundation => "foundation",
ApproachAgainst.Platform => "deck",
_ => "auto_awesome"
};
void PickAgainst( ArchWorkflowStep step ) {
using var grid = ArchIconGrid.In( step.Layout );
grid.Pick( "Auto — whichever of the two is nearer to the drag", "approach_auto", "auto_awesome",
approachDraft.Against == ApproachAgainst.Auto, () => Against( ApproachAgainst.Auto, step ) );
grid.Pick( "Foundation — the house's own plinth, climbing to its floor", "approach_foundation", "foundation",
approachDraft.Against == ApproachAgainst.Foundation, () => Against( ApproachAgainst.Foundation, step ) );
grid.Pick( "Platform — a podium's edge, climbing to its deck; its coping breaks to let the drive in",
"approach_platform", "deck", approachDraft.Against == ApproachAgainst.Platform,
() => Against( ApproachAgainst.Platform, step ) );
}
void Against( ApproachAgainst wanted, ArchWorkflowStep step ) {
approachDraft.Against = wanted;
step.Chose();
}
string FittingsName {
get {
if ( approachDraft.Kind != ApproachKind.Ramp ) {
return approachDraft.Rails ? "Handrails" : "Bare";
}
var worn = new List<string>();
if ( approachDraft.Kerbs ) {
worn.Add( "Kerbs" );
}
if ( approachDraft.Skirted ) {
worn.Add( "skirted" );
}
return worn.Count == 0 ? "Bare" : string.Join( ", ", worn );
}
}
void BuildFittings( ArchWorkflowStep step ) {
if ( approachDraft.Kind == ApproachKind.Ramp && splineDriveway ) {
step.Layout.Add( ArchPartUi.Number( "Width", approachDraft.Width, 96f,
value => approachDraft.Width = Owner.Grid.Subgrid( value, 4 ) ) );
}
ArchSidebarLayout.Form( step.Layout, form => ArchStairUi.Fittings( form, approachDraft, null ) );
}
void SeedRise() {
if ( seededRise ) {
return;
}
flightRise = Owner.StoreyHeight;
seededRise = true;
}
string WalkAdvice() {
if ( continuing is not null ) {
return $"Walking {continuing.Name} on from its head. Click, then Enter.";
}
var destination = snapStoreys ? $"reaches floor {Owner.Level + stories + 1}" : $"{ChainRise():0} of climb";
return $"Click the foot, then each turn, then Enter — {destination}.";
}
}