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.
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;
}
}