Editor/Output/ArchAudit.cs

Editor utility that audits architectural meshes. Collects pieces and facets from meshes, computes normals, areas, bounds, and runs a suite of checks (windings, gaps, degenerate, coplanar, intersections, buried) to produce a ranked report of findings.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using HalfEdgeMesh;
using Sandbox;

namespace Sunless.Architecture;

public sealed class ArchFinding
{
	public string Check { get; init; }
	public string Where { get; init; }
	public string What { get; init; }
	public string At { get; init; }
	public float Severity { get; init; }
}

public sealed class ArchAuditResult
{
	public string Target { get; init; }
	public int Objects { get; init; }
	public int Faces { get; init; }
	public Dictionary<string, int> Totals { get; init; } = new();
	public List<ArchFinding> Findings { get; init; } = new();
	public List<string> Truncated { get; init; } = new();
	public string Verdict { get; init; }
}

// Defects a screenshot can't settle - each finding aims via arch_view, not hunting.
public static partial class ArchAudit
{
	// 0.01in - ArchFootprint's tolerance, so shared corners key by exact equality.
	const float Grain = 100f;
	const float Skin = 0.15f;
	const float Reach = 12f;
	const float MinArea = 0.75f;
	const int PairBudget = 400000;
	// Shorter than this and every mitre's own cut reads as a hole.
	const float MinBorder = 2f;
	// How near a surface has to be before a border counts as dying into it. Wider than ArchContact's
	// Proud, because every generator laps its parts by a bite ON PURPOSE and that slop is contact.
	const float Closing = 1f;
	// Far enough to name the miss ("stops 0.5 in short"), near enough that the wall opposite is not it.
	const float MissReach = 6f;

	public static ArchAuditResult InspectScene( ArchTool tool, ArchTarget target, IReadOnlyCollection<string> checks, int limit )
	{
		return Report( Collect( target ), target.Describe, tool.Scene, checks, limit, Generated( tool.Scene ) );
	}

	public static ArchAuditResult InspectCanvases( IReadOnlyDictionary<string, ArchMesh> parts, IReadOnlyCollection<string> checks = null, int limit = 40 )
	{
		var pieces = parts
			.Where( part => part.Value is not null )
			.Select( part => Of( part.Key, part.Value.Finish(), part.Value.Projection ) )
			.ToList();

		return Report( pieces, string.Join( ", ", parts.Keys ), null, checks, limit );
	}

	// Everything generated, whatever the target narrows to: whether a border is closed is a question
	// about the part NEXT DOOR, so auditing one room with only that room in hand answers it wrong.
	static List<Piece> Generated( Scene scene )
	{
		return ArchScene.FindRoots( scene )
			.SelectMany( root => root.Components.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants ) )
			.Where( renderer => renderer.Mesh is not null )
			.Select( renderer => Of( Path( renderer.GameObject ), renderer.Mesh, renderer.WorldTransform ) )
			.ToList();
	}

	static ArchAuditResult Report( List<Piece> pieces, string describe, Scene scene, IReadOnlyCollection<string> checks, int limit, List<Piece> context = null )
	{
		var findings = new List<ArchFinding>();
		var totals = new Dictionary<string, int>();
		var truncated = new List<string>();

		var wanted = checks is { Count: > 0 }
			? new HashSet<string>( checks.Select( check => check.ToLowerInvariant() ) )
			: new HashSet<string> { "windings", "gaps", "coplanar", "degenerate", "intersections", "buried" };

		if ( wanted.Contains( "windings" ) )
		{
			Windings( pieces, findings, totals );
		}

		if ( wanted.Contains( "gaps" ) )
		{
			Gaps( pieces, context is { Count: > 0 } ? context : pieces, findings, totals, truncated );
		}

		if ( wanted.Contains( "degenerate" ) )
		{
			Degenerate( pieces, findings, totals );
		}

		if ( wanted.Contains( "coplanar" ) )
		{
			Coplanar( pieces, findings, totals, truncated );
		}

		if ( wanted.Contains( "intersections" ) )
		{
			Intersections( pieces, findings, totals );
		}

		if ( wanted.Contains( "buried" ) )
		{
			if ( scene is null )
			{
				truncated.Add( "buried needs somewhere to trace, so it was not run." );
			}
			else
			{
				Buried( scene, pieces, findings, totals );
			}
		}

		var ranked = findings
			.OrderByDescending( finding => finding.Severity )
			.ToList();

		if ( ranked.Count > limit )
		{
			truncated.Add( $"{ranked.Count - limit} further findings not shown - raise limit or narrow the target." );
			ranked = ranked.Take( limit ).ToList();
		}

		return new ArchAuditResult
		{
			Target = describe,
			Objects = pieces.Count,
			Faces = pieces.Sum( piece => piece.Faces.Count ),
			Totals = totals,
			Findings = ranked,
			Truncated = truncated,
			Verdict = Verdict( totals )
		};
	}

	// "open edges" is a count, not a fault - a border closed by the piece next door is how parts meet.
	// The fault is the subset `gaps` proves nothing closes.
	static string Verdict( Dictionary<string, int> totals )
	{
		var problems = totals
			.Where( entry => entry.Value > 0 && entry.Key is not ("open edges" or "faces") )
			.Select( entry => $"{entry.Value} {entry.Key}" )
			.ToList();

		return problems.Count == 0 ? "Clean." : string.Join( ", ", problems ) + ".";
	}

	sealed class Piece
	{
		public string Name { get; init; }
		public List<Facet> Faces { get; init; } = new();
		public BBox Bounds { get; set; }
	}

	sealed class Facet
	{
		public FaceHandle Handle { get; init; }
		public int HandleIndex { get; init; }
		public Vector3[] Corners { get; init; }
		public Vector3 Normal { get; init; }
		public Vector3 Centre { get; init; }
		public Vector3 TextureAxisU { get; init; }
		public Vector3 TextureAxisV { get; init; }
		public float Area { get; init; }
		public string Material { get; init; }
	}

	static List<Piece> Collect( ArchTarget target )
	{
		return target.Meshes()
			.Select( renderer => Of( Path( renderer.GameObject ), renderer.Mesh, renderer.WorldTransform ) )
			.ToList();
	}

	static Piece Of( string name, PolygonMesh mesh, Transform transform )
	{
		var piece = new Piece { Name = name };
		var extent = new ArchExtent();

		foreach ( var handle in mesh.FaceHandles.ToList() )
		{
			var corners = mesh.GetFaceVertexPositions( handle, transform ).ToArray();

			if ( corners.Length < 3 )
			{
				continue;
			}

			mesh.ComputeFaceNormal( handle, out var local );
			mesh.GetFaceTextureParameters( handle, out var axisU, out var axisV, out _ );

			piece.Faces.Add( new Facet
			{
				Handle = handle,
				HandleIndex = handle.Index,
				Corners = corners,
				Normal = transform.NormalToWorld( local ).Normal,
				Centre = Centroid( corners ),
				TextureAxisU = axisU,
				TextureAxisV = axisV,
				Area = Area( corners, ArchMesh.Newell( corners ) ),
				Material = mesh.GetFaceMaterial( handle )?.ResourcePath ?? "none"
			} );

			foreach ( var corner in corners )
			{
				extent.Add( corner );
			}
		}

		piece.Bounds = extent.Box;

		return piece;
	}
}