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.
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;
}
}