Editor/Output/ArchAudit.Gaps.cs
using System;
using System.Collections.Generic;
using System.Linq;
using HalfEdgeMesh;
using Sandbox;

namespace Sunless.Architecture;

public static partial class ArchAudit {
	// Sampled along its length, so a border half covered by the part next door still reports.
	static float? Unmet( Border border, List<Piece> pieces ) {
		var nearest = float.MaxValue;

		foreach ( var fraction in new[] { 0.25f, 0.5f, 0.75f } ) {
			var point = border.Along( fraction );
			var gap = Reaches( point, border.Face, pieces );

			if ( gap <= Closing ) {
				return null;
			}

			nearest = MathF.Min( nearest, gap );
		}

		return nearest;
	}

	// How far this point is from the nearest surface other than the border's own. Inside the face's
	// own outline, or a border running past a wall's end would read as landing on it.
	static float Reaches( Vector3 point, Facet owner, List<Piece> pieces ) {
		var nearest = float.MaxValue;

		foreach ( var piece in pieces ) {
			if ( Outside( piece.Bounds, point, MissReach ) ) {
				continue;
			}

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

				var gap = MathF.Abs( Vector3.Dot( point - face.Centre, face.Normal ) );

				if ( gap >= nearest || gap > MissReach || !Covers( face, point ) ) {
					continue;
				}

				nearest = gap;
			}
		}

		return nearest;
	}

	// Inside the outline OR on it. A mitre's two cut faces meet exactly, so the contact pass deletes
	// one of them as wholly covered - and the border it leaves behind lies on the survivor's own
	// EDGE, which a strict containment test calls open. That is a met mitre, not a hole.
	static bool Covers( Facet face, Vector3 point ) {
		Basis( ArchFaceOverlap.Canonical( face.Normal ), out var right, out var up );

		var flat = Flatten( face.Corners, face.Centre, right, up );
		var offset = point - face.Centre;
		var flattened = new Vector2( Vector3.Dot( offset, right ), Vector3.Dot( offset, up ) );

		return Contains( flat, flattened ) || Rims( flat, flattened );
	}

	static bool Rims( Vector2[] loop, Vector2 point ) {
		for ( var index = 0; index < loop.Length; index++ ) {
			var from = loop[index];
			var run = loop[(index + 1) % loop.Length] - from;
			var length = run.Length;
			var along = length < 0.01f ? 0f : Math.Clamp( Vector2.Dot( point - from, run / length ), 0f, length );

			// A hair, not a Closing: the border a deleted mitre leaves lies exactly ON the survivor's
			// edge. Anything wider and every edge in the mesh closes every border near it.
			if ( (point - (from + (length < 0.01f ? Vector2.Zero : run / length * along))).Length <= 0.05f ) {
				return true;
			}
		}

		return false;
	}

	static bool Outside( BBox bounds, Vector3 point, float reach ) {
		return point.x < bounds.Mins.x - reach || point.x > bounds.Maxs.x + reach
			|| point.y < bounds.Mins.y - reach || point.y > bounds.Maxs.y + reach
			|| point.z < bounds.Mins.z - reach || point.z > bounds.Maxs.z + reach;
	}

	static List<Border> Borders( List<Piece> pieces ) {
		var borders = new List<Border>();

		foreach ( var piece in pieces ) {
			var walked = new Dictionary<(long, long), (Facet Face, Vector3 From, Vector3 To)>();
			var counts = new Dictionary<(long, long), int>();

			foreach ( var face in piece.Faces ) {
				for ( var index = 0; index < face.Corners.Length; index++ ) {
					var one = face.Corners[index];
					var two = face.Corners[(index + 1) % face.Corners.Length];
					var from = Key( one );
					var to = Key( two );

					if ( from == to ) {
						continue;
					}

					var edge = from < to ? (from, to) : (to, from);

					counts[edge] = counts.GetValueOrDefault( edge ) + 1;
					walked[edge] = (face, one, two);
				}
			}

			foreach ( var (edge, _) in counts.Where( entry => entry.Value == 1 ) ) {
				var (face, from, to) = walked[edge];

				borders.Add( new Border { Piece = piece, Face = face, From = from, To = to } );
			}
		}

		return borders;
	}

	static (long, long) Span( Border border ) {
		var from = Key( border.From );
		var to = Key( border.To );

		return from < to ? (from, to) : (to, from);
	}

	static void Degenerate( List<Piece> pieces, List<ArchFinding> findings, Dictionary<string, int> totals ) {
		var slivers = 0;
		var normals = 0;

		foreach ( var piece in pieces ) {
			foreach ( var face in piece.Faces ) {
				if ( face.Normal.Length < 0.5f || !float.IsFinite( face.Normal.x ) ) {
					normals++;

					findings.Add( new ArchFinding {
						Check = "degenerate",
						Where = piece.Name,
						What = "face has no usable normal - its corners are collinear",
						At = Say( face.Centre ),
						Severity = 0.8f
					} );

					continue;
				}

				if ( face.Area >= MinArea ) {
					continue;
				}

				slivers++;

				findings.Add( new ArchFinding {
					Check = "degenerate",
					Where = piece.Name,
					What = $"sliver face, area {face.Area:0.###} sq in",
					At = Say( face.Centre ),
					Severity = 0.35f
				} );
			}
		}

		totals["sliver faces"] = slivers;
		totals["bad normals"] = normals;
	}

	// Overlapping faces on one plane - the z-fight the generators' constants avoid.
	static void Coplanar( List<Piece> pieces, List<ArchFinding> findings, Dictionary<string, int> totals, List<string> truncated ) {
		var planes = new Dictionary<(long, long, long, long), List<(Piece Piece, Facet Face)>>();

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

				// Unsigned: back-to-back is the commonest z-fight, and those normals are opposite.
				var key = ArchFaceOverlap.PlaneKey( face.Normal, face.Centre );

				if ( !planes.TryGetValue( key, out var group ) ) {
					group = new List<(Piece, Facet)>();
					planes[key] = group;
				}

				group.Add( (piece, face) );
			}
		}

		var overlaps = 0;
		var budget = PairBudget;

		foreach ( var group in planes.Values.Where( group => group.Count > 1 ) ) {
			for ( var a = 0; a < group.Count; a++ ) {
				for ( var b = a + 1; b < group.Count; b++ ) {
					if ( budget-- <= 0 ) {
						truncated.Add( "coplanar comparison budget reached - narrow the target for a complete answer." );
						totals["coplanar overlaps"] = overlaps;
						return;
					}

					var (pieceA, faceA) = group[a];
					var (pieceB, faceB) = group[b];

					if ( !Overlapping( faceA, faceB ) ) {
						continue;
					}

					overlaps++;

					var same = ReferenceEquals( pieceA, pieceB );
					var facing = Vector3.Dot( faceA.Normal, faceB.Normal ) < 0f ? "back to back" : "stacked";

					findings.Add( new ArchFinding {
						Check = "coplanar",
						Where = same ? pieceA.Name : $"{pieceA.Name} vs {pieceB.Name}",
						What = $"{facing} faces share a plane and overlap - these will z-fight",
						At = Say( faceA.Centre ),
						Severity = same ? 0.7f : 0.85f
					} );
				}
			}
		}

		totals["coplanar overlaps"] = overlaps;
	}
}