Editor/Stair/ArchStairCanvas.cs
using System;
using System.Collections.Generic;
using Editor;
namespace Sunless.Architecture;
public enum ArchStairView {
Plan,
Along,
Across
}
enum ArchStairGrip {
Head,
Left,
Right,
Lift,
Body,
Bend,
Rail,
RailFoot,
RailHead
}
static class ArchStairInk {
public static readonly Color Walls = new( 0.92f, 0.55f, 0.18f );
public static readonly Color Floors = new( 0.88f, 0.26f, 0.22f );
public static readonly Color Flight = new( 0.30f, 0.58f, 0.82f );
public static readonly Color Platform = new( 0.86f, 0.66f, 0.26f );
public static readonly Color Balustrade = new( 0.94f, 0.84f, 0.36f );
public static readonly Color Handrail = new( 0.62f, 0.78f, 0.52f );
public static readonly Color Lift = new( 0.52f, 0.86f, 0.42f );
public static readonly Color Reach = new( 0.42f, 0.72f, 0.92f );
public static readonly Color Head = new( 0.74f, 0.54f, 0.94f );
public static readonly Color Bend = new( 0.88f, 0.46f, 0.72f );
public static readonly Color Flow = new( 0.80f, 0.88f, 0.96f );
}
public sealed class ArchStairCanvas : Widget {
const float Padding = 28f;
const float GripSize = 9f;
const float BandHeight = 142f;
const float Elbow = 20f;
// The bend canvas ratchets finer than ArchGridService.AngleStep on purpose: a flight turns to meet a landing,
// not to sit on the plan's angle ladder.
const float BendStep = 5f;
const float StationReach = 6f;
readonly ArchTool tool;
readonly Func<ArchStairPart> subject;
readonly Action changed;
readonly Action committed;
readonly List<(ArchStairGrip Kind, int Step, int Guard, Vector2 At)> grips = new();
readonly List<(int Step, Rect At)> walked = new();
readonly List<(int Step, Vector2 At)> pins = new();
float scale = 1f;
Vector2 origin;
Vector2 bandFoot;
float bandZoom = 1f;
int held = -1;
int heldGuard = -1;
ArchStairGrip heldKind;
bool dragging;
bool profiling;
int joining = -1;
Vector2 hover;
ArchStairPart holding;
ArchRoom home;
public ArchStairView View { get; set; } = ArchStairView.Plan;
public bool Profile { get; set; } = true;
public bool Surrounds { get; set; } = true;
public int Picked { get; set; }
public int PickedGuard { get; set; } = -1;
public Action<int> Selected { get; set; }
public ArchStairCanvas( Widget parent, ArchTool tool, Func<ArchStairPart> subject, Action changed, Action committed ) : base( parent ) {
this.tool = tool;
this.subject = subject;
this.changed = changed;
this.committed = committed;
MinimumSize = new Vector2( 420, 320 );
MouseTracking = true;
FocusMode = FocusMode.Click;
}
Vector2 Flat( float along, float across, float lift ) => View switch {
ArchStairView.Along => new Vector2( along, -lift ),
ArchStairView.Across => new Vector2( across, -lift ),
_ => new Vector2( along, across )
};
Vector2 Screen( Vector2 flat ) => origin + flat * scale;
Vector2 Local( Vector2 screen ) => (screen - origin) / MathF.Max( 0.0001f, scale );
void Frame( ArchStairPart stair, Rect page ) {
var core = stair.Core;
var climb = stair.TotalRise;
var corners = new List<Vector2>
{
Flat( 0f, 0f, 0f ),
Flat( core.Length, core.Width, 0f ),
Flat( 0f, 0f, climb ),
Flat( core.Length, core.Width, climb )
};
// The room the stair stands in is framed with it, so the drawing opens on the stair AND on what it has to
// fit into. Only the room - framing the whole plan would shrink the thing being worked on to a smudge.
if ( Surrounds && View == ArchStairView.Plan ) {
foreach ( var point in Standing( stair ) ) {
corners.Add( Flat( point.x, point.y, 0f ) );
}
}
var min = corners[0];
var max = corners[0];
foreach ( var corner in corners ) {
min = new Vector2( MathF.Min( min.x, corner.x ), MathF.Min( min.y, corner.y ) );
max = new Vector2( MathF.Max( max.x, corner.x ), MathF.Max( max.y, corner.y ) );
}
var span = max - min;
var room = new Vector2( MathF.Max( 1f, page.Width - Padding * 2f ), MathF.Max( 1f, page.Height - Padding * 2f ) );
scale = MathF.Min( room.x / MathF.Max( 1f, span.x ), room.y / MathF.Max( 1f, span.y ) );
origin = new Vector2( page.Left + Padding, page.Top + Padding ) - min * scale
+ (room - span * scale) * 0.5f;
}
protected override void OnPaint() {
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground.Darken( 0.25f ) );
Paint.DrawRect( LocalRect );
if ( subject() is not { Core: not null } stair || stair.Lanes.Count == 0 ) {
Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
Paint.DrawText( LocalRect, "no stair", TextFlag.Center );
return;
}
var band = Strip();
grips.Clear();
walked.Clear();
pins.Clear();
home = ArchStairSteps.Home( tool?.Plan, stair );
Frame( stair, new Rect( 0f, 0f, Width, Height - band.Height ) );
PaintContext( stair );
PaintShaft( stair );
// Hoisted, not asked per row: both walk every step, so reaching through one inside the loop is how an
// honest table becomes an n-squared allocation sixty times a second.
var climbs = ArchStairLanes.Climbs( stair.Core, stair.Lanes );
var bases = ArchStairEdges.Bases( stair );
for ( var index = 0; index < stair.Lanes.Count; index++ ) {
PaintStep( stair, index, climbs, bases );
}
PaintFlow( stair, climbs, bases );
PaintGrips();
PaintLegend();
PaintJoining( stair );
if ( band.Height > 1f ) {
PaintProfile( stair, band, climbs, bases );
}
}
Rect Strip() => Profile && Height > BandHeight * 2f
? new Rect( 0f, Height - BandHeight, Width, BandHeight )
: new Rect( 0f, Height, Width, 0f );
void PaintContext( ArchStairPart stair ) {
if ( tool?.Plan is null || !Surrounds ) {
return;
}
if ( View == ArchStairView.Plan ) {
PaintRooms( stair );
PaintWalls( stair );
return;
}
PaintStoreys( stair );
}
int Storey => home?.Floor ?? tool?.Level ?? 0;
IEnumerable<Vector2> Standing( ArchStairPart stair ) {
if ( home is not { HasFootprint: true } room ) {
return Array.Empty<Vector2>();
}
return room.Footprint.Select( point => ArchStairLanes.Local( stair.Core, point ) );
}
void PaintRooms( ArchStairPart stair ) {
var core = stair.Core;
var level = Storey;
foreach ( var room in tool.Plan.AllRooms().Where( candidate => candidate.Floor == level && candidate.HasFootprint ) ) {
var loop = room.Footprint
.Select( point => ArchStairLanes.Local( core, point ) )
.Select( local => Screen( Flat( local.x, local.y, 0f ) ) )
.ToArray();
Paint.ClearPen();
Paint.SetBrush( ArchStairInk.Floors.WithAlpha( 0.07f ) );
Paint.DrawPolygon( loop );
Paint.ClearBrush();
Paint.SetPen( ArchStairInk.Floors.WithAlpha( 0.4f ), 1f );
for ( var corner = 0; corner < loop.Length; corner++ ) {
Paint.DrawLine( loop[corner], loop[(corner + 1) % loop.Length] );
}
}
}
void PaintWalls( ArchStairPart stair ) {
var core = stair.Core;
var thickness = MathF.Max( 1f, tool.Kit.WallThickness );
Paint.ClearPen();
Paint.SetBrush( ArchStairInk.Walls.WithAlpha( 0.5f ) );
foreach ( var wall in tool.Plan.WallsOn( Storey ) ) {
var from = ArchStairLanes.Local( core, wall.Start );
var to = ArchStairLanes.Local( core, wall.End );
var span = to - from;
if ( span.IsNearZeroLength ) {
continue;
}
var half = new Vector2( -span.Normal.y, span.Normal.x ) * (wall.Thickness > 0f ? wall.Thickness : thickness) * 0.5f;
Paint.DrawPolygon(
Screen( Flat( from.x - half.x, from.y - half.y, 0f ) ),
Screen( Flat( to.x - half.x, to.y - half.y, 0f ) ),
Screen( Flat( to.x + half.x, to.y + half.y, 0f ) ),
Screen( Flat( from.x + half.x, from.y + half.y, 0f ) ) );
}
}
void PaintStoreys( ArchStairPart stair ) {
Paint.SetDefaultFont( 7 );
foreach ( var (level, lift) in Floors( stair ) ) {
var line = Screen( Flat( 0f, 0f, lift ) ).y;
Paint.ClearBrush();
Paint.SetPen( ArchStairInk.Floors.WithAlpha( 0.5f ), 1f );
Paint.DrawLine( new Vector2( 0f, line ), new Vector2( Width, line ) );
Paint.SetPen( ArchStairInk.Floors.WithAlpha( 0.85f ) );
Paint.DrawText( new Rect( 4f, line - 12f, 120f, 12f ), $"floor {level} · {stair.BaseHeight + lift:0}", TextFlag.LeftTop );
}
}
IEnumerable<(int Level, float Lift)> Floors( ArchStairPart stair ) {
var storey = MathF.Max( 1f, tool.StoreyHeight );
var clearance = tool.Kit.GroundClearance;
var lowest = (int)MathF.Floor( (stair.BaseHeight - clearance) / storey );
var reach = lowest + (int)MathF.Ceiling( stair.TotalRise / storey ) + 1;
for ( var level = lowest; level <= reach; level++ ) {
yield return (level, level * storey + clearance - stair.BaseHeight);
}
}
void PaintShaft( ArchStairPart stair ) {
var core = stair.Core;
var climb = stair.TotalRise;
var a = Screen( Flat( 0f, 0f, 0f ) );
var b = Screen( Flat( core.Length, core.Width, View == ArchStairView.Plan ? 0f : climb ) );
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground.WithAlpha( 0.35f ) );
Paint.DrawRect( Rect.FromPoints( a, b ) );
Paint.ClearBrush();
Paint.SetPen( Theme.TextControl.WithAlpha( 0.16f ), 1f );
Paint.DrawRect( Rect.FromPoints( a, b ) );
}
void PaintStep( ArchStairPart stair, int index, float[] climbs, float[] bases ) {
var lane = stair.Lanes[index];
var seat = bases[index];
var climb = climbs[index];
var picked = index == Picked;
var foot = seat - stair.BaseHeight;
var head = foot + (lane.Climbs ? climb : 0f);
var body = Body( lane, foot, head );
var bounds = Rect.FromPoints( body[0], body[0] );
foreach ( var corner in body ) {
bounds.Add( corner );
}
Paint.ClearPen();
Paint.SetBrush( Tint( lane, picked ) );
Paint.DrawPolygon( body );
if ( lane.Climbs ) {
PaintTreads( stair, lane, index, foot, head );
}
Paint.ClearBrush();
Paint.SetPen( picked ? Theme.Primary : Theme.TextControl.WithAlpha( 0.45f ), picked ? 2f : 1f );
for ( var corner = 0; corner < body.Length; corner++ ) {
Paint.DrawLine( body[corner], body[(corner + 1) % body.Length] );
}
PaintLabel( bounds, lane, index, climb );
PaintRails( lane, index );
Offer( stair, index, climbs, bases );
}
Vector2[] Body( ArchStairLane lane, float foot, float head ) {
if ( View == ArchStairView.Plan ) {
return lane.Corners().Select( corner => Screen( Flat( corner.x, corner.y, 0f ) ) ).ToArray();
}
var (from, to) = Reach( lane );
return new[]
{
Screen( Flat( from.x, from.y, foot ) ),
Screen( Flat( to.x, to.y, foot ) ),
Screen( Flat( to.x, to.y, head ) ),
Screen( Flat( from.x, from.y, head ) )
};
}
static (Vector2 From, Vector2 To) Reach( ArchStairLane lane ) {
var frame = lane.Frame;
return (frame.Flat( 0f, lane.Width * 0.5f ), frame.Flat( lane.Length, lane.Width * 0.5f ));
}
static Color Tint( ArchStairLane lane, bool picked ) {
return Hue( lane ).WithAlpha( picked ? 0.55f : 0.30f );
}
static Color Hue( ArchStairLane lane ) => lane.Climbs ? ArchStairInk.Flight : ArchStairInk.Platform;
void PaintTreads( ArchStairPart stair, ArchStairLane lane, int index, float foot, float head ) {
var going = stair.StepGoing > 0.5f ? stair.StepGoing : 12f;
var riser = stair.StepRise > 0.5f ? stair.StepRise : 8f;
var steps = ArchStairLanes.Steps( head - foot, riser, lane.Length, going );
Paint.ClearBrush();
Paint.SetPen( Theme.TextControl.WithAlpha( 0.22f ), 1f );
for ( var step = 1; step < steps; step++ ) {
var part = step / (float)steps;
var (from, to) = Nosing( lane, part, foot, head );
Paint.DrawLine( Screen( from ), Screen( to ) );
}
}
(Vector2 From, Vector2 To) Nosing( ArchStairLane lane, float part, float foot, float head ) {
var lift = View == ArchStairView.Plan ? 0f : foot + (head - foot) * part;
var frame = lane.Frame;
var along = lane.Length * part;
var from = frame.Flat( along, 0f );
var to = frame.Flat( along, lane.Width );
return (Flat( from.x, from.y, lift ), Flat( to.x, to.y, lift ));
}
void PaintLabel( Rect body, ArchStairLane lane, int index, float climb ) {
if ( body.Width < 34f || body.Height < 16f ) {
return;
}
Paint.SetPen( Theme.TextControl.WithAlpha( 0.85f ) );
Paint.SetDefaultFont( 8 );
var caption = lane.Climbs
? $"{index + 1} {lane.Length:0} run · {climb:0} climb"
: $"{index + 1} platform {lane.Length:0} deep";
Paint.DrawText( body, caption, TextFlag.Center );
}
void PaintFlow( ArchStairPart stair, float[] climbs, float[] bases ) {
if ( View != ArchStairView.Plan ) {
return;
}
for ( var index = 0; index < stair.Lanes.Count; index++ ) {
var lane = stair.Lanes[index];
var frame = lane.Frame;
var foot = Screen( Flat( frame.Flat( 0f, lane.Width * 0.5f ).x, frame.Flat( 0f, lane.Width * 0.5f ).y, 0f ) );
var head = Screen( Flat( frame.Flat( lane.Length, lane.Width * 0.5f ).x, frame.Flat( lane.Length, lane.Width * 0.5f ).y, 0f ) );
Paint.ClearBrush();
Paint.SetPen( ArchStairInk.Flow.WithAlpha( index == Picked ? 0.9f : 0.4f ), index == Picked ? 2f : 1.5f );
Paint.DrawLine( foot, head );
Arrow( foot, head, index == Picked ? 9f : 7f );
if ( index + 1 >= stair.Lanes.Count ) {
continue;
}
var next = stair.Lanes[index + 1];
var stands = next.Frame.Flat( 0f, next.Width * 0.5f );
var onto = Screen( Flat( stands.x, stands.y, 0f ) );
// From the JOINT, not from the head: a step joined to a flank leaves from that flank, and an arrow drawn
// off the head would point at a corner nothing happens at.
var meets = Nearest( lane, next, stands );
var joint = Screen( Flat( meets.x, meets.y, 0f ) );
var apart = (joint - onto).Length > GripSize;
Paint.SetPen( apart ? ArchStairInk.Floors : ArchStairInk.Flow.WithAlpha( 0.35f ), apart ? 2f : 1f );
Paint.DrawLine( joint, onto );
if ( apart ) {
Arrow( joint, onto, 7f );
}
}
}
static Vector2 Nearest( ArchStairLane below, ArchStairLane next, Vector2 foot ) {
return ArchStairSteps.Stations( below, next.Width )
.OrderBy( station => (station.At - foot).Length )
.Select( station => station.At )
.FirstOrDefault();
}
static void Arrow( Vector2 from, Vector2 to, float size ) {
var span = to - from;
if ( span.Length < size ) {
return;
}
var along = span.Normal;
var across = new Vector2( -along.y, along.x );
Paint.DrawLine( to, to - along * size + across * size * 0.5f );
Paint.DrawLine( to, to - along * size - across * size * 0.5f );
}
void PaintJoining( ArchStairPart stair ) {
if ( joining < 1 || joining >= stair.Lanes.Count ) {
return;
}
var ring = stair.Lanes[joining - 1].Corners()
.Select( corner => Screen( Flat( corner.x, corner.y, 0f ) ) )
.ToArray();
Paint.ClearPen();
Paint.SetBrush( ArchStairInk.Flow.WithAlpha( 0.15f ) );
Paint.DrawPolygon( ring );
Paint.ClearBrush();
Paint.SetPen( ArchStairInk.Flow, 2f );
for ( var corner = 0; corner < ring.Length; corner++ ) {
Paint.DrawLine( ring[corner], ring[(corner + 1) % ring.Length] );
}
foreach ( var station in Stations( stair ) ) {
var hot = Box( station.At ).Grow( StationReach ).IsInside( hover );
Paint.ClearPen();
Paint.SetBrush( ArchStairInk.Flow.WithAlpha( hot ? 1f : 0.7f ) );
Paint.DrawRect( Box( station.At ).Grow( hot ? 3f : 1f ), 2 );
}
Paint.SetDefaultFont( 8 );
Paint.SetPen( ArchStairInk.Flow );
Paint.DrawText( new Rect( hover.x + 12f, hover.y - 8f, 260f, 16f ),
$"click a box to join step {joining + 1} there", TextFlag.LeftCenter );
}
IEnumerable<(ArchStairStation Station, Vector2 At)> Stations( ArchStairPart stair ) {
var lane = stair.Lanes[joining];
return ArchStairSteps.Stations( stair.Lanes[joining - 1], lane.Width )
.Select( station => (station, Screen( Flat( station.At.x, station.At.y, 0f ) )) );
}
void Offer( ArchStairPart stair, int index, float[] climbs, float[] bases ) {
if ( index != Picked ) {
return;
}
var lane = stair.Lanes[index];
var frame = lane.Frame;
if ( View == ArchStairView.Plan ) {
grips.Add( (ArchStairGrip.Head, index, -1, frame.Flat( lane.Length, lane.Width * 0.5f )) );
grips.Add( (ArchStairGrip.Left, index, -1, frame.Flat( lane.Length * 0.5f, lane.Width )) );
grips.Add( (ArchStairGrip.Right, index, -1, frame.Flat( lane.Length * 0.5f, 0f )) );
// EVERY step drags, and the ones around it give way rather than being passed through - so a stair is
// moved by taking hold of it, and the first step carries the whole climb with it.
grips.Add( (ArchStairGrip.Body, index, -1, frame.Flat( lane.Length * 0.5f, lane.Width * 0.5f )) );
// Standing off the head, because it is the angle the step LEAVES at - dragging it round is how a
// segment of a curve is aimed once the sweep has been cut.
if ( lane.Climbs ) {
grips.Add( (ArchStairGrip.Bend, index, -1, frame.Flat( lane.Length + Elbow, lane.Width * 0.5f )) );
}
return;
}
var top = bases[index] - stair.BaseHeight + (lane.Climbs ? climbs[index] : 0f);
var middle = frame.Flat( lane.Length * 0.5f, lane.Width * 0.5f );
grips.Add( (ArchStairGrip.Lift, index, -1, Flat( middle.x, middle.y, top )) );
}
void PaintRails( ArchStairLane lane, int index ) {
if ( View != ArchStairView.Plan ) {
return;
}
for ( var guard = 0; guard < lane.Guards.Count; guard++ ) {
var rail = lane.Guards[guard];
var (from, to) = RailLine( lane, rail );
var picked = index == Picked && guard == PickedGuard;
Paint.ClearBrush();
Paint.SetPen( rail.Railing == StairRailing.Handrail
? ArchStairInk.Handrail
: ArchStairInk.Balustrade, picked ? 4f : 2.5f );
Paint.DrawLine( Screen( Flat( from.x, from.y, 0f ) ), Screen( Flat( to.x, to.y, 0f ) ) );
grips.Add( (ArchStairGrip.RailFoot, index, guard, from) );
grips.Add( (ArchStairGrip.RailHead, index, guard, to) );
grips.Add( (ArchStairGrip.Rail, index, guard, (from + to) * 0.5f) );
}
}
static (Vector2 From, Vector2 To) RailLine( ArchStairLane lane, ArchStairGuardPart rail ) {
var frame = lane.Frame;
if ( rail.Edge == StairEdge.Head ) {
return (frame.Flat( lane.Length, 0f ), frame.Flat( lane.Length, lane.Width ));
}
var hand = rail.Edge == StairEdge.Left ? lane.Width : 0f;
return (frame.Flat( lane.Length * rail.Start, hand ), frame.Flat( lane.Length * rail.End, hand ));
}
static float Fraction( ArchStairLane lane, Vector2 local ) {
return Math.Clamp( ArchStairEdges.Along( lane, local ) / MathF.Max( 1f, lane.Length ), 0f, 1f );
}
void PaintGrips() {
foreach ( var grip in grips ) {
var at = Screen( View == ArchStairView.Plan
? Flat( grip.At.x, grip.At.y, 0f )
: grip.At );
var hot = grip.Step == Picked;
var hue = grip.Kind switch {
ArchStairGrip.Head => ArchStairInk.Head,
ArchStairGrip.Lift => ArchStairInk.Lift,
ArchStairGrip.Body => ArchStairInk.Platform,
ArchStairGrip.Bend => ArchStairInk.Bend,
ArchStairGrip.Rail or ArchStairGrip.RailFoot or ArchStairGrip.RailHead => ArchStairInk.Balustrade,
_ => ArchStairInk.Reach
};
Paint.ClearPen();
Paint.SetBrush( hue.WithAlpha( hot ? 1f : 0.65f ) );
Paint.DrawRect( Box( at ), 2 );
}
}
static Rect Box( Vector2 at ) => new( at.x - GripSize * 0.5f, at.y - GripSize * 0.5f, GripSize, GripSize );
static readonly (Color Hue, string Name)[] Keys =
{
(ArchStairInk.Flight, "flight"),
(ArchStairInk.Platform, "platform"),
(ArchStairInk.Balustrade, "railing"),
(ArchStairInk.Walls, "walls"),
(ArchStairInk.Floors, "floors")
};
void PaintLegend() {
var top = 8f;
Paint.SetDefaultFont( 7 );
foreach ( var key in Keys ) {
Paint.ClearPen();
Paint.SetBrush( key.Hue.WithAlpha( 0.85f ) );
Paint.DrawRect( new Rect( Width - 62f, top + 3f, 8f, 8f ), 2 );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
Paint.DrawText( new Rect( Width - 50f, top, 44f, 14f ), key.Name, TextFlag.LeftCenter );
top += 13f;
}
}
void PaintProfile( ArchStairPart stair, Rect band, float[] climbs, float[] bases ) {
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground.Darken( 0.45f ) );
Paint.DrawRect( band );
Paint.ClearBrush();
Paint.SetPen( Theme.TextControl.WithAlpha( 0.14f ), 1f );
Paint.DrawLine( new Vector2( band.Left, band.Top ), new Vector2( band.Right, band.Top ) );
var run = 0f;
foreach ( var lane in stair.Lanes ) {
run += lane.Length;
}
var climb = MathF.Max( 1f, stair.TotalRise );
var left = band.Left + 58f;
var seat = band.Bottom - 20f;
bandZoom = MathF.Min( (band.Right - 18f - left) / MathF.Max( 1f, run ), (seat - band.Top - 22f) / climb );
bandFoot = new Vector2( left, seat );
PaintFloorLines( stair, band );
PaintClimbNote( stair, band, run, PaintWalked( stair, band, climbs, bases ) );
}
Vector2 Profiled( float along, float lift ) => new( bandFoot.x + along * bandZoom, bandFoot.y - lift * bandZoom );
void PaintFloorLines( ArchStairPart stair, Rect band ) {
Paint.SetDefaultFont( 7 );
foreach ( var (level, lift) in Floors( stair ) ) {
var line = Profiled( 0f, lift ).y;
if ( line < band.Top + 12f || line > band.Bottom - 6f ) {
continue;
}
Paint.ClearBrush();
Paint.SetPen( ArchStairInk.Floors.WithAlpha( 0.45f ), 1f );
Paint.DrawLine( new Vector2( band.Left + 54f, line ), new Vector2( band.Right - 8f, line ) );
Paint.SetPen( ArchStairInk.Floors.WithAlpha( 0.9f ) );
Paint.DrawText( new Rect( band.Left + 6f, line - 7f, 44f, 14f ),
$"{level} · {stair.BaseHeight + lift:0}", TextFlag.RightCenter );
}
}
float PaintWalked( ArchStairPart stair, Rect band, float[] climbs, float[] bases ) {
var along = 0f;
var carried = Vector2.Zero;
var arrival = 0f;
for ( var index = 0; index < stair.Lanes.Count; index++ ) {
var lane = stair.Lanes[index];
var foot = bases[index] - stair.BaseHeight;
var head = foot + (lane.Climbs ? climbs[index] : 0f);
var picked = index == Picked;
var start = Profiled( along, foot );
walked.Add( (index, new Rect( start.x, band.Top, MathF.Max( 2f, lane.Length * bandZoom ), band.Height )) );
// A pinned platform lifts the climb before it stands, so the walking line jumps. Drawn, because a split
// level nobody can see is a split level nobody meant.
if ( index > 0 && MathF.Abs( start.y - carried.y ) > 1f ) {
Paint.ClearBrush();
Paint.SetPen( ArchStairInk.Platform.WithAlpha( 0.8f ), 1.5f );
Paint.DrawLine( carried, start );
}
if ( lane.Climbs ) {
PaintRake( stair, lane, along, foot, head, picked );
} else {
PaintPad( lane, along, head, picked );
}
along += lane.Length;
arrival = head;
carried = Profiled( along, head );
pins.Add( (index, carried) );
Paint.ClearPen();
Paint.SetBrush( ArchStairInk.Lift.WithAlpha( picked ? 1f : 0.6f ) );
Paint.DrawRect( Box( carried ), 2 );
if ( !picked ) {
continue;
}
Paint.SetDefaultFont( 7 );
Paint.SetPen( Theme.Primary );
Paint.DrawText( new Rect( carried.x - 62f, carried.y - 16f, 60f, 13f ),
$"{stair.BaseHeight + head:0}", TextFlag.RightCenter );
}
return arrival;
}
void PaintRake( ArchStairPart stair, ArchStairLane lane, float along, float foot, float head, bool picked ) {
var going = stair.StepGoing > 0.5f ? stair.StepGoing : 12f;
var riser = stair.StepRise > 0.5f ? stair.StepRise : 8f;
var steps = ArchStairLanes.Steps( head - foot, riser, lane.Length, going );
var run = lane.Length / steps;
var lift = (head - foot) / steps;
Paint.ClearPen();
Paint.SetBrush( ArchStairInk.Flight.WithAlpha( picked ? 0.5f : 0.24f ) );
for ( var step = 0; step < steps; step++ ) {
var top = Profiled( along + run * step, foot + lift * (step + 1) );
var toe = Profiled( along + run * (step + 1), 0f );
Paint.DrawRect( Rect.FromPoints( top, new Vector2( toe.x, bandFoot.y ) ) );
}
Paint.ClearBrush();
Paint.SetPen( picked ? Theme.Primary : ArchStairInk.Flight, picked ? 2.5f : 1.5f );
Paint.DrawLine( Profiled( along, foot ), Profiled( along + lane.Length, head ) );
}
void PaintPad( ArchStairLane lane, float along, float level, bool picked ) {
var top = Profiled( along, level );
var end = Profiled( along + lane.Length, level );
Paint.ClearPen();
Paint.SetBrush( ArchStairInk.Platform.WithAlpha( picked ? 0.5f : 0.24f ) );
Paint.DrawRect( Rect.FromPoints( top, new Vector2( end.x, bandFoot.y ) ) );
Paint.ClearBrush();
Paint.SetPen( picked ? Theme.Primary : ArchStairInk.Platform, picked ? 2.5f : 1.5f );
Paint.DrawLine( top, end );
}
void PaintClimbNote( ArchStairPart stair, Rect band, float run, float arrival ) {
Paint.SetDefaultFont( 7 );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.55f ) );
Paint.DrawText( new Rect( band.Left + 58f, band.Top + 3f, band.Width - 70f, 14f ),
$"profile · {run:0} walked · {arrival:0} climb · arrives {stair.BaseHeight + arrival:0}", TextFlag.LeftCenter );
var head = Profiled( run, arrival );
// The drop to the foot only - the arrival is the last step's own head pin, and a second mark standing on it
// is one mark you can grab and one you cannot.
Paint.ClearBrush();
Paint.SetPen( ArchStairInk.Lift.WithAlpha( 0.7f ), 1f );
Paint.DrawLine( head, new Vector2( head.x, bandFoot.y ) );
}
protected override void OnContextMenu( ContextMenuEvent e ) {
if ( subject() is not { Core: not null } stair || StepUnder( stair, hover ) is var step && step < 0 ) {
return;
}
Picked = step;
Selected?.Invoke( Picked );
var menu = new ContextMenu( this );
var join = menu.AddOption( "Join to the step below", "link", () => {
joining = step;
Update();
} );
join.Enabled = step > 0;
menu.OpenAtCursor();
e.Accepted = true;
}
protected override void OnMousePress( MouseEvent e ) {
base.OnMousePress( e );
held = -1;
dragging = false;
if ( subject() is not { Core: not null } stair ) {
return;
}
// A join waiting for its point takes the click outright - nothing else in the drawing is being asked for
// until it has one.
if ( joining >= 1 && joining < stair.Lanes.Count ) {
Joined( stair, e.LocalPosition );
return;
}
// The head pins in the profile come first: they stand ON a step's own stride, and a pin you can see and
// cannot grab is the drawing lying about what it offers.
foreach ( var pin in pins ) {
if ( !Box( pin.At ).Grow( 4f ).IsInside( e.LocalPosition ) ) {
continue;
}
held = pin.Step;
heldKind = ArchStairGrip.Lift;
heldGuard = -1;
holding = stair;
dragging = true;
profiling = true;
Picked = pin.Step;
Selected?.Invoke( Picked );
Update();
return;
}
// The profile is a drawing of the same steps, so a step is picked in it the way it is picked in the plan.
foreach ( var stride in walked ) {
if ( !stride.At.IsInside( e.LocalPosition ) ) {
continue;
}
Picked = stride.Step;
Selected?.Invoke( Picked );
Update();
return;
}
for ( var index = 0; index < grips.Count; index++ ) {
var grip = grips[index];
var at = Screen( View == ArchStairView.Plan ? Flat( grip.At.x, grip.At.y, 0f ) : grip.At );
if ( !Box( at ).Grow( 3f ).IsInside( e.LocalPosition ) ) {
continue;
}
held = grip.Step;
heldKind = grip.Kind;
heldGuard = grip.Guard;
holding = stair;
dragging = true;
Picked = grip.Step;
PickedGuard = grip.Guard;
Selected?.Invoke( Picked );
Update();
return;
}
// A press that caught no grip still picks the step under it, so the list and the drawing agree about
// which row is being worked on.
if ( StepUnder( stair, e.LocalPosition ) is var step && step >= 0 ) {
Picked = step;
Selected?.Invoke( Picked );
Update();
}
}
int StepUnder( ArchStairPart stair, Vector2 screen ) {
if ( View != ArchStairView.Plan ) {
return -1;
}
var local = Local( screen );
for ( var index = 0; index < stair.Lanes.Count; index++ ) {
if ( ArchFootprint.Contains( stair.Lanes[index].Corners(), local ) ) {
return index;
}
}
return -1;
}
void Joined( ArchStairPart stair, Vector2 screen ) {
var picked = Stations( stair ).FirstOrDefault( station => Box( station.At ).Grow( StationReach ).IsInside( screen ) );
if ( picked.At == default ) {
Update();
return;
}
var lane = stair.Lanes[joining];
joining = -1;
ArchStairSteps.JoinTo( stair, lane, picked.Station );
ArchStairLanes.Reseat( stair.Core, stair.Lanes );
committed?.Invoke();
}
protected override void OnMouseMove( MouseEvent e ) {
base.OnMouseMove( e );
hover = e.LocalPosition;
if ( !dragging || held < 0 || holding is not { Core: not null } stair || held >= stair.Lanes.Count ) {
if ( joining >= 0 ) {
Update();
}
return;
}
var lane = stair.Lanes[held];
var local = Local( e.LocalPosition );
if ( heldKind == ArchStairGrip.Lift ) {
var bases = ArchStairEdges.Bases( stair );
var foot = bases[held] - stair.BaseHeight;
var lift = profiling
? (bandFoot.y - e.LocalPosition.y) / MathF.Max( 0.0001f, bandZoom )
: -local.y;
ArchStairSteps.Lift( stair, lane, ArchGridService.Fine( lift - foot ) );
} else if ( heldKind is ArchStairGrip.Rail or ArchStairGrip.RailFoot or ArchStairGrip.RailHead ) {
if ( heldGuard < 0 || heldGuard >= lane.Guards.Count ) {
return;
}
var rail = lane.Guards[heldGuard];
var at = Fraction( lane, local );
if ( heldKind == ArchStairGrip.RailFoot ) {
rail.From = MathF.Min( at, rail.End - 0.02f );
} else if ( heldKind == ArchStairGrip.RailHead ) {
rail.To = MathF.Max( at, rail.Start + 0.02f );
} else {
// The whole railing slid along its edge, keeping the stretch it covers.
var reach = rail.End - rail.Start;
var start = Math.Clamp( at - reach * 0.5f, 0f, 1f - reach );
rail.From = start;
rail.To = start + reach;
}
} else if ( heldKind == ArchStairGrip.Body ) {
ArchStairSteps.Move( stair, lane, Rested( stair, local ) );
} else if ( heldKind == ArchStairGrip.Bend ) {
ArchStairSteps.Bend( stair, lane, Aimed( stair, lane, local ) );
} else {
var point = Rested( stair, local );
if ( heldKind == ArchStairGrip.Head ) {
ArchStairEdges.Head( lane, point );
} else {
ArchStairEdges.Flank( lane, point, heldKind == ArchStairGrip.Left );
}
ArchStairSteps.Settle( stair, lane );
}
ArchStairLanes.Fit( stair.Core, stair.Lanes );
changed?.Invoke();
Update();
}
// Plan view snaps to walls; elevations have no world-space snap.
Vector2 Rested( ArchStairPart stair, Vector2 local ) {
if ( View == ArchStairView.Plan && tool is not null
&& tool.Walled( stair.Core.Flat( local.x, local.y ), out var wall, Storey ) ) {
return ArchStairLanes.Local( stair.Core, wall );
}
return ArchGridService.Fine( local );
}
// Snaps to BendStep increments and to a neighbour's heading when close.
static float Aimed( ArchStairPart stair, ArchStairLane lane, Vector2 local ) {
var reach = local - lane.Seat;
if ( reach.IsNearZeroLength ) {
return lane.Bearing;
}
var aimed = ArchGridService.Snap( MathF.Atan2( reach.y, reach.x ).RadianToDegree() - lane.Cardinal, BendStep );
var index = stair.Lanes.IndexOf( lane );
foreach ( var neighbour in new[] { index - 1, index + 1 } ) {
if ( neighbour < 0 || neighbour >= stair.Lanes.Count
|| MathF.Abs( aimed - stair.Lanes[neighbour].Bearing ) >= BendStep ) {
continue;
}
return stair.Lanes[neighbour].Bearing;
}
return aimed;
}
protected override void OnMouseReleased( MouseEvent e ) {
base.OnMouseReleased( e );
if ( dragging ) {
// The shaft laid back onto the steps standing in it, once - during the drag it only ever grows, because
// a box that re-seats under the cursor moves the very frame the drag is being read in.
if ( holding is { Core: not null } stair ) {
ArchStairLanes.Reseat( stair.Core, stair.Lanes );
}
committed?.Invoke();
}
dragging = false;
profiling = false;
held = -1;
holding = null;
}
}