Editor/Output/ArchAudit.Edges.cs

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.

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