Editor/Services/ArchMeshContactService.cs

Editor service that finds and removes redundant mesh faces between architectural parts. It collects faces from built parts, groups faces by approximate plane, tests pairwise overlap/projection to determine coverage, and removes fully covered faces from their meshes.

File AccessNative Interop
using System;
using System.Collections.Generic;
using System.Linq;
using HalfEdgeMesh;
using Sandbox;

namespace Sunless.Architecture;

// One built face, with the plane it lies in already keyed - the key is how a face finds the only faces it could
// ever be covered by, and it is what an incremental build uses to decide which parts still have to be compared.
sealed class ArchContactFace
{
	public PolygonMesh Mesh { get; init; }
	public FaceHandle Handle { get; init; }
	public Vector3[] Corners { get; init; }
	public Vector3 Normal { get; init; }
	public Vector3 Centre { get; init; }
	public float Area { get; init; }
	public int Part { get; init; }
	public (long, long, long, long) Plane { get; init; }
}

public sealed class ArchMeshContactService
{
	const float PlaneAngle = 0.02f;
	const float PlaneOffset = 0.06f;

	public int RemoveRedundantContacts( IReadOnlyList<ArchBuiltPart> parts )
	{
		return Resolve( Collect( parts, null ) );
	}

	// Faces in EMISSION order, and a subset is a filter rather than a re-ordering: the pass breaks a mutual cover
	// by area but breaks everything else by which face it reached first, so a reordered subset could kill the other
	// half of a pair and a warm build would stop matching a cold one.
	internal List<ArchContactFace> Collect( IReadOnlyList<ArchBuiltPart> parts, IReadOnlySet<int> only )
	{
		var faces = new List<ArchContactFace>();

		for ( var partIndex = 0; partIndex < parts.Count; partIndex++ )
		{
			if ( only is not null && !only.Contains( partIndex ) )
			{
				continue;
			}

			faces.AddRange( Faces( parts[partIndex], partIndex ) );
		}

		return faces;
	}

	internal static IEnumerable<ArchContactFace> Faces( ArchBuiltPart part, int partIndex )
	{
		var mesh = part.Canvas.Finish();

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

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

			mesh.ComputeFaceNormal( handle, out var local );

			var normal = part.Canvas.Projection.NormalToWorld( local ).Normal;
			var centre = corners.Aggregate( Vector3.Zero, ( total, point ) => total + point ) / corners.Length;

			yield return new ArchContactFace
			{
				Mesh = mesh,
				Handle = handle,
				Corners = corners,
				Normal = normal,
				Centre = centre,
				Area = Area( corners ),
				Part = partIndex,
				Plane = PlaneKey( normal, centre )
			};
		}
	}

	internal int Resolve( List<ArchContactFace> faces )
	{
		var groups = faces.GroupBy( face => face.Plane );
		var dead = new Dictionary<PolygonMesh, HashSet<FaceHandle>>();

		foreach ( var group in groups )
		{
			var candidates = group.ToList();
			var extents = candidates.Select( Bounds ).ToArray();

			for ( var first = 0; first < candidates.Count; first++ )
			{
				var a = candidates[first];

				for ( var second = first + 1; second < candidates.Count; second++ )
				{
					var b = candidates[second];

					if ( a.Part == b.Part && ReferenceEquals( a.Mesh, b.Mesh ) && (IsDead( dead, a ) || IsDead( dead, b )) )
					{
						continue;
					}

					if ( !Touches( extents[first], extents[second] ) )
					{
						continue;
					}

					var remove = Redundant( a, b );

					if ( remove is null || IsDead( dead, remove ) )
					{
						continue;
					}

					(dead.TryGetValue( remove.Mesh, out var handles ) ? handles : dead[remove.Mesh] = new HashSet<FaceHandle>()).Add( remove.Handle );
				}
			}
		}

		foreach ( var entry in dead )
		{
			entry.Key.RemoveFaces( entry.Value.ToList() );
		}

		return dead.Values.Sum( handles => handles.Count );
	}

	internal static (long, long, long, long) PlaneKey( Vector3 normal, Vector3 centre )
	{
		var canonical = Canonical( normal );

		return (
			(long)MathF.Round( canonical.x / PlaneAngle ),
			(long)MathF.Round( canonical.y / PlaneAngle ),
			(long)MathF.Round( canonical.z / PlaneAngle ),
			(long)MathF.Round( Vector3.Dot( canonical, centre ) / PlaneOffset ) );
	}

	// Only a face its partner covers WHOLE - a partial dip leaves the rest out in the open.
	static ArchContactFace Redundant( ArchContactFace a, ArchContactFace b )
	{
		var normal = Canonical( a.Normal );
		var flatA = Project( a.Corners, normal, a.Centre );
		var flatB = Project( b.Corners, normal, a.Centre );

		var aCovered = Covers( flatB, flatA );
		var bCovered = Covers( flatA, flatB );

		if ( aCovered && bCovered )
		{
			return a.Area <= b.Area ? a : b;
		}

		return aCovered ? a : bCovered ? b : null;
	}

	// Measured once per face rather than six LINQ passes per PAIR - a plane group is compared pairwise, so the
	// same face's extents were being walked again for every other face standing in its plane.
	internal static BBox Bounds( ArchContactFace face ) => BBox.FromPoints( face.Corners );

	// A plane key is coarse on purpose: EVERY ground slab in a plan lands in the z=0 bucket. What decides whether
	// two faces in one plane can affect each other is whether they are anywhere near each other, so an incremental
	// build has to ask the same question the pass asks rather than settle for the bucket.
	internal static bool Touches( BBox a, BBox b )
	{
		return a.Mins.x <= b.Maxs.x + 0.5f && a.Maxs.x >= b.Mins.x - 0.5f
			&& a.Mins.y <= b.Maxs.y + 0.5f && a.Maxs.y >= b.Mins.y - 0.5f
			&& a.Mins.z <= b.Maxs.z + 0.5f && a.Maxs.z >= b.Mins.z - 0.5f;
	}

	static bool Covers( IReadOnlyList<Vector2> cover, IReadOnlyList<Vector2> face )
	{
		return Samples( face ).All( point => Contains( cover, point ) );
	}

	static bool IsDead( Dictionary<PolygonMesh, HashSet<FaceHandle>> dead, ArchContactFace face )
	{
		return dead.TryGetValue( face.Mesh, out var handles ) && handles.Contains( face.Handle );
	}

	internal static Vector3 Canonical( Vector3 normal )
	{
		if ( normal.x < -0.001f || normal.x is > -0.001f and < 0.001f && normal.y < -0.001f || normal.x is > -0.001f and < 0.001f && normal.y is > -0.001f and < 0.001f && normal.z < 0f )
		{
			return -normal;
		}

		return normal;
	}

	static Vector2[] Project( IReadOnlyList<Vector3> corners, Vector3 normal, Vector3 origin )
	{
		var result = new Vector2[corners.Count];
		var axis = MathF.Abs( normal.x ) >= MathF.Abs( normal.y ) && MathF.Abs( normal.x ) >= MathF.Abs( normal.z ) ? 0 : MathF.Abs( normal.y ) >= MathF.Abs( normal.z ) ? 1 : 2;

		for ( var index = 0; index < corners.Count; index++ )
		{
			var point = corners[index] - origin;
			result[index] = axis switch
			{
				0 => new Vector2( point.y, point.z ),
				1 => new Vector2( point.x, point.z ),
				_ => new Vector2( point.x, point.y )
			};
		}

		return result;
	}

	static IEnumerable<Vector2> Samples( IReadOnlyList<Vector2> corners )
	{
		var centre = corners.Aggregate( Vector2.Zero, ( total, point ) => total + point ) / corners.Count;
		yield return centre;

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

	static bool Contains( IReadOnlyList<Vector2> loop, Vector2 point ) => ArchFootprint.Contains( loop, point );

	static float Area( IReadOnlyList<Vector3> corners )
	{
		var normal = Vector3.Zero;

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

		return normal.Length * 0.5f;
	}
}