Editor-side architecture audit helpers. It computes edge windings to find inverted faces, non-manifold and open edges, and detects border gaps between pieces by comparing borders in a context set and reporting unclosed edges.
using System;
using System.Collections.Generic;
using System.Linq;
using HalfEdgeMesh;
using Sandbox;
namespace Sunless.Architecture;
public static partial class ArchAudit
{
// Shared edges must be walked opposite ways - same way twice winds one face inside out.
static void Windings( List<Piece> pieces, List<ArchFinding> findings, Dictionary<string, int> totals )
{
var flipped = 0;
var open = 0;
var manifold = 0;
foreach ( var piece in pieces )
{
var directed = new Dictionary<(long, long), int>();
var somewhere = new Dictionary<(long, long), Vector3>();
foreach ( var face in piece.Faces )
{
for ( var index = 0; index < face.Corners.Length; index++ )
{
var from = Key( face.Corners[index] );
var to = Key( face.Corners[(index + 1) % face.Corners.Length] );
if ( from == to )
{
continue;
}
directed[(from, to)] = directed.GetValueOrDefault( (from, to) ) + 1;
somewhere[(from, to)] = (face.Corners[index] + face.Corners[(index + 1) % face.Corners.Length]) * 0.5f;
}
}
var seen = new HashSet<(long, long)>();
foreach ( var (edge, forward) in directed )
{
var reverse = (edge.Item2, edge.Item1);
if ( !seen.Add( edge ) || seen.Contains( reverse ) )
{
continue;
}
seen.Add( reverse );
var backward = directed.GetValueOrDefault( reverse );
var total = forward + backward;
if ( total == 1 )
{
open++;
continue;
}
if ( total > 2 )
{
manifold++;
findings.Add( new ArchFinding
{
Check = "windings",
Where = piece.Name,
What = $"non-manifold edge shared by {total} faces",
At = Say( somewhere[edge] ),
Severity = 0.5f
} );
continue;
}
if ( forward == backward )
{
continue;
}
flipped++;
findings.Add( new ArchFinding
{
Check = "windings",
Where = piece.Name,
What = "edge walked the same way by both its faces - one of them is wound inside out",
At = Say( somewhere[edge] ),
Severity = 0.9f
} );
}
}
totals["inverted faces"] = flipped;
totals["non-manifold edges"] = manifold;
totals["open edges"] = open;
}
sealed class Border
{
public Piece Piece { get; init; }
// The one face on it. Everything else in the scene may close it, including its own piece's
// other faces - a skirting's top edge dies into the plaster of the very wall it is drawn with.
public Facet Face { get; init; }
public Vector3 From { get; init; }
public Vector3 To { get; init; }
public float Length => (To - From).Length;
public Vector3 Along( float fraction ) => Vector3.Lerp( From, To, fraction );
}
// The check a render cannot make and a winding check will not: a border - an edge with one face on
// it - that NOTHING closes. A border is legitimate wherever the part next door meets it, either
// edge to edge or by lying flat on that part's surface, which is how a wall foot dies into a slab.
// What is left is a hole: a ceiling short of the wall it should reach, a board that stops before
// the one it should mitre into, a box someone forgot the lid of.
static void Gaps( List<Piece> pieces, List<Piece> context, List<ArchFinding> findings, Dictionary<string, int> totals, List<string> truncated )
{
// Read off the CONTEXT's own borders and filtered by name, never re-derived from the target -
// two passes over the same mesh make two sets of faces, and then every border closes itself.
var wanted = new HashSet<string>( pieces.Select( piece => piece.Name ) );
var borders = Borders( context );
var met = new Dictionary<(long, long), Piece>();
foreach ( var border in borders )
{
var span = Span( border );
met[span] = met.TryGetValue( span, out var first ) && !ReferenceEquals( first, border.Piece ) ? null : border.Piece;
}
var open = 0;
var budget = PairBudget;
foreach ( var border in borders.Where( entry => entry.Length >= MinBorder && wanted.Contains( entry.Piece.Name ) ) )
{
if ( met[Span( border )] is null )
{
continue;
}
if ( budget <= 0 )
{
truncated.Add( "gap comparison budget reached - narrow the target for a complete answer." );
break;
}
budget -= context.Count;
var miss = Unmet( border, context );
if ( miss is null )
{
continue;
}
open++;
findings.Add( new ArchFinding
{
Check = "gaps",
Where = border.Piece.Name,
What = miss.Value < MissReach
? $"{border.Length:0.#} in edge is open and the nearest surface stops {miss.Value:0.##} in short of it"
: $"{border.Length:0.#} in edge is open with nothing within {MissReach:0} in of it",
At = Say( border.Along( 0.5f ) ),
Severity = 0.6f + MathF.Min( 0.35f, border.Length / 400f )
} );
}
totals["unclosed edges"] = open;
}
}