Editor/Output/ArchReport.MeshDebug.cs

Editor utility that inspects MeshComponent objects and produces a plaintext diagnostic report. It enumerates faces, collects material counts and texel scale values, checks per-face texture axes, scale and geometry for faults (NaN, collapsed axes, non-orthogonal axes, too few corners, or axes not in face plane) and formats human-readable lines for display.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using HalfEdgeMesh;
using Sandbox;

namespace Sunless.Architecture;

// What a scene MeshComponent actually carries, face by face - the numbers the mapping tool's Face Mode
// shows one face at a time. Read here because a texture axis that came out NaN is invisible in the
// viewport: the face renders, it just smears, and only its parameters say why.
public static partial class ArchReport
{
	public static string MeshDebug( IEnumerable<MeshComponent> meshes )
	{
		var lines = new List<string> { "# Architecture mesh debug" };
		var components = meshes?.Where( mesh => mesh.IsValid() ).ToList() ?? new List<MeshComponent>();

		if ( components.Count == 0 )
		{
			lines.Add( "nothing selected carries a mesh - pick a game object with a MeshComponent on it or under it" );

			return string.Join( "\n", lines );
		}

		foreach ( var component in components )
		{
			Describe( lines, component );
		}

		return string.Join( "\n", lines );
	}

	static void Describe( List<string> lines, MeshComponent component )
	{
		var mesh = component.Mesh;
		var path = Path( component.GameObject );

		if ( mesh is null )
		{
			lines.Add( $"\n## {path}\nno polygon mesh" );

			return;
		}

		var faces = mesh.FaceHandles.ToList();
		var transform = component.WorldTransform;
		var broken = new List<string>();
		var materials = new Dictionary<string, int>();
		var scales = new HashSet<string>();

		foreach ( var face in faces )
		{
			mesh.GetFaceTextureParameters( face, out var axisU, out var axisV, out var scale );

			var material = mesh.GetFaceMaterial( face )?.ResourcePath ?? "none";

			materials[material] = materials.TryGetValue( material, out var seen ) ? seen + 1 : 1;
			scales.Add( $"{scale.x:0.####}x{scale.y:0.####}" );

			if ( Fault( mesh, face, transform, axisU, axisV, scale ) is not { } fault )
			{
				continue;
			}

			broken.Add( $"  {fault} at {transform.PointToWorld( mesh.GetFaceCenter( face ) )} · u {axisU} · v {axisV} · scale {scale} · {material}" );
		}

		lines.Add( $"\n## {path}" );
		lines.Add( $"{faces.Count} faces · {mesh.VertexHandles.Count()} vertices · at {component.WorldPosition}" );
		lines.Add( $"texel scales: {string.Join( ", ", scales.OrderBy( entry => entry ) )}  (the density law is {ArchMesh.TexelScale:0.####})" );

		foreach ( var material in materials.OrderByDescending( entry => entry.Value ) )
		{
			lines.Add( $"  {material.Value,5} {material.Key}" );
		}

		if ( broken.Count == 0 )
		{
			lines.Add( "no faulted faces - every face has finite axes, a real normal and three or more distinct corners" );

			return;
		}

		lines.Add( $"{broken.Count} FAULTED faces:" );
		lines.AddRange( broken.Take( 60 ) );

		if ( broken.Count > 60 )
		{
			lines.Add( $"  ... and {broken.Count - 60} more" );
		}
	}

	// Everything that makes a face render but read wrong. NaN axes come first because they are the one
	// fault no screenshot shows: the face is there, the tiling is a smear, and the mapping tool's texture
	// selection reads NaN.
	static string Fault( PolygonMesh mesh, FaceHandle face, Transform transform, Vector4 axisU, Vector4 axisV, Vector2 scale )
	{
		if ( !Finite( axisU ) || !Finite( axisV ) || !float.IsFinite( scale.x ) || !float.IsFinite( scale.y ) )
		{
			return "NaN texture parameters";
		}

		if ( ((Vector3)axisU).IsNearZeroLength || ((Vector3)axisV).IsNearZeroLength )
		{
			return "collapsed texture axis";
		}

		if ( MathF.Abs( Vector3.Dot( ((Vector3)axisU).Normal, ((Vector3)axisV).Normal ) ) > 0.01f )
		{
			return "texture axes are not square to each other";
		}

		var corners = mesh.GetFaceVertexPositions( face, transform ).ToList();

		if ( corners.Count < 3 )
		{
			return $"{corners.Count} corners";
		}

		var normal = ArchMesh.Newell( corners );

		if ( MathF.Abs( Vector3.Dot( normal, ((Vector3)axisU).Normal ) ) > 0.01f )
		{
			return "texture axis is not in the face's own plane, so the projection stretches";
		}

		return null;
	}

	static bool Finite( Vector4 value )
	{
		return float.IsFinite( value.x ) && float.IsFinite( value.y ) && float.IsFinite( value.z ) && float.IsFinite( value.w );
	}

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

		for ( var at = target; at.IsValid(); at = at.Parent )
		{
			names.Add( at.Name );
		}

		names.Reverse();

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