Editor/Output/ArchLocate.cs

Editor-side locating and target-resolution utilities for the architecture editor. Defines ArchExtent for accumulating bounding boxes, ArchTarget as a resolved target describing plans/objects/geometry, and ArchLocate parsing and resolution helpers to interpret target strings (kinds, ids, coordinates, levels) and produce an ArchTarget.

ReflectionFile Access
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

// BBox's default is zero-size at the origin, so Any tracks emptiness before unioning.
public struct ArchExtent
{
	Vector3 low;
	Vector3 high;

	public bool Any { get; private set; }

	public void Add( Vector3 point )
	{
		low = Any ? Vector3.Min( low, point ) : point;
		high = Any ? Vector3.Max( high, point ) : point;
		Any = true;
	}

	public void Add( Vector2 point, float bottom, float top )
	{
		Add( new Vector3( point.x, point.y, bottom ) );
		Add( new Vector3( point.x, point.y, top ) );
	}

	public void Add( IEnumerable<Vector2> loop, float bottom, float top )
	{
		foreach ( var point in loop )
		{
			Add( point, bottom, top );
		}
	}

	public void Add( ArchExtent other )
	{
		if ( !other.Any )
		{
			return;
		}

		Add( other.low );
		Add( other.high );
	}

	public BBox Box => Any ? new BBox( low, high ) : default;

	public ArchExtent Grown( float margin )
	{
		if ( !Any )
		{
			return this;
		}

		var grown = new ArchExtent();
		var reach = new Vector3( margin, margin, margin );

		grown.Add( low - reach );
		grown.Add( high + reach );

		return grown;
	}
}

// Every tool resolves through here, so a target string means the same thing to each.
public sealed class ArchTarget
{
	public string Describe { get; init; } = "plan";
	public string Kind { get; init; } = "plan";
	public object Item { get; init; }
	public ArchRoom Room { get; init; }
	public ArchBuilding Building { get; init; }
	public List<GameObject> Objects { get; init; } = new();

	// Separate from Built: it aims the camera anyway, and divergence shows as disagreement.
	public BBox Planned { get; init; }
	public BBox Built { get; init; }
	public float Yaw { get; init; }
	public int Faces { get; init; }
	public string Note { get; init; }
	public int? Level { get; init; }
	public bool Focused { get; init; }

	public bool HasGeometry => Faces > 0;

	// A point target's geometry is every root - frame the box asked for, not that.
	public bool FixedFrame { get; private init; }

	public ArchTarget Aimed() => new()
	{
		Describe = Describe,
		Kind = Kind,
		Item = Item,
		Room = Room,
		Building = Building,
		Objects = Objects,
		Planned = Planned,
		Built = Built,
		Yaw = Yaw,
		Faces = Faces,
		Note = Note,
		Level = Level,
		Focused = Focused,
		FixedFrame = true
	};

	public BBox Frame
	{
		get
		{
			var extent = new ArchExtent();

			if ( Planned.Size.Length > 0.01f )
			{
				extent.Add( Planned.Mins );
				extent.Add( Planned.Maxs );
			}

			if ( Faces > 0 && !FixedFrame )
			{
				extent.Add( Built.Mins );
				extent.Add( Built.Maxs );
			}

			return extent.Any ? extent.Box : BBox.FromPositionAndSize( Vector3.Zero, 256f );
		}
	}

	public IEnumerable<MeshComponent> Meshes()
	{
		var seen = new HashSet<MeshComponent>();

		foreach ( var node in Objects.Where( node => node.IsValid() ) )
		{
			foreach ( var mesh in node.Components.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants ) )
			{
				if ( mesh.Mesh is not null && seen.Add( mesh ) )
				{
					yield return mesh;
				}
			}
		}
	}

	public IEnumerable<GameObject> RenderObjects()
	{
		var seen = new HashSet<GameObject>();

		foreach ( var root in Objects.Where( node => node.IsValid() ) )
		{
			foreach ( var node in ArchScene.Descendants( root ) )
			{
				if ( seen.Add( node ) )
				{
					yield return node;
				}
			}
		}
	}
}

// Plan ids are unique document-wide; kind keywords cover 'every X' without hunting ids.
public static partial class ArchLocate
{
	public static readonly string[] Kinds =
	{
		"plan", "buildings", "rooms", "walls", "openings", "doors", "windows", "roofs",
		"gutters", "downpipes", "pipes", "brackets", "stairs", "pillars", "porches", "approaches", "fences", "trims", "slabs", "foundations",
		"platforms", "rooflights", "cuts", "roads", "junctions", "tunnels", "walkways", "connections", "connection"
	};

	sealed class Query
	{
		public string Kind { get; init; }
		public int? Id { get; init; }
		public int? Level { get; init; }
	}

	public static ArchTarget Find( ArchTool tool, string target )
	{
		var query = (target ?? string.Empty).Trim();

		if ( query.Length == 0 || query.Equals( "plan", StringComparison.OrdinalIgnoreCase ) || query.Equals( "all", StringComparison.OrdinalIgnoreCase ) )
		{
			return Whole( tool );
		}

		if ( query.Equals( "selection", StringComparison.OrdinalIgnoreCase ) || query.Equals( "picked", StringComparison.OrdinalIgnoreCase ) )
		{
			return Selected( tool );
		}

		if ( query.Equals( "level", StringComparison.OrdinalIgnoreCase )
			|| query.Equals( "storey", StringComparison.OrdinalIgnoreCase )
			|| query.Equals( "floor", StringComparison.OrdinalIgnoreCase ) )
		{
			return Storey( tool, tool.Level );
		}

		if ( Spot( query, out var point, out var radius ) )
		{
			return Around( tool, point, radius );
		}

		if ( !TryQuery( query, out var parsed ) )
		{
			if ( int.TryParse( query, out var id ) )
			{
				return Focus( tool, Part( tool, id ), null );
			}

			return Kind( tool, query, tool.Level );
		}

		if ( parsed.Kind == "level" )
		{
			return Storey( tool, parsed.Level ?? tool.Level );
		}

		if ( parsed.Id.HasValue )
		{
			var part = Part( tool, parsed.Id.Value );

			if ( part.Kind == "none" )
			{
				return part;
			}

			if ( parsed.Kind == "walkway" && (part.Room is null || !ArchAsks.IsWalkway( part.Room )) )
			{
				return Missing( query, "That id is not a walkway room." );
			}

			if ( parsed.Kind is "room" or "wall" or "walkway" )
			{
				if ( parsed.Kind == "room" && part.Kind != "room" || parsed.Kind == "wall" && part.Kind != "wall" )
				{
					return Missing( query, $"That id is not a {parsed.Kind}." );
				}

				return Focus( tool, part, parsed.Level );
			}

			if ( parsed.Kind is "connection" or "connections" )
			{
				return Focus( tool, part, parsed.Level );
			}

			return part;
		}

		return Kind( tool, parsed.Kind, parsed.Level ?? tool.Level );
	}

	// The parse on its own, with no plan to resolve against: what a target STRING means. Public so the
	// harness can pin the syntax every tool description promises.
	public static bool Parses( string query, out string kind, out int? id )
	{
		kind = null;
		id = null;

		if ( !TryQuery( (query ?? string.Empty).Trim(), out var parsed ) )
		{
			return false;
		}

		kind = parsed.Kind;
		id = parsed.Id;

		return true;
	}

	static bool TryQuery( string query, out Query parsed )
	{
		parsed = null;

		var tokens = query
			.Split( new[] { ':', '@', '/', ' ', '=' }, StringSplitOptions.RemoveEmptyEntries )
			.Select( token => token.Trim() )
			.Where( token => token.Length > 0 )
			.ToList();

		if ( tokens.Count == 0 )
		{
			return false;
		}

		var first = tokens[0].ToLowerInvariant();

		if ( first is "level" or "storey" or "floor" )
		{
			if ( tokens.Count < 2 || !int.TryParse( tokens[1], out var level ) )
			{
				return false;
			}

			parsed = new Query
			{
				Kind = tokens.Count > 2 ? Singular( tokens[2] ) : "level",
				Level = level
			};

			return true;
		}

		if ( int.TryParse( first, out var id ) )
		{
			parsed = new Query { Kind = "part", Id = id };
			return true;
		}

		// Matched as singulars on BOTH sides. Kinds are written plural, so testing the token against the
		// list as it stands rejected every singular one - which is every focused target the tools tell
		// you to use: 'wall:84', 'room:42', 'walkway:120' all fell through to the unknown-keyword error.
		if ( !Kinds.Any( known => Singular( known ) == Singular( first ) ) )
		{
			return false;
		}

		var kind = Singular( first );
		int? partId = null;
		int? partLevel = null;

		if ( tokens.Count > 1 && int.TryParse( tokens[1], out var value ) )
		{
			if ( kind is "room" or "wall" or "walkway" or "connection" )
			{
				partId = value;
			}
			else
			{
				partLevel = value;
			}
		}

		if ( tokens.Count > 2 && int.TryParse( tokens[2], out var explicitLevel ) )
		{
			partLevel = explicitLevel;
		}

		parsed = new Query { Kind = kind, Id = partId, Level = partLevel };
		return true;
	}

	static ArchTarget Missing( string query, string note )
	{
		return new ArchTarget { Describe = query, Kind = "none", Note = note };
	}

	// 'x,y,z'@radius: the view path for arch_audit findings.
	static bool Spot( string query, out Vector3 point, out float radius )
	{
		point = default;
		radius = 48f;

		var at = query.IndexOf( '@' );

		if ( at >= 0 )
		{
			if ( float.TryParse( query[(at + 1)..], out var asked ) )
			{
				radius = MathF.Max( 2f, asked );
			}

			query = query[..at];
		}

		var parts = query.Split( ',' );

		if ( parts.Length != 3
			|| !float.TryParse( parts[0], out var x )
			|| !float.TryParse( parts[1], out var y )
			|| !float.TryParse( parts[2], out var z ) )
		{
			return false;
		}

		point = new Vector3( x, y, z );
		return true;
	}
}