Editor utility that performs picking queries on architectural plan data. It finds approaches, roofs, walls, openings and other parts under a 2D point on a specified level, collects hit candidates, ranks and returns a bounded sorted list of ArchHit results.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// One pick for every caller: flights by footprint, not by their far-away origin.
public static class ArchPick
{
static IEnumerable<ArchRoom> Storey( ArchPlan plan, int level )
{
return plan?.Buildings.SelectMany( building => building.Rooms ).Where( room => room.Floor == level )
?? Enumerable.Empty<ArchRoom>();
}
// By its rectangle, not its origin: the origin sits at the wall end.
public static ArchApproachPart ApproachIn( ArchRoom room, Vector2 point )
{
return room?.Approaches.FirstOrDefault( approach => Covers( approach, point ) );
}
public static ArchApproachPart ApproachUnder( ArchPlan plan, int level, Vector2 point, out ArchRoom room )
{
room = null;
foreach ( var candidate in Storey( plan, level ) )
{
if ( ApproachIn( candidate, point ) is not { } approach )
{
continue;
}
room = candidate;
return approach;
}
return null;
}
static bool Covers( ArchApproachPart approach, Vector2 point )
{
if ( approach.IsSpline )
{
return approach.Curve().Nearest( point, out var frame, out var gap )
&& gap <= MathF.Max( 12f, approach.Width * frame.WidthScale ) * 0.5f + 4f;
}
var outward = ArchApproachAxes.Outward( approach );
var across = new Vector2( -outward.y, outward.x );
var local = point - approach.Origin;
var along = Vector2.Dot( local, outward );
var side = MathF.Abs( Vector2.Dot( local, across ) );
return along >= -4f && along <= MathF.Max( 12f, approach.Run ) + 4f && side <= approach.Width * 0.5f + 4f;
}
// Wound first: an authored outline can arrive either way round.
public static ArchRoofPart RoofUnder( ArchBuilding building, int level, Vector2 point )
{
return building?.Roofs.FirstOrDefault( roof =>
roof.Level == level && ArchFootprint.Contains( ArchFootprint.Wind( roof.Outline() ), point ) );
}
public static ArchRoofPart RoofUnder( ArchPlan plan, int level, Vector2 point, out ArchBuilding owner )
{
owner = null;
foreach ( var building in plan?.Buildings ?? new List<ArchBuilding>() )
{
if ( RoofUnder( building, level, point ) is not { } roof )
{
continue;
}
owner = building;
return roof;
}
return null;
}
// The bounded, ranked answer to "what authored things are under this point?". The projected
// tree names the layers; the walkway footprint outranks its own walls, everything else follows
// the picker's long-standing order, and the stack keeps the also-rans the old single-winner hid.
public static IReadOnlyList<ArchHit> HitsAt( ArchLayerTree tree, ArchKit kit, int level, Vector2 point, int limit = 12 )
{
var hits = new List<ArchHit>();
var kinds = ArchKinds.Load();
if ( tree is not null )
{
foreach ( var group in tree.Domains )
{
foreach ( var root in group.Children )
{
Collect( root, kit, level, point, hits, kinds );
}
}
}
// The group a hit stands in is a thing you can pick too - it is what moves the whole
// assembly - but it never wins the click, so it rides at the bottom of the stack.
foreach ( var group in hits
.Select( hit => tree?.Find( hit.Layer.ItemId ) )
.Select( GroupAbove )
.Where( node => node is not null )
.Distinct()
.ToList() )
{
hits.Add( Hit( group, ArchHitChannel.Boundary, 0f, false ) );
}
hits.Sort( ( left, right ) =>
{
var rank = Rank( kinds, left ).CompareTo( Rank( kinds, right ) );
return rank != 0 ? rank : left.Distance.CompareTo( right.Distance );
} );
return hits.Take( Math.Max( 1, limit ) ).ToList();
}
static void Collect( ArchLayerNode node, ArchKit kit, int level, Vector2 point, List<ArchHit> hits, ArchKinds kinds )
{
if ( node is null )
{
return;
}
if ( node.Payload is { } payload )
{
if ( ArchPickers.Load().For( node.Kind ) is { } picker )
{
picker.Under( new ArchPicking( node, kit, level, point, hits ) );
}
else
switch ( payload )
{
case ArchBuilding building:
if ( ArchHandles.Bounds( building, out var boundsMin, out var boundsMax )
&& point.x >= boundsMin.x && point.x <= boundsMax.x
&& point.y >= boundsMin.y && point.y <= boundsMax.y )
{
hits.Add( Hit( node, ArchHitChannel.Boundary, 0f, false ) );
}
break;
// A spanning room is a Walkway; the rank, not the case, decides which it outranks.
case ArchRoom room:
var loop = ArchFloorGen.Footprint( room );
if ( loop.Count >= 3 && ArchFootprint.Contains( loop, point ) )
{
hits.Add( Hit( node, ArchHitChannel.Footprint, 0f, true ) );
}
break;
case ArchWall wall:
WallHit( node, wall, point, hits );
break;
case ArchOpening opening:
if ( node.Parent?.Payload is ArchWall owner )
{
StationHit( node, owner, opening.Left, opening.Right, point, hits );
}
break;
case ArchWallModPart modifier:
if ( node.Parent?.Payload is ArchWall host )
{
StationHit( node, host, modifier.Left, modifier.Right, point, hits );
}
break;
case ArchApproachPart approach when node.Room is not null:
if ( ApproachIn( node.Room, point ) == approach )
{
hits.Add( Hit( node, ArchHitChannel.Footprint, 0f, true ) );
}
break;
case ArchFencePart fence:
if ( fence.Curve().Nearest( point, out _, out var fenceGap )
&& fenceGap <= MathF.Max( 36f, fence.Barrier.PostSize * 4f ) )
{
hits.Add( Hit( node, ArchHitChannel.Path, fenceGap, true ) );
}
break;
case ArchCutPart cut when cut.Level == level:
if ( cut.Outlines().Any( outline => ArchFootprint.Contains( outline, point ) ) )
{
hits.Add( Hit( node, ArchHitChannel.Footprint, 0f, true ) );
}
break;
case ArchPlatformPart platform when platform.Level == level:
if ( ArchFootprint.Contains( platform.Outline(), point ) )
{
hits.Add( Hit( node, ArchHitChannel.Footprint, 0f, true ) );
}
break;
case ArchBeamPart beam when beam.Level == level:
if ( ArchFootprint.Contains( beam.Outline(), point ) )
{
hits.Add( Hit( node, ArchHitChannel.Footprint, 0f, true ) );
}
break;
case ArchDownpipePart pipe:
var pipeGap = (point - pipe.Wall).Length;
if ( pipeGap < 96f )
{
hits.Add( Hit( node, ArchHitChannel.Anchor, pipeGap, false ) );
}
break;
case ArchRoofPart roof when roof.Level == level:
if ( node.Building is { } roofOwner && RoofUnder( roofOwner, level, point ) == roof )
{
hits.Add( Hit( node, ArchHitChannel.Footprint, 0f, true ) );
}
break;
}
}
foreach ( var child in node.Children )
{
Collect( child, kit, level, point, hits, kinds );
}
}
// Anything filed at a station on a wall - a hole, a pilaster - is under the cursor when the
// cursor is beside that stretch of the run.
static void StationHit( ArchLayerNode node, ArchWall wall, float left, float right, Vector2 point, List<ArchHit> hits )
{
var length = wall.Length;
if ( length < 0.5f )
{
return;
}
var at = Math.Clamp( Vector2.Dot( point - wall.Start, wall.Direction ), 0f, length );
var gap = (point - wall.PointAt( at )).Length;
if ( gap < 96f && at >= left && at <= right )
{
hits.Add( Hit( node, ArchHitChannel.Span, gap, false ) );
}
}
static void WallHit( ArchLayerNode node, ArchWall wall, Vector2 point, List<ArchHit> hits )
{
if ( wall.Length < 0.5f )
{
return;
}
var at = Math.Clamp( Vector2.Dot( point - wall.Start, wall.Direction ), 0f, wall.Length );
var gap = (point - wall.PointAt( at )).Length;
if ( gap < 96f )
{
hits.Add( Hit( node, ArchHitChannel.Boundary, gap, false ) );
}
}
static ArchLayerNode GroupAbove( ArchLayerNode node )
{
for ( var current = node?.Parent; current is not null; current = current.Parent )
{
if ( current.Kind == ArchKind.Assembly )
{
return current;
}
}
return null;
}
internal static ArchLayerNode Child( ArchLayerNode parent, object payload )
{
return parent?.Children.FirstOrDefault( child => ReferenceEquals( child.Payload, payload ) );
}
internal static ArchHit Hit( ArchLayerNode node, ArchHitChannel channel, float distance, bool direct )
{
return new ArchHit
{
Layer = node.Ref ?? new ArchLayerRef { ItemId = -1, Kind = node.Kind },
Channel = channel,
Distance = distance,
Depth = Depth( node ),
Direct = direct,
};
}
static int Depth( ArchLayerNode node )
{
var depth = 0;
for ( var current = node.Parent; current is not null; current = current.Parent )
{
depth++;
}
return depth;
}
static int Rank( ArchKinds kinds, ArchHit hit )
{
var kind = hit.Layer.Kind;
// A group is what the click means: you drew a walkway between two houses, so clicking any of
// it hands you the join. A repeat click cycles down into the member you actually want.
if ( kind == ArchKind.Assembly )
{
return -1;
}
if ( hit.Direct )
{
return kinds.For( kind )?.DirectRank ?? 200;
}
return kinds.For( kind )?.IndirectRank ?? 999;
}
}