Editor/Services/ArchPick.cs

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.

ReflectionFile Access
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;
	}
}