Editor/Output/ArchAudit.Geometry.cs

Editor-side geometry audit utilities for architecture pieces. It provides overlap and intersection tests for facets and flattened polygons, buried-face detection via ray traces, area/centroid/basis helpers, sampling and spatial heuristics, and reporting helpers used to produce ArchFinding entries.

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

namespace Sunless.Architecture;
public static partial class ArchAudit
{
	static bool Overlapping( Facet a, Facet b )
	{
		return Overlapping( a.Corners, b.Corners, a.Normal, a.Centre );
	}

	// Sampled, not clipped: concave faces break a convex clip; centre-pulled points skip shared edges.
	static bool Overlapping( Vector3[] a, Vector3[] b, Vector3 normal, Vector3 origin )
	{
		if ( !Near( a, b ) )
		{
			return false;
		}

		Basis( ArchMeshContactService.Canonical( normal ), out var right, out var up );

		// Both flattened about the SAME origin, or the two 2D loops are in unrelated frames.
		var flatA = Flatten( a, origin, right, up );
		var flatB = Flatten( b, origin, right, up );

		return Samples( flatA ).Count( point => Contains( flatB, point ) ) >= 2
			|| Samples( flatB ).Count( point => Contains( flatA, point ) ) >= 2
			|| Crossing( flatA, flatB );
	}

	// Sampling alone only sees an overlap wide enough for centre-pulled points to fall inside it, so a SLIVER -
	// a ceiling cap lapping an inch over a lintel, a run four inches wide down a jamb - read as clean and was
	// fixed by eye over and over. Two loops whose interiors meet partially always cross an edge, so the crossing
	// is the honest test. Strictly interior on both segments, or every pair of butting faces reports as a fight.
	static bool Crossing( Vector2[] a, Vector2[] b )
	{
		for ( var one = 0; one < a.Length; one++ )
		{
			for ( var two = 0; two < b.Length; two++ )
			{
				if ( Crosses( a[one], a[(one + 1) % a.Length], b[two], b[(two + 1) % b.Length] ) )
				{
					return true;
				}
			}
		}

		return false;
	}

	static bool Crosses( Vector2 from, Vector2 to, Vector2 other, Vector2 otherTo )
	{
		var run = to - from;
		var otherRun = otherTo - other;
		var denominator = run.x * otherRun.y - run.y * otherRun.x;

		if ( MathF.Abs( denominator ) < 0.0001f )
		{
			return false;
		}

		var gap = other - from;
		var along = (gap.x * otherRun.y - gap.y * otherRun.x) / denominator;
		var across = (gap.x * run.y - gap.y * run.x) / denominator;

		return along > Grazing && along < 1f - Grazing && across > Grazing && across < 1f - Grazing;
	}

	// A crossing this close to either end is two faces meeting at a corner, which is contact, not a fight.
	const float Grazing = 0.0005f;

	static bool Near( Vector3[] a, Vector3[] b )
	{
		var reach = 0.5f;

		return a.Min( corner => corner.x ) - reach <= b.Max( corner => corner.x )
			&& a.Max( corner => corner.x ) + reach >= b.Min( corner => corner.x )
			&& a.Min( corner => corner.y ) - reach <= b.Max( corner => corner.y )
			&& a.Max( corner => corner.y ) + reach >= b.Min( corner => corner.y )
			&& a.Min( corner => corner.z ) - reach <= b.Max( corner => corner.z )
			&& a.Max( corner => corner.z ) + reach >= b.Min( corner => corner.z );
	}

	static IEnumerable<Vector2> Samples( Vector2[] flat )
	{
		var centre = Vector2.Zero;

		foreach ( var point in flat )
		{
			centre += point;
		}

		centre /= flat.Length;

		yield return centre;

		foreach ( var point in flat )
		{
			yield return Vector2.Lerp( point, centre, 0.25f );
			yield return Vector2.Lerp( point, centre, 0.6f );
		}
	}

	static Vector2[] Flatten( Vector3[] corners, Vector3 origin, Vector3 right, Vector3 up )
	{
		var flat = new Vector2[corners.Length];

		for ( var index = 0; index < corners.Length; index++ )
		{
			var offset = corners[index] - origin;

			flat[index] = new Vector2( Vector3.Dot( offset, right ), Vector3.Dot( offset, up ) );
		}

		return flat;
	}

	static bool Contains( Vector2[] loop, Vector2 point ) => ArchFootprint.Contains( loop, point );

	// Coarse on purpose: parts are meant to abut, so only a most-of-the-smaller overlap counts.
	static void Intersections( List<Piece> pieces, List<ArchFinding> findings, Dictionary<string, int> totals )
	{
		var found = 0;

		for ( var a = 0; a < pieces.Count; a++ )
		{
			for ( var b = a + 1; b < pieces.Count; b++ )
			{
				var one = pieces[a];
				var two = pieces[b];

				if ( one.Faces.Count == 0 || two.Faces.Count == 0 )
				{
					continue;
				}

				var low = Vector3.Max( one.Bounds.Mins, two.Bounds.Mins );
				var high = Vector3.Min( one.Bounds.Maxs, two.Bounds.Maxs );
				var overlap = high - low;

				if ( overlap.x <= 1f || overlap.y <= 1f || overlap.z <= 1f )
				{
					continue;
				}

				var smaller = Vector3.Min( one.Bounds.Size, two.Bounds.Size );
				var share = MathF.Min( 1f, (overlap.x * overlap.y * overlap.z) / MathF.Max( 1f, smaller.x * smaller.y * smaller.z ) );

				if ( share < 0.25f )
				{
					continue;
				}

				found++;

				findings.Add( new ArchFinding
				{
					Check = "intersections",
					Where = $"{one.Name} vs {two.Name}",
					What = $"solids interpenetrate over {overlap.x:0.#} x {overlap.y:0.#} x {overlap.z:0.#} in, {share * 100f:0}% of the smaller part",
					At = Say( (low + high) * 0.5f ),
					Severity = 0.4f + share * 0.4f
				} );
			}
		}

		totals["interpenetrating parts"] = found;
	}

	// ArchCull's rays, reported not acted on - a rebuild undoes the clean pass.
	static void Buried( Scene scene, List<Piece> pieces, List<ArchFinding> findings, Dictionary<string, int> totals )
	{
		var buried = 0;

		foreach ( var piece in pieces )
		{
			var hidden = 0;
			var somewhere = Vector3.Zero;

			foreach ( var face in piece.Faces )
			{
				if ( face.Normal.Length < 0.5f || face.Area < MinArea )
				{
					continue;
				}

				var surface = face.Centre + face.Normal * Skin;
				var outward = face.Centre + face.Normal * Reach;

				if ( !scene.Trace.Ray( outward, surface ).Run().Hit || scene.Trace.Ray( surface, outward ).Run().Hit )
				{
					continue;
				}

				hidden++;
				somewhere = face.Centre;
			}

			if ( hidden == 0 )
			{
				continue;
			}

			buried += hidden;

			findings.Add( new ArchFinding
			{
				Check = "buried",
				Where = piece.Name,
				What = $"{hidden} faces are sealed inside other geometry - arch_clean_hidden_faces would remove them",
				At = Say( somewhere ),
				Severity = 0.2f
			} );
		}

		totals["buried faces"] = buried;
	}

	static void Basis( Vector3 normal, out Vector3 right, out Vector3 up )
	{
		var reference = MathF.Abs( normal.z ) > 0.9f ? Vector3.Forward : Vector3.Up;

		right = Vector3.Cross( reference, normal ).Normal;
		up = Vector3.Cross( normal, right ).Normal;
	}

	static Vector3 Centroid( Vector3[] corners )
	{
		var total = Vector3.Zero;

		foreach ( var corner in corners )
		{
			total += corner;
		}

		return total / corners.Length;
	}

	static float Area( Vector3[] corners, Vector3 normal )
	{
		var total = Vector3.Zero;

		for ( var index = 0; index < corners.Length; index++ )
		{
			total += Vector3.Cross( corners[index], corners[(index + 1) % corners.Length] );
		}

		return MathF.Abs( Vector3.Dot( total * 0.5f, normal.Normal ) );
	}

	static long Key( Vector3 point )
	{
		var x = (long)MathF.Round( point.x * Grain );
		var y = (long)MathF.Round( point.y * Grain );
		var z = (long)MathF.Round( point.z * Grain );

		return x * 73856093L ^ y * 19349663L ^ z * 83492791L;
	}

	public static string Path( GameObject node )
	{
		var names = new List<string>();

		while ( node.IsValid() && node.Name != ArchScene.RootName )
		{
			names.Add( node.Name );
			node = node.Parent;
		}

		names.Reverse();

		return string.Join( "/", names );
	}

	static string Say( Vector3 point ) => $"{point.x:0.#},{point.y:0.#},{point.z:0.#}";
}