Editor-side utility for exterior switchback stairs. Defines data classes for flights and landings, resolves a stair part into a concrete shape with flights and landings, places parts against building walls, creates platform slabs, and removes openings the stair created.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// One flight of the switchback: the straight leg handed to the stair engine, and the rake its rail follows.
public sealed class ArchExteriorStairFlight
{
public int Index { get; init; }
public ArchStairPart Stair { get; init; }
public ArchStairShape Shape { get; init; }
public List<Vector3> Edge { get; init; } = new();
}
// One storey's arrival: a hung slab and the two free edges of it that need a rail.
public sealed class ArchExteriorStairLanding
{
public int Storey { get; init; }
public int Level { get; init; }
public float Height { get; init; }
public float Thickness { get; init; }
public List<Vector2> Outline { get; init; } = new();
public List<Vector3> Edge { get; init; } = new();
}
// Resolved once, for the generator, the placement ghost, the affector and the report.
public sealed class ArchExteriorStairShape
{
public List<ArchExteriorStairFlight> Flights { get; } = new();
public List<ArchExteriorStairLanding> Landings { get; } = new();
public ArchRoom Host { get; init; }
public float Width { get; init; }
public float Pad { get; init; }
public float Run { get; init; }
public float Going { get; init; }
public float Rise { get; init; }
public float Climb { get; init; }
public float Foot { get; init; }
public float Span { get; init; }
public bool IsUsable => Flights.Count > 0;
// Every run of the whole switchback as ONE shape, because Breach clears a wall's holes by the layer that
// made them: handed a flight at a time it would close the leg before it on every wall it touched.
public ArchStairShape Walk()
{
var walk = new ArchStairShape { Width = Width, Going = Going, Rise = Rise, BaseHeight = Foot };
foreach ( var flight in Flights )
{
walk.Runs.AddRange( flight.Shape.Runs );
}
return walk;
}
}
// The part is the ROUTING - an anchor, a rise, and a landing at every storey the rise passes. The legs come
// off ArchStairShape, the landings off ArchPlatformGen and the rails off ArchBarrierShape, so nothing about
// a tread, a slab or a baluster is decided here.
public static class ArchExteriorStair
{
public static ArchExteriorStairPart Place( ArchPlan plan, ArchKit kit, int level, Vector2 point, ArchExteriorStairPart draft, out ArchBuilding host )
{
host = null;
if ( plan is null || draft is null )
{
return null;
}
var station = ArchFixtureAnchor.At( plan.Buildings, level, new ArchGridService(), point, draft.Width );
return Place( plan, kit, station, draft, out host );
}
public static ArchExteriorStairPart Place( ArchPlan plan, ArchKit kit, ArchFixtureStation? at, ArchExteriorStairPart draft, out ArchBuilding host )
{
host = null;
if ( plan is null || draft is null || at is not { } station )
{
return null;
}
host = station.Building;
var thickness = station.Wall.Thickness > 0f ? station.Wall.Thickness : kit.WallThickness;
return new ArchExteriorStairPart
{
Id = plan.AllocateId(),
Name = $"Escape{plan.CountFiled<ArchExteriorStairPart>( host ) + 1}",
Anchor = station.Centre + station.Outward * (thickness * 0.5f),
Outward = station.Outward,
BaseHeight = Grade( plan, host, kit ),
TopHeight = station.Room.BaseHeight + ArchFloorGen.WallHeight( station.Room, kit ),
// NOT the dragged extent: a flight running along an elevation measures its Width off the FACE, and
// how far it reaches along the wall is its legs' business. The drag names the station alone.
Width = draft.Width,
Reach = draft.Reach,
StoreyHeight = draft.StoreyHeight,
Rail = draft.Rail
};
}
public static ArchExteriorStairShape Resolve( ArchExteriorStairPart part, ArchBuilding building, ArchKit kit )
{
var width = MathF.Max( 12f, part.Width > 1f ? part.Width : kit.EscapeWidth );
var pad = MathF.Max( width, part.Reach > 1f ? part.Reach : kit.EscapeLanding );
var going = MathF.Max( 4f, kit.StepGoing );
var storey = part.StoreyHeight > 1f ? part.StoreyHeight : ArchStairShape.StoreyHeight( building, kit );
// One flight per storey the rise passes, and the climb split evenly across them so the head lands
// exactly where it was dragged rather than a part-storey over or under it.
var flights = Math.Max( 1, (int)MathF.Round( part.Rise / MathF.Max( 1f, storey ) ) );
var climb = part.Rise / flights;
var steps = Math.Max( 2, (int)MathF.Round( climb / MathF.Max( 1f, kit.StepRise ) ) );
var run = steps * going;
var shape = new ArchExteriorStairShape
{
Host = Hosting( building, part ),
Width = width,
Pad = pad,
Run = run,
Going = going,
Rise = climb / steps,
Climb = climb,
Foot = part.BaseHeight,
Span = pad * 2f + run
};
for ( var index = 0; index < flights; index++ )
{
shape.Flights.Add( Flight( part, building, kit, shape, index ) );
shape.Landings.Add( Landing( part, building, kit, shape, index + 1 ) );
}
return shape;
}
// Alternating along the elevation: the run leaves the landing it arrived on and walks back the other way,
// which is the whole of what makes a switchback rather than one enormous straight rake.
static ArchExteriorStairFlight Flight( ArchExteriorStairPart part, ArchBuilding building, ArchKit kit, ArchExteriorStairShape shape, int index )
{
var outbound = index % 2 == 0;
var foot = outbound ? shape.Pad : shape.Pad + shape.Run;
var head = outbound ? shape.Pad + shape.Run : shape.Pad;
var baseHeight = shape.Foot + index * shape.Climb;
// Width runs to the LEFT of travel, so an outbound leg starts off the face and an inbound one on it -
// either way the flight hugs the elevation and the free edge is the one away from it.
var start = At( part, foot, outbound ? shape.Width : 0f );
var finish = At( part, head, outbound ? shape.Width : 0f );
var (core, lanes) = ArchStairCore.Straight( start, finish - start, (finish - start).Length, shape.Width, shape.Climb );
var stair = new ArchStairPart
{
Id = part.Id,
Name = $"{part.Name} Flight {index + 1}",
BaseHeight = baseHeight,
Core = core,
Lanes = lanes,
Width = shape.Width,
StepRise = kit.StepRise,
StepGoing = shape.Going,
Support = StairSupport.OpenString,
Under = StairUnder.Open,
Guard = StairGuard.None,
WellGuard = false,
TopLanding = false,
Palette = part.Palette
};
return new ArchExteriorStairFlight
{
Index = index,
Stair = stair,
Shape = ArchStairShape.Resolve( stair, shape.Host, kit, building?.Rooms, building ),
Edge = new List<Vector3>
{
Raised( At( part, foot, shape.Width ), baseHeight ),
Raised( At( part, head, shape.Width ), baseHeight + shape.Climb )
}
};
}
// The far zone on an odd storey, the near one on an even: the landing is wherever the flight below it
// arrived, which is what puts one at every floor without a count being typed anywhere.
static ArchExteriorStairLanding Landing( ArchExteriorStairPart part, ArchBuilding building, ArchKit kit, ArchExteriorStairShape shape, int storey )
{
var far = storey % 2 == 1;
var open = far ? shape.Pad + shape.Run : shape.Pad;
var shut = far ? shape.Span : 0f;
var height = shape.Foot + storey * shape.Climb;
return new ArchExteriorStairLanding
{
Storey = storey,
Level = ArchStairShape.FloorIndex( building, kit, height ),
Height = height,
Thickness = MathF.Max( 2f, kit.BalconySlab ),
Outline = new List<Vector2>
{
At( part, open, 0f ),
At( part, open, shape.Width ),
At( part, shut, shape.Width ),
At( part, shut, 0f )
},
// Open where the flights meet it, walled where it touches the elevation - two edges to rail.
Edge = new List<Vector3>
{
Raised( At( part, open, shape.Width ), height ),
Raised( At( part, shut, shape.Width ), height ),
Raised( At( part, shut, 0f ), height )
}
};
}
public static ArchPlatformPart Slab( ArchExteriorStairPart part, ArchExteriorStairLanding landing )
{
var slab = new ArchPlatformPart
{
Id = part.Id,
Name = $"{part.Name} Landing {landing.Storey}",
Level = landing.Level,
GradeHeight = landing.Height - landing.Thickness,
TopHeight = landing.Height,
Hung = true,
Coping = false,
Palette = part.Palette
};
slab.Reshape( landing.Outline );
return slab;
}
// The room whose wall the anchor stands against, so the probe that decides which side of a run is walled
// reads the elevation the flight hangs on. Never null: the stair engine skins itself off a room.
public static ArchRoom Hosting( ArchBuilding building, ArchExteriorStairPart part )
{
if ( building is null )
{
return new ArchRoom();
}
ArchTool.NearestWall( building.Rooms, part.Anchor, out var room, out _, out _ );
return room ?? new ArchRoom();
}
static Vector2 At( ArchExteriorStairPart part, float along, float depth )
{
return part.Anchor + part.Along * along + part.Facing * depth;
}
static Vector3 Raised( Vector2 point, float height ) => new( point.x, point.y, height );
// Built in the building's lifted space: grade sits a plinth below the ground floor.
static float Grade( ArchPlan plan, ArchBuilding building, ArchKit kit )
{
var lowest = float.MaxValue;
foreach ( var room in building.Rooms )
{
lowest = MathF.Min( lowest, room.BaseHeight );
}
return (lowest == float.MaxValue ? 0f : lowest) - ArchAsks.Lift( plan, building, kit );
}
// Deleting the flight takes the archways its runs opened; the group it stood in is the author's and stays.
public static void Remove( ArchPlan plan, ArchExteriorStairPart part )
{
var kinds = ArchKinds.Load();
foreach ( var wall in plan.AllRooms().SelectMany( room => plan.Filed( ArchKind.Wall, room, kinds ) ) )
{
plan.RemoveAll( ArchKind.Opening, wall, opening => opening is ArchOpening archway && archway.OwnerId == part.Id, kinds );
}
ArchLayerGroups.Leave( plan, part.Id );
}
}