Editor/Stair/ArchStairHandles.cs
namespace Sunless.Architecture;
// A stair is authored by GRABBING POINTS, the way a pipe run is: a square on the foot, one on every step's head
// and one on each of its flanks, each dragged where you are looking at it, plus a lift arrow for the climb.
//
// There is no box. A box could only ever say how big the whole thing was and nothing about where any flight in it
// went, and it stood over the very surface the next gesture is drawn on - so the shaft is derived from the steps
// standing in it and is never a widget of its own.
public static class ArchStairHandles {
// A step picked in the stack gets ITS widgets and nobody else's - six flights' worth of squares standing at
// once is a thicket, and the row you clicked is the one you meant to move. Below zero is the whole stair.
public static bool Draw( ArchTool tool, ArchStairPart stair, ArchRoom room, ArchBuilding building = null, int only = -1 ) {
var core = stair.Core;
if ( core is null ) {
return false;
}
var shape = tool.Resolved( stair.Id, () => ArchStairShape.Resolve( stair, room, tool.Kit, building?.Rooms, building ) );
if ( shape.Runs.Count == 0 && shape.Pads.Count == 0 ) {
return false;
}
ArchStairGhost.Flight( shape, Soffit( tool, room ) );
var gesture = ArchGesture.On( ArchKind.Stair, stair.Id );
// A shaft left with no step authored resolves as one flight filling it. The widgets WRITE, so that
// flight has to be real before one can be dragged - a handle editing a list the resolve synthesised
// per call would move nothing and look broken.
if ( stair.Lanes.Count == 0 ) {
stair.Lanes.AddRange( ArchStairShape.Standing( stair, core ).Select( lane => lane.Copy() ) );
}
var lanes = stair.Lanes;
if ( only < 0 && Travelled( tool, gesture, stair, core ) ) {
FollowOpening( stair, room, building, tool.Kit );
return true;
}
if ( only < 0 && Swung( gesture, stair, core ) ) {
FollowOpening( stair, room, building, tool.Kit );
return true;
}
var changed = only < 0 && Footed( tool, gesture, stair, core );
changed |= Headed( tool, gesture, stair, core, lanes, shape, only );
changed |= Flanked( tool, gesture, stair, core, lanes, shape, only );
changed |= Climbs( tool, gesture, stair, lanes, shape, only );
changed |= Terrace( tool, gesture, stair, lanes, shape, only );
changed |= only < 0 && Arrival( tool, gesture, stair, shape );
if ( changed ) {
ArchStairLanes.Fit( core, stair.Lanes );
FollowOpening( stair, room, building, tool.Kit );
}
return changed | (only < 0 && Opening( tool, gesture, stair, core, shape ));
}
static void FollowOpening( ArchStairPart stair, ArchRoom room, ArchBuilding building, ArchKit kit )
{
if ( stair.TopOpening is not { Count: >= 3 } || stair.Core is not { } core )
{
return;
}
var required = ArchStairShape.Resolve( stair, room, kit, building?.Rooms, building )
.AutomaticTopOpening
.SelectMany( loop => loop )
.Select( point => ArchStairLanes.Local( core, point ) )
.ToList();
if ( required.Count < 3 )
{
return;
}
ArchFootprint.Bounds( stair.TopOpening, out var min, out var max );
ArchFootprint.Bounds( required, out var requiredMin, out var requiredMax );
if ( stair.TopOpeningSnap is { Count: >= 3 } )
{
ArchFootprint.Bounds( stair.TopOpeningSnap, out var snappedMin, out var snappedMax );
var opening = stair.TopOpening.ToList();
ArchRemap.Between( snappedMin, snappedMax, requiredMin, requiredMax ).Loop( opening );
stair.TopOpening = opening;
stair.TopOpeningSnap = ArchFootprint.Rect( requiredMin, requiredMax );
return;
}
var stoodMin = min;
var stoodMax = max;
var minX = min.x;
var maxX = max.x;
var minY = min.y;
var maxY = max.y;
Contain( requiredMin.x, requiredMax.x, ref minX, ref maxX );
Contain( requiredMin.y, requiredMax.y, ref minY, ref maxY );
stair.TopOpening = ArchFootprint.Rect( new Vector2( minX, minY ), new Vector2( maxX, maxY ) );
if ( (stoodMin - new Vector2( minX, minY )).Length > 0.01f
|| (stoodMax - new Vector2( maxX, maxY )).Length > 0.01f )
{
stair.TopOpeningSnap = ArchFootprint.Rect( requiredMin, requiredMax );
}
}
static void Contain( float requiredMin, float requiredMax, ref float min, ref float max )
{
var size = max - min;
var requiredSize = requiredMax - requiredMin;
if ( size < requiredSize )
{
min = requiredMin;
max = requiredMax;
return;
}
if ( min > requiredMin )
{
max += requiredMin - min;
min = requiredMin;
}
if ( max < requiredMax )
{
min += requiredMax - max;
max = requiredMax;
}
}
static bool Opening( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairCore core, ArchStairShape shape ) {
var well = shape.Levels.Find( candidate => candidate.Level == shape.TopLevel );
if ( well is null || well.Loops.Count == 0 ) {
return false;
}
var outline = stair.TopOpening is { Count: >= 3 }
? stair.TopOpening.Select( point => core.Flat( point.x, point.y ) ).ToList()
: Boxed( core, well.Loops );
var height = well.Height + 2f;
var colour = Color.Magenta;
Gizmo.Draw.Color = colour.WithAlpha( 0.85f );
ArchGhost.Ring( outline, height );
ArchFootprint.Bounds( outline, out var min, out var max );
var centre = (min + max) * 0.5f;
var changed = false;
if ( ArchShapeHandles.Landed( tool, gesture.At( "opening.move" ), new Vector3( centre.x, centre.y, height ), out var moved, colour, core.Yaw ) ) {
var shift = moved - centre;
for ( var index = 0; index < outline.Count; index++ ) {
outline[index] += shift;
}
changed = true;
}
changed |= ArchShapeHandles.Edges( tool, gesture.At( "opening.resize" ), outline, height, null, colour );
if ( changed ) {
stair.TopOpening = outline.Select( point => ArchStairLanes.Local( core, point ) ).ToList();
stair.TopOpeningSnap = null;
}
return changed;
}
static List<Vector2> Boxed( ArchStairCore core, IReadOnlyList<List<Vector2>> loops ) {
var points = loops.SelectMany( loop => loop ).Select( point => ArchStairLanes.Local( core, point ) ).ToList();
ArchFootprint.Bounds( points, out var min, out var max );
return core.Axes.Rect( min.x, max.x, min.y, max.y );
}
static float Soffit( ArchTool tool, ArchRoom room ) {
if ( room is null ) {
return tool.LevelHeight + tool.Kit.WallHeight;
}
return room.BaseHeight + (room.WallHeight > 0f ? room.WallHeight : tool.Kit.WallHeight);
}
// ---- Where the whole thing stands ----
static bool Travelled( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairCore core ) {
var standing = stair.BaseHeight;
var foot = core.Origin;
if ( !ArchShapeHandles.Position( tool, gesture.At( "move" ), new Vector3( foot.x, foot.y, standing ), out var moved ) ) {
return false;
}
var shift = new Vector2( moved.x, moved.y ) - foot;
ArchCarry.Stair( stair, ArchRemap.Translation( shift ) );
stair.BaseHeight = moved.z;
return shift.Length > 0.01f || MathF.Abs( moved.z - standing ) > 0.01f;
}
// Turning the shaft turns every step in it, because a lane is stated in the core's own frame and has never
// held a world coordinate to swing.
static bool Swung( ArchGesture gesture, ArchStairPart stair, ArchStairCore core ) {
var foot = core.Origin;
if ( !ArchShapeHandles.Turned( gesture.At( "turn" ), new Vector3( foot.x, foot.y, stair.BaseHeight ), out var degrees, ArchGridService.AngleStep ) ) {
return false;
}
core.Yaw += degrees;
return true;
}
// The bottom step, as a point you drag rather than a mode you switch into. It carries the whole stair, so it
// is the one handle that answers "where does this go" without asking the author to find the mover first.
static bool Footed( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairCore core ) {
// The engine's mover already stands on this exact point in Move mode, and two widgets in one place is
// one widget - whichever hit-tests first takes the press and the other can be seen and never grabbed.
if ( ArchShapeHandles.Mode == ArchHandleMode.Move ) {
return false;
}
var foot = core.Origin;
if ( !ArchShapeHandles.Control( tool, gesture.At( "foot" ), new Vector3( foot.x, foot.y, stair.BaseHeight ), out var moved, Gizmo.Colors.Pitch ) ) {
return false;
}
core.Origin = new Vector2( moved.x, moved.y );
stair.BaseHeight = moved.z;
return true;
}
// ---- Each flight's own head and flanks ----
// The point a flight ARRIVES at. Dragged in plan it lengthens or shortens that flight - which is where the
// landing after it begins - and dragged in an elevation its height PINS the flight's climb, exactly as the
// lift arrow does. One point, both questions, standing on the geometry it moves.
static bool Headed( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairCore core, IReadOnlyList<ArchStairLane> lanes, ArchStairShape shape, int only ) {
foreach ( var run in shape.Runs ) {
if ( run.Index >= lanes.Count || (only >= 0 && run.Index != only) ) {
continue;
}
var lane = lanes[run.Index];
var at = run.Axes.Point( run.Length, run.Width * 0.5f, run.TopHeight );
if ( !ArchShapeHandles.Control( tool, gesture.At( "head", run.Index ), at, out var moved, Gizmo.Colors.Roll ) ) {
continue;
}
ArchStairEdges.Head( lane, ArchStairLanes.Local( core, new Vector2( moved.x, moved.y ) ) );
if ( MathF.Abs( moved.z - run.TopHeight ) > 0.01f ) {
lane.Rise = MathF.Max( MathF.Max( 1f, stair.StepRise ), moved.z - run.BaseHeight );
}
ArchStairSteps.Settle( stair, lane );
return true;
}
return false;
}
// Both flanks of every flight, so a width is DRAGGED rather than typed - and each flight keeps its own, which
// is what lets a grand lower flight arrive at a narrower upper one.
static bool Flanked( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairCore core, IReadOnlyList<ArchStairLane> lanes, ArchStairShape shape, int only ) {
foreach ( var run in shape.Runs ) {
if ( run.Index >= lanes.Count || (only >= 0 && run.Index != only) ) {
continue;
}
var lane = lanes[run.Index];
var middle = run.Length * 0.5f;
foreach ( var side in new[] { 0f, run.Width } ) {
var at = run.Axes.Point( middle, side, run.Rake( middle ) );
var key = side > 0f ? "left" : "right";
var outward = run.Axes.Across * (side > 0f ? 1f : -1f);
if ( !ArchShapeHandles.Arrow( tool, gesture.At( key, run.Index ), at, outward, out var moved, Color.Red ) ) {
continue;
}
ArchStairEdges.Flank( lane, ArchStairLanes.Local( core, moved ), side > 0f );
if ( lanes.Count( entry => entry.Climbs ) == 1 ) {
stair.Width = MathF.Max( ArchStairLanes.MinLane, lane.Width );
}
ArchStairSteps.Settle( stair, lane );
return true;
}
}
return false;
}
// ---- The climb ----
// The up-axis arrow, which is the one widget that works from ANY camera - a head square in a plan view can
// only be dragged across the ground. A stair of one step has no arrow of its own: its climb is the shaft's.
static bool Climbs( ArchTool tool, ArchGesture gesture, ArchStairPart stair, IReadOnlyList<ArchStairLane> lanes, ArchStairShape shape, int only ) {
if ( lanes.Count < 2 ) {
return false;
}
foreach ( var run in shape.Runs ) {
if ( run.Index >= lanes.Count || (only >= 0 && run.Index != only) ) {
continue;
}
var head = run.Axes.Flat( run.Length, run.Width * 0.5f );
if ( !ArchShapeHandles.Lift( tool, gesture.At( "climb", run.Index ), head, run.TopHeight, out var lifted ) ) {
continue;
}
lanes[run.Index].Rise = MathF.Max( MathF.Max( 1f, stair.StepRise ), lifted - run.BaseHeight );
return true;
}
return false;
}
// A landing's own two gestures, both on the pad it stands on. Its DEPTH is pushed on the far edge, and its
// HEIGHT is the same lift arrow a flight gets - a landing lifted off the flight below it is a split level,
// which is the one thing a derived pad could never be.
static bool Terrace( ArchTool tool, ArchGesture gesture, ArchStairPart stair, IReadOnlyList<ArchStairLane> lanes, ArchStairShape shape, int only ) {
foreach ( var pad in shape.Pads ) {
if ( pad.Index < 0 || pad.Index >= lanes.Count || (only >= 0 && pad.Index != only) ) {
continue;
}
var lane = lanes[pad.Index];
var across = (pad.AcrossFrom + pad.AcrossTo) * 0.5f;
var middle = pad.Axes.Flat( (pad.AlongFrom + pad.AlongTo) * 0.5f, across );
if ( ArchShapeHandles.Lift( tool, gesture.At( "pad", pad.Index ), middle, pad.Height, out var lifted ) ) {
lane.Rise = MathF.Max( 0f, lifted - (pad.Height - MathF.Max( 0f, lane.Rise )) );
return true;
}
var far = pad.Axes.Point( pad.AlongTo, across, pad.Height );
if ( ArchShapeHandles.Push( tool, gesture.At( "terrace", pad.Index ), far,
pad.Axes.Flat( pad.AlongTo, across ), pad.Axes.Along, out var push, Gizmo.Colors.Blue ) ) {
ArchStairEdges.Grow( lane, push );
ArchStairSteps.Settle( stair, lane );
return true;
}
}
return false;
}
// The one landing whose depth is authored on the stair rather than in the list, because nothing follows it
// to bound it - and it only exists where the walk did not end on a platform of its own.
static bool Arrival( ArchTool tool, ArchGesture gesture, ArchStairPart stair, ArchStairShape shape ) {
if ( !stair.TopLanding ) {
return false;
}
var pad = shape.Pads.Find( candidate => candidate.Arrival && candidate.Index < 0 );
if ( pad is null ) {
return false;
}
var middle = (pad.AcrossFrom + pad.AcrossTo) * 0.5f;
var head = pad.Axes.Point( pad.AlongTo, middle, pad.Height );
if ( !ArchShapeHandles.Push( tool, gesture.At( "landing" ), head,
pad.Axes.Flat( pad.AlongTo, middle ), pad.Axes.Along, out var push, Gizmo.Colors.Blue ) ) {
return false;
}
stair.TopLandingDepth = MathF.Max( shape.Going, pad.AlongTo - pad.AlongFrom + push );
return true;
}
}