Editor/Output/ArchReport.cs

Editor reporting utility for architecture parts. It inspects an ArchTarget and its meshes to measure extents, face counts, surface material roles, and produces a serializable report including planned vs built extents, fit analysis, per-piece and per-material summaries, and any expected reports from ArchExpectedReports.

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

namespace Sunless.Architecture;

// Plan vs produced, split by surface role - the only labels the geometry carries.
public static partial class ArchReport
{
	public static object Measure( ArchTool tool, ArchTarget target )
	{
		var pieces = new List<object>();
		var surfaces = Surfaces( tool, target );

		foreach ( var renderer in target.Meshes() )
		{
			var extent = new ArchExtent();
			var faces = 0;
			var transform = renderer.WorldTransform;

			foreach ( var face in renderer.Mesh.FaceHandles.ToList() )
			{
				faces++;

				foreach ( var corner in renderer.Mesh.GetFaceVertexPositions( face, transform ) )
				{
					extent.Add( corner );
				}
			}

			pieces.Add( new
			{
				Piece = ArchAudit.Path( renderer.GameObject ),
				Faces = faces,
				Extent = Extent( extent.Box, faces > 0 )
			} );
		}

		return new
		{
			Target = target.Describe,
			target.Kind,
			Id = ArchPlanStore.IdOf( target.Item ),
			target.Faces,
			Planned = Extent( target.Planned, target.Planned.Size.Length > 0.01f ),
			Built = Extent( target.Built, target.HasGeometry ),
			Fit = Fit( target ),
			Pieces = pieces,
			Surfaces = surfaces,
			Expected = Expected( tool, target ),
			Note = target.Note
		};
	}

	static object Extent( BBox box, bool real )
	{
		if ( !real )
		{
			return null;
		}

		return new
		{
			Min = Say( box.Mins ),
			Max = Say( box.Maxs ),
			Size = Say( box.Size ),
			Centre = Say( box.Center )
		};
	}

	// A gutter hung low or a floating rail both read as a one-axis overshoot.
	static string Fit( ArchTarget target )
	{
		if ( !target.HasGeometry )
		{
			return "no geometry was generated for this part";
		}

		if ( target.Planned.Size.Length < 0.01f )
		{
			return "the plan gives no extent to compare against";
		}

		var over = new List<string>();
		var axes = new[] { "x", "y", "z" };

		for ( var axis = 0; axis < 3; axis++ )
		{
			var low = Component( target.Planned.Mins, axis ) - Component( target.Built.Mins, axis );
			var high = Component( target.Built.Maxs, axis ) - Component( target.Planned.Maxs, axis );

			if ( low > 1f )
			{
				over.Add( $"{low:0.#} in below planned {axes[axis]}" );
			}

			if ( high > 1f )
			{
				over.Add( $"{high:0.#} in above planned {axes[axis]}" );
			}
		}

		return over.Count == 0 ? "within the planned extent" : "reaches " + string.Join( ", ", over );
	}

	static float Component( Vector3 vector, int axis ) => axis switch { 0 => vector.x, 1 => vector.y, _ => vector.z };

	// Roles share materials via the fallback chain, so one bucket lists them all. Public because a face
	// row names its role from the same map - a face called Reveal here and something else there would
	// be two names for one skin.
	public static Dictionary<string, string> RolesByMaterial( ArchTool tool, ArchTarget target )
	{
		var style = new ArchStyle( tool.Kit );
		var chain = Chain( target );
		var roles = new Dictionary<string, List<string>>();

		foreach ( var role in Enum.GetValues<ArchSurface>() )
		{
			var path = style.Brush( role, chain ).Material?.ResourcePath ?? "none";

			if ( !roles.TryGetValue( path, out var names ) )
			{
				names = new List<string>();
				roles[path] = names;
			}

			names.Add( role.ToString() );
		}

		return roles.ToDictionary( entry => entry.Key, entry => string.Join( "/", entry.Value ) );
	}

	static List<object> Surfaces( ArchTool tool, ArchTarget target )
	{
		var roles = RolesByMaterial( tool, target );
		var groups = new Dictionary<string, (ArchExtent Extent, int Faces)>();

		foreach ( var renderer in target.Meshes() )
		{
			var transform = renderer.WorldTransform;

			foreach ( var face in renderer.Mesh.FaceHandles.ToList() )
			{
				var path = renderer.Mesh.GetFaceMaterial( face )?.ResourcePath ?? "none";

				groups.TryGetValue( path, out var group );

				var extent = group.Extent;

				foreach ( var corner in renderer.Mesh.GetFaceVertexPositions( face, transform ) )
				{
					extent.Add( corner );
				}

				groups[path] = (extent, group.Faces + 1);
			}
		}

		var report = new List<object>();

		foreach ( var (path, group) in groups.OrderByDescending( entry => entry.Value.Faces ) )
		{
			report.Add( new
			{
				Roles = roles.TryGetValue( path, out var names ) ? names : "unbound",
				Material = path,
				group.Faces,
				Extent = Extent( group.Extent.Box, true )
			} );
		}

		// A role nothing was skinned with is a missing-fitting bug, said out loud.
		foreach ( var (path, names) in roles )
		{
			if ( groups.ContainsKey( path ) || path == "none" )
			{
				continue;
			}

			report.Add( new
			{
				Roles = names,
				Material = path,
				Faces = 0,
				Extent = (object)null
			} );
		}

		return report;
	}

	static ArchPalette[] Chain( ArchTarget target )
	{
		var chain = new List<ArchPalette>();

		switch ( target.Item )
		{
			case ArchStairPart stair: chain.Add( stair.Palette ); break;
			case ArchWall wall: chain.Add( wall.Palette ); break;
			case ArchRoofPart roof: chain.Add( roof.Palette ); break;
			case ArchPillarPart pillar: chain.Add( pillar.Palette ); break;
			case ArchPorchPart porch: chain.Add( porch.Palette ); break;
			case ArchFencePart fence: chain.Add( fence.Palette ); break;
			case ArchDownpipePart pipe: chain.Add( pipe.Palette ); break;
			case ArchPipePart run: chain.Add( run.Palette ); break;
			case ArchPipeBracketPart bracket: chain.Add( bracket.Palette ); break;
			case ArchTrimPart trim: chain.Add( trim.Palette ); break;
		}

		if ( target.Room is not null )
		{
			chain.Add( target.Room.Palette );
		}

		if ( target.Building is not null )
		{
			chain.Add( target.Building.Palette );
		}

		return chain.ToArray();
	}

	static object Expected( ArchTool tool, ArchTarget target )
	{
		var kind = ArchKinds.Load().Claiming( target.Item )?.Kind ?? default;
		var contributed = ArchExpectedReports.Describe( kind, target.Item, target, tool );

		if ( contributed is not null )
		{
			return contributed;
		}

		return null;
	}
}