Editor subtool class for creating and editing stairs and approaches in the architecture editor. It handles walking-click creation of multi-leg stairs, concrete carving of steps, placing approaches (steps or ramps), spline driveway editing, preview/ghost drawing, input handling, and sidebar UI options.
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 };
// A stair is the one part whose HEIGHT is half of what you are authoring, so it has to be reachable where
// height can be seen and grabbed - the 3D view and both elevations, not the plan alone. Plan-only is what
// left it with nothing but a box: every point handle it owns draws on the surface you are looking at.
public override ArchViewAxis[] Works => new[] { ArchViewAxis.Free, ArchViewAxis.Top, ArchViewAxis.Front, ArchViewAxis.Side };
// The stair just placed keeps its widgets, so a shaft can be pulled about without leaving the tool that drew it.
protected override bool Adjusts => true;
readonly ArchStairPart draft = new();
readonly ArchApproachPart approachDraft = new();
readonly List<ArchCurveNode> drivewayNodes = new();
// The walk: one click per point the stair turns or arrives at. The clicks ARE the pattern.
readonly List<Vector3> walk = new();
bool splineDriveway;
DrivewayConnection drivewayStart;
DrivewayConnection drivewayEnd;
bool enterDown;
bool escapeDown;
// Snaps to the wall face so the approach always meets the building.
ApproachKind? approach;
// Explicit, not inferred - the drag must land on something cuttable; a miss says so.
bool carving;
bool walking = true;
// The stair the first click landed on the head of, so a walk picks a standing stair up rather than
// starting a second one beside it - the way a pipe route clicks a node another run already owns.
ArchStairPart continuing;
StairFill fill;
bool snapStoreys = true;
int stories = 1;
bool cutStairwell = true;
// The drawing opens on the stair just walked. Off for anyone laying out a dozen in a row, who wants the next
// gesture rather than a window over it.
bool designs = true;
bool seededRise;
float flightRise = 128f;
protected override bool UsesDrag => !walking && (approach != ApproachKind.Ramp || !splineDriveway);
protected override bool TakesFreeClick => walking;
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 and the flight is cut OUT of its concrete. For a free-standing concrete stair, use the shaft with Solid instead."
: approach switch
{
ApproachKind.Steps => "Drag out from the wall outside a door. Away from the wall is the going, along it is the width; the rise is however high the plinth stands.",
ApproachKind.Ramp when splineDriveway => "Start on an exterior wall or street edge, click snapped control points, then click the other connection. Select the result to edit every node's width.",
ApproachKind.Ramp => "Start on an outside wall and drag away from it. The driveway stays square to that wall and snaps in its local grid.",
_ => "Drag the stair's shaft the way you drag a building."
};
void DrawApproachPreview()
{
if ( Draft( DragStart, DragCurrent, out var room ) is not { } draft )
{
return;
}
var building = Owner.Plan.OwnerOf( room ) ?? 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 );
ArchGhost.Note( new Vector3( foot.x, foot.y, room.BaseHeight + lift ),
$"{draft.Kind} · rise {rise:0} · run {draft.Run:0} · {draft.Width:0} wide" );
}
// Snapped to the wall face - the approach must always meet the building.
ArchApproachPart Draft( Vector2 from, Vector2 to, out ArchRoom room )
{
room = null;
var wall = ArchApproachPlacement.NearestExteriorWall(
Owner.Plan.Buildings, Owner.Level, from,
out var building, out room, out var along, out _, out var outward );
if ( wall is null || room is null )
{
return null;
}
var wallDirection = (wall.End - wall.Start).Normal;
var drag = to - from;
var grid = Owner.Grid;
var minimum = grid.SubgridSize( 4 );
var splay = Owner.FindArchetype( building?.Archetype )?.DriveSplay ?? approachDraft.Splay;
var run = Vector2.Dot( drag, outward );
if ( run < minimum )
{
return null;
}
if ( approach == ApproachKind.Ramp )
{
return ArchApproachPlacement.Seat(
wall, room, building, Owner.Kit, along, ApproachKind.Ramp,
approachDraft.Width, run, approachDraft.Kerbs, approachDraft.Rails, splay );
}
var widthDrag = Vector2.Dot( drag, wallDirection );
var width = MathF.Abs( widthDrag ) < minimum ? approachDraft.Width : MathF.Abs( widthDrag );
return ArchApproachPlacement.Seat(
wall, room, building, Owner.Kit, along + widthDrag * 0.5f, ApproachKind.Steps,
width, run, approachDraft.Kerbs, approachDraft.Rails, splay );
}
bool PlaceApproach( Vector2 from, Vector2 to )
{
if ( Draft( from, to, out var room ) is not { } draft )
{
return true;
}
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;
}
// ---- The walk: one click per point the stair turns or arrives at ----
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,
Under = draft.Under,
Guard = draft.Guard,
WallRail = draft.WallRail,
Wrap = draft.Wrap,
WrapThickness = draft.WrapThickness
};
}
// Where the ray actually lands, seated on the surface it hit and snapped the way a pipe node is - so a click
// on the floor above IS the climb, and the stair never has to be told how many storeys it reaches.
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 );
}
// A blockout with nothing built in it yet still has to be walkable, so the work plane is the last resort
// rather than the only surface.
if ( Owner.Cursor( out var cursor ) )
{
point = new Vector3( cursor.Plan.x, cursor.Plan.y, cursor.Height );
return true;
}
point = default;
return false;
}
// The grid and the wall snap are the tool's own two, off the toolbar - the stair had a third of each and
// authored on a ladder nothing in the viewport had drawn.
bool Seated( Vector3 hit, out Vector3 point )
{
var flat = Owner.Snap( new Vector2( hit.x, hit.y ) );
if ( Owner.SnapsToWalls && NearestWall( flat, out var distance, out var onFace ) is not null
&& distance < ArchWallSnap.Reach( Owner.WallSnapReach ) )
{
flat = onFace;
}
point = new Vector3( flat.x, flat.y, ArchGridService.Fine( hit.z ) );
return true;
}
// The stair a walk starting here would pick up rather than stand beside.
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;
}
// One resolve for the ghost and the commit alike: a preview that worked the steps out its own way would show
// a stair the release then built somewhere else.
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 };
foreach ( var node in line )
{
Gizmo.Draw.LineSphere( node, 4f );
}
for ( var index = 0; index + 1 < line.Count; index++ )
{
Gizmo.Draw.Line( line[index], line[index + 1] );
}
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.Flight( shape, room.BaseHeight + Owner.Kit.WallHeight );
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();
if ( designs )
{
ArchStairWindow.Open( 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;
}
// Off the aimed drag, so the ghost climbs as the commit will, even dragged downhill.
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();
}
// The near FACE, not the centreline: a step snapped to the middle of a wall stands half inside it, and
// the run comes out short by a thickness at whichever end caught.
ArchWall NearestWall( Vector2 point, out float distance, out Vector2 onFace )
{
distance = float.MaxValue;
onFace = default;
ArchWall best = null;
foreach ( var room in Owner.Plan.AllRooms().Where( entry => entry.Floor == Owner.Level ) )
{
foreach ( var wall in room.Walls )
{
var span = wall.End - wall.Start;
var length = span.Length;
if ( length < 1f )
{
continue;
}
var t = Math.Clamp( Vector2.Dot( point - wall.Start, span ) / (length * length), 0f, 1f );
var at = wall.Start + span * t;
var side = point - at;
var thickness = wall.Thickness > 0.1f ? wall.Thickness : Owner.Kit.WallThickness;
var face = at + (side.IsNearZeroLength ? wall.Normal : side.Normal) * thickness * 0.5f;
var gap = (face - point).Length;
if ( gap < distance )
{
distance = gap;
onFace = face;
best = wall;
}
}
}
return best;
}
// Against an exterior wall the stair can start outside the footprint — file to that wall's room. Outdoors,
// with no wall anywhere near, it still has to land somewhere: the room on this level, else the nearest one
// the plan has, because a yard stair between two retaining walls belongs to no room at all.
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 )
{
ArchSidebarSection.Show( panel, Scope( "kind" ), "Kind", group =>
{
using var grid = ArchIconGrid.In( group );
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 ) );
grid.Pick( "Concrete steps — cut a flight OUT of a platform you already stood", "stair_carve", "content_cut", carving,
() => Mode( () => carving = true ) );
grid.Pick( "Foundation steps — climb from grade onto the plinth at a door", "approach_steps", "stairs_2",
approach == ApproachKind.Steps, () => Mode( () => approach = ApproachKind.Steps ) );
grid.Pick( "Driveway ramp — slope from grade onto the plinth at a garage", "approach_ramp", "trending_up",
approach == ApproachKind.Ramp, () => Mode( () => approach = ApproachKind.Ramp ) );
} );
if ( walking )
{
BuildWalkOptions( panel );
BuildPicked( panel );
return;
}
// A carved flight has none of the rest - only width and riser survive the cut.
if ( carving )
{
ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
{
group.Add( ArchPartUi.Number( "Width", draft.Width, Owner.Kit.StairWidth, value => draft.Width = value ) );
group.Add( ArchPartUi.Number( "Riser", draft.StepRise, Owner.Kit.StepRise, value => draft.StepRise = value ) );
group.Add( ArchPartUi.Number( "Going", draft.StepGoing, Owner.Kit.StepGoing, value => draft.StepGoing = value ) );
} );
panel.Layout.Add( ArchSidebarLayout.Advice( "The riser bends to fit the platform's own rise, so a flight always arrives level with the coping." ) );
return;
}
approachDraft.Kind = approach ?? ApproachKind.Steps;
if ( approachDraft.Kind == ApproachKind.Ramp )
{
ArchSidebarSection.Show( panel, Scope( "path" ), "Path", group =>
{
using var path = ArchIconGrid.In( group );
path.Pick( "Straight — locked square to the supporting wall", "driveway_straight", "horizontal_rule",
!splineDriveway, () => { CancelSplineDriveway(); splineDriveway = false; Refresh(); } );
path.Pick( "Spline — snapped control points between a wall and street", "driveway_spline", "gesture",
splineDriveway, () => { CancelSplineDriveway(); splineDriveway = true; Refresh(); } );
} );
}
ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
{
if ( approachDraft.Kind == ApproachKind.Ramp && splineDriveway )
{
group.Add( ArchPartUi.Number( "Width", approachDraft.Width, 96f,
value => approachDraft.Width = Owner.Grid.Subgrid( value, 4 ) ) );
}
ArchSidebarLayout.Form( group, form => ArchStairUi.Fittings( form, approachDraft, null ) );
} );
}
// One place clears every other mode, so a kind pick can never leave two of them live.
void Mode( Action chosen )
{
CancelSplineDriveway();
CancelWalk();
walking = false;
carving = false;
approach = null;
chosen();
Refresh();
}
// The stair under the cursor is edited through the same sheet the layer stack shows, so its balustrade,
// its wall rails and every step's own climb are one click away from the tool that walked it.
void BuildPicked( ToolSidebarWidget panel )
{
if ( Owner.Picked is not { Item: ArchStairPart } picked )
{
return;
}
ArchSidebarSection.Show( panel, Scope( "selected" ), $"Selected — {picked.Describe()}", group =>
group.Add( new Button( "Deselect", "close" ) { Clicked = () => { Owner.Select( null ); Refresh(); } } ) );
ArchPartUi.Sheet( panel, Owner, picked, Refresh );
}
void BuildWalkOptions( ToolSidebarWidget panel )
{
SeedRise();
ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
{
group.Add( ArchPartUi.Number( "Flight width", draft.Width, Owner.Kit.StairWidth, value => draft.Width = MathF.Max( ArchStairLanes.MinLane, value ) ) );
group.Add( ArchPartUi.Number( "Riser", draft.StepRise, Owner.Kit.StepRise, value => draft.StepRise = value ) );
group.Add( ArchPartUi.Number( "Going", draft.StepGoing, Owner.Kit.StepGoing, value => draft.StepGoing = value ) );
if ( snapStoreys )
{
group.Add( ArchPartUi.Integer( "Climbs floors", stories, 1, value => stories = Math.Clamp( value, 1, 12 ) ) );
}
else
{
var baseHeight = Owner.LevelHeight;
group.Add( ArchPartUi.Number( "Head", baseHeight + flightRise, baseHeight + Owner.StoreyHeight,
value => flightRise = MathF.Max( 1f, value - baseHeight ) ) );
}
// Stays out of Advanced because it decides which of those two rows is above it - a toggle that
// re-lays out the group it is in cannot be the one thing folded away from it.
using var grid = ArchIconGrid.In( group );
grid.Toggle( "Snap to storeys — the climb is whole storeys", "bp_storeys", "stairs", snapStoreys, value =>
{
snapStoreys = value;
Refresh();
} );
grid.Toggle( "Solid — every flight a stepped mass on the shaft floor, the concrete stair", "bp_solid", "foundation", fill == StairFill.Solid, value =>
{
fill = value ? StairFill.Solid : StairFill.Open;
Refresh();
} );
} );
ArchSidebarSection.Disclosure( panel, Scope( "assembly" ), "Assembly", true, group =>
ArchSidebarLayout.Form( group, form => ArchStairUi.Build( form, draft, Refresh, null ) ) );
ArchSidebarSection.Disclosure( panel, Scope( "advanced" ), "Advanced", true, group =>
{
using ( var grid = ArchIconGrid.In( group ) )
{
grid.Toggle( "Cut a stairwell through every floor the climb reaches", "opt_pierce_floor", "crop_free", cutStairwell, value => cutStairwell = value );
grid.Toggle( "Open the drawing on the stair just walked", "opt_designer", "draw", designs, value => designs = value );
}
var flow = group.AddRow();
flow.Spacing = 4;
flow.Add( new Button( "To the top", "vertical_align_top" ) { Clicked = PunchToTop } );
} );
panel.Layout.Add( ArchSidebarLayout.Advice(
"Every flight's climb is divided evenly. Select the stair and pull a step's LIFT ARROW to pin that one — three risers onto a broad landing, fourteen more after it — and the rest share what is left." ) );
}
// The climb defaults to one storey; the box only ever sets the footprint.
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 where it turns or arrives and press Enter — the new flights turn against the one below them.";
}
var destination = snapStoreys ? $"climbs to floor {Owner.Level + stories + 1}" : $"{ChainRise():0} climb";
return $"Click the foot, then each point the stair turns or arrives at, and press Enter. Every leg is a flight and every turn drops a platform in — click the floor above and it {destination} on its own. Start on a standing stair's head to walk it further.";
}
}