Editor utility that audits architectural meshes. Collects pieces and facets from meshes, computes normals, areas, bounds, and runs a suite of checks (windings, gaps, degenerate, coplanar, intersections, buried) to produce a ranked report of findings.
using System;
using System.Collections.Generic;
using System.Linq;
using HalfEdgeMesh;
using Sandbox;
namespace Sunless.Architecture;
public sealed class ArchFinding
{
public string Check { get; init; }
public string Where { get; init; }
public string What { get; init; }
public string At { get; init; }
public float Severity { get; init; }
}
public sealed class ArchAuditResult
{
public string Target { get; init; }
public int Objects { get; init; }
public int Faces { get; init; }
public Dictionary<string, int> Totals { get; init; } = new();
public List<ArchFinding> Findings { get; init; } = new();
public List<string> Truncated { get; init; } = new();
public string Verdict { get; init; }
}
// Defects a screenshot can't settle - each finding aims via arch_view, not hunting.
public static partial class ArchAudit
{
// 0.01in - ArchFootprint's tolerance, so shared corners key by exact equality.
const float Grain = 100f;
const float Skin = 0.15f;
const float Reach = 12f;
const float MinArea = 0.75f;
const int PairBudget = 400000;
// Shorter than this and every mitre's own cut reads as a hole.
const float MinBorder = 2f;
// How near a surface has to be before a border counts as dying into it. Wider than ArchContact's
// Proud, because every generator laps its parts by a bite ON PURPOSE and that slop is contact.
const float Closing = 1f;
// Far enough to name the miss ("stops 0.5 in short"), near enough that the wall opposite is not it.
const float MissReach = 6f;
public static ArchAuditResult InspectScene( ArchTool tool, ArchTarget target, IReadOnlyCollection<string> checks, int limit )
{
return Report( Collect( target ), target.Describe, tool.Scene, checks, limit, Generated( tool.Scene ) );
}
public static ArchAuditResult InspectCanvases( IReadOnlyDictionary<string, ArchMesh> parts, IReadOnlyCollection<string> checks = null, int limit = 40 )
{
var pieces = parts
.Where( part => part.Value is not null )
.Select( part => Of( part.Key, part.Value.Finish(), part.Value.Projection ) )
.ToList();
return Report( pieces, string.Join( ", ", parts.Keys ), null, checks, limit );
}
// Everything generated, whatever the target narrows to: whether a border is closed is a question
// about the part NEXT DOOR, so auditing one room with only that room in hand answers it wrong.
static List<Piece> Generated( Scene scene )
{
return ArchScene.FindRoots( scene )
.SelectMany( root => root.Components.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants ) )
.Where( renderer => renderer.Mesh is not null )
.Select( renderer => Of( Path( renderer.GameObject ), renderer.Mesh, renderer.WorldTransform ) )
.ToList();
}
static ArchAuditResult Report( List<Piece> pieces, string describe, Scene scene, IReadOnlyCollection<string> checks, int limit, List<Piece> context = null )
{
var findings = new List<ArchFinding>();
var totals = new Dictionary<string, int>();
var truncated = new List<string>();
var wanted = checks is { Count: > 0 }
? new HashSet<string>( checks.Select( check => check.ToLowerInvariant() ) )
: new HashSet<string> { "windings", "gaps", "coplanar", "degenerate", "intersections", "buried" };
if ( wanted.Contains( "windings" ) )
{
Windings( pieces, findings, totals );
}
if ( wanted.Contains( "gaps" ) )
{
Gaps( pieces, context is { Count: > 0 } ? context : pieces, findings, totals, truncated );
}
if ( wanted.Contains( "degenerate" ) )
{
Degenerate( pieces, findings, totals );
}
if ( wanted.Contains( "coplanar" ) )
{
Coplanar( pieces, findings, totals, truncated );
}
if ( wanted.Contains( "intersections" ) )
{
Intersections( pieces, findings, totals );
}
if ( wanted.Contains( "buried" ) )
{
if ( scene is null )
{
truncated.Add( "buried needs somewhere to trace, so it was not run." );
}
else
{
Buried( scene, pieces, findings, totals );
}
}
var ranked = findings
.OrderByDescending( finding => finding.Severity )
.ToList();
if ( ranked.Count > limit )
{
truncated.Add( $"{ranked.Count - limit} further findings not shown - raise limit or narrow the target." );
ranked = ranked.Take( limit ).ToList();
}
return new ArchAuditResult
{
Target = describe,
Objects = pieces.Count,
Faces = pieces.Sum( piece => piece.Faces.Count ),
Totals = totals,
Findings = ranked,
Truncated = truncated,
Verdict = Verdict( totals )
};
}
// "open edges" is a count, not a fault - a border closed by the piece next door is how parts meet.
// The fault is the subset `gaps` proves nothing closes.
static string Verdict( Dictionary<string, int> totals )
{
var problems = totals
.Where( entry => entry.Value > 0 && entry.Key is not ("open edges" or "faces") )
.Select( entry => $"{entry.Value} {entry.Key}" )
.ToList();
return problems.Count == 0 ? "Clean." : string.Join( ", ", problems ) + ".";
}
sealed class Piece
{
public string Name { get; init; }
public List<Facet> Faces { get; init; } = new();
public BBox Bounds { get; set; }
}
sealed class Facet
{
public FaceHandle Handle { get; init; }
public int HandleIndex { get; init; }
public Vector3[] Corners { get; init; }
public Vector3 Normal { get; init; }
public Vector3 Centre { get; init; }
public Vector3 TextureAxisU { get; init; }
public Vector3 TextureAxisV { get; init; }
public float Area { get; init; }
public string Material { get; init; }
}
static List<Piece> Collect( ArchTarget target )
{
return target.Meshes()
.Select( renderer => Of( Path( renderer.GameObject ), renderer.Mesh, renderer.WorldTransform ) )
.ToList();
}
static Piece Of( string name, PolygonMesh mesh, Transform transform )
{
var piece = new Piece { Name = name };
var extent = new ArchExtent();
foreach ( var handle in mesh.FaceHandles.ToList() )
{
var corners = mesh.GetFaceVertexPositions( handle, transform ).ToArray();
if ( corners.Length < 3 )
{
continue;
}
mesh.ComputeFaceNormal( handle, out var local );
mesh.GetFaceTextureParameters( handle, out var axisU, out var axisV, out _ );
piece.Faces.Add( new Facet
{
Handle = handle,
HandleIndex = handle.Index,
Corners = corners,
Normal = transform.NormalToWorld( local ).Normal,
Centre = Centroid( corners ),
TextureAxisU = axisU,
TextureAxisV = axisV,
Area = Area( corners, ArchMesh.Newell( corners ) ),
Material = mesh.GetFaceMaterial( handle )?.ResourcePath ?? "none"
} );
foreach ( var corner in corners )
{
extent.Add( corner );
}
}
piece.Bounds = extent.Box;
return piece;
}
}