Editor/Output/ArchLocate.cs
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", "cabinets", "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;
}
}