Editor-side utilities for architecture auditing. Defines ArchFaceDetail, ArchFaceGroup, ArchFacePiece data structures and many static helpers to collect, group, query and report on mesh faces (z-fights, containment, picking, briefings) for the editor tooling.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// One built face, named well enough to be argued about: the piece and layer that emitted it, what it
// was skinned with, and the plane it lies on. Identity is (piece, index) because a face carries no id
// of its own - the index is its order in that piece's mesh, which holds for a given build.
public sealed class ArchFaceDetail
{
public string Piece { get; init; }
public int Index { get; init; }
public int HandleIndex { get; init; }
public int LayerId { get; init; }
public string Material { get; init; }
public string Role { get; init; }
public string Plane { 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 BBox Bounds { get; init; }
public Vector3[] Corners { get; init; } = Array.Empty<Vector3>();
// A face is flat, so its smallest extent is the one that says nothing.
public string Size
{
get
{
var sides = new[] { Bounds.Size.x, Bounds.Size.y, Bounds.Size.z }
.OrderByDescending( side => side )
.ToArray();
return $"{sides[0]:0.#} x {sides[1]:0.#} in";
}
}
public string Label => $"face {Index} · {Role} · {Size} · {Plane}";
// Stable enough to hand out and ask about later: the layer that emitted it, the piece's own name and
// the face's place in that mesh. Survives anything short of the geometry itself changing.
public string Id => $"{LayerId}:{Piece.Split( '/' )[^1]}:{Index}";
}
// A lump of one piece's geometry, derived rather than authored: a mesh is a bag of faces and says
// nothing about which of them are the north gutter's channel and which are the brackets hung on it.
// Faces that share an edge are one lump; where a lump SITS on the piece names it.
public sealed class ArchFaceGroup
{
public string Name { get; init; }
public BBox Bounds { get; init; }
public List<ArchFaceGroup> Children { get; init; } = new();
public List<ArchFaceDetail> Faces { get; init; } = new();
public int Count => Faces.Count + Children.Sum( child => child.Count );
public IEnumerable<ArchFaceDetail> Every => Faces.Concat( Children.SelectMany( child => child.Every ) );
public bool Holds( ArchFaceDetail face ) => Faces.Contains( face ) || Children.Any( child => child.Holds( face ) );
}
public sealed class ArchFacePiece
{
public string Name { get; init; }
public int LayerId { get; init; }
public BBox Bounds { get; init; }
public List<ArchFaceDetail> Faces { get; init; } = new();
// Empty when the piece is one lump - a wall is six faces and grouping them helps nobody.
public List<ArchFaceGroup> Groups { get; init; } = new();
}
public static partial class ArchAudit
{
// Every built piece filed under the layer that emitted it - a wall's mesh belongs to that wall, not
// to the room it stands in, so the stack can hang each piece under the row that authored it and
// offer the row only where there is something to open.
public static Dictionary<int, List<MeshComponent>> BuiltByLayer( Scene scene )
{
var built = new Dictionary<int, List<MeshComponent>>();
foreach ( var renderer in ArchScene.FindRoots( scene )
.SelectMany( root => root.Components.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants ) )
.Where( renderer => renderer.Mesh is not null ) )
{
var layer = LayerOf( Path( renderer.GameObject ) );
if ( layer == 0 )
{
continue;
}
if ( !built.TryGetValue( layer, out var pieces ) )
{
pieces = new List<MeshComponent>();
built[layer] = pieces;
}
pieces.Add( renderer );
}
return built;
}
// The faces a target actually emitted. The layer stack's geometry rows read this, so what the tree
// offers to select and what the checks run over are the same faces in the same order.
public static List<ArchFacePiece> Faces( ArchTool tool, ArchTarget target )
{
return Faced( ArchReport.RolesByMaterial( tool, target ), Collect( target ) );
}
public static List<ArchFacePiece> Faces( ArchTool tool, IEnumerable<MeshComponent> meshes )
{
var pieces = meshes
.Where( renderer => renderer.IsValid() && renderer.Mesh is not null )
.Select( renderer => Of( Path( renderer.GameObject ), renderer.Mesh, renderer.WorldTransform ) )
.ToList();
return Faced( ArchReport.RolesByMaterial( tool, ArchLocate.Find( tool, "plan" ) ), pieces );
}
static List<ArchFacePiece> Faced( IReadOnlyDictionary<string, string> roles, List<Piece> pieces )
{
return pieces
.Select( piece => Detailed( piece, roles ) )
.Select( faces => new ArchFacePiece
{
Name = faces.Count > 0 ? faces[0].Piece : "",
LayerId = faces.Count > 0 ? faces[0].LayerId : 0,
Bounds = Extent( faces ),
Faces = faces,
Groups = Grouped( faces )
} )
.Where( piece => piece.Faces.Count > 0 )
.ToList();
}
// One lump per connected run of faces, filed under the side of the piece it stands on, and the
// biggest lump on a side named as its run with the rest hung under it as fittings. A gutter comes
// out as North Gutter → Run + Bracket-shaped fittings instead of three hundred rows called Gutter.
static List<ArchFaceGroup> Grouped( List<ArchFaceDetail> faces )
{
var lumps = Lumps( faces );
if ( lumps.Count < 2 )
{
return new List<ArchFaceGroup>();
}
var bounds = Extent( faces );
var groups = new List<ArchFaceGroup>();
foreach ( var side in lumps.GroupBy( lump => Side( Extent( lump ), bounds ) ).OrderBy( side => side.Key ) )
{
var ordered = side
.OrderByDescending( lump => Extent( lump ).Size.Length )
.ToList();
var name = $"{side.Key} {Roled( ordered[0] )}";
if ( ordered.Count == 1 )
{
groups.Add( new ArchFaceGroup { Name = name, Bounds = Extent( ordered[0] ), Faces = ordered[0] } );
continue;
}
var run = new ArchFaceGroup { Name = "Run", Bounds = Extent( ordered[0] ), Faces = ordered[0] };
var group = new ArchFaceGroup { Name = name, Bounds = Extent( side.SelectMany( lump => lump ).ToList() ) };
group.Children.Add( run );
// Along the run, so fitting 1 is the one at the end you would count from.
var along = Longest( run.Bounds );
var fittings = ordered
.Skip( 1 )
.OrderBy( lump => Component( Extent( lump ).Center, along ) )
.ToList();
for ( var index = 0; index < fittings.Count; index++ )
{
group.Children.Add( new ArchFaceGroup
{
Name = $"Fitting {index + 1}",
Bounds = Extent( fittings[index] ),
Faces = fittings[index]
} );
}
groups.Add( group );
}
return groups;
}
// Union-find over shared edges: two faces walking the same pair of corners are the same lump.
static List<List<ArchFaceDetail>> Lumps( List<ArchFaceDetail> faces )
{
var owner = Enumerable.Range( 0, faces.Count ).ToArray();
var edges = new Dictionary<(long, long), int>();
int Root( int index )
{
while ( owner[index] != index )
{
owner[index] = owner[owner[index]];
index = owner[index];
}
return index;
}
for ( var index = 0; index < faces.Count; index++ )
{
var corners = faces[index].Corners;
for ( var corner = 0; corner < corners.Length; corner++ )
{
var from = Key( corners[corner] );
var to = Key( corners[(corner + 1) % corners.Length] );
if ( from == to )
{
continue;
}
var edge = from < to ? (from, to) : (to, from);
if ( !edges.TryGetValue( edge, out var other ) )
{
edges[edge] = index;
continue;
}
var left = Root( other );
var right = Root( index );
if ( left != right )
{
owner[right] = left;
}
}
}
return faces
.Select( ( face, index ) => (Face: face, Lump: Root( index )) )
.GroupBy( entry => entry.Lump )
.Select( group => group.Select( entry => entry.Face ).ToList() )
.ToList();
}
public static List<List<ArchFaceDetail>> Connected( IEnumerable<ArchFaceDetail> faces )
{
return faces
.GroupBy( face => face.Piece )
.SelectMany( piece => Lumps( piece.ToList() ) )
.ToList();
}
public static List<List<ArchFaceDetail>> PhysicallyConnected( IEnumerable<ArchFaceDetail> faces )
{
return Lumps( faces.ToList() );
}
// Measured as a share of the piece's own extent, so the naming axis is the one the lump actually
// sits off-centre on rather than whichever happens to be biggest in inches.
static string Side( BBox lump, BBox piece )
{
var offset = lump.Center - piece.Center;
var size = piece.Size;
var share = new Vector3(
size.x > 1f ? offset.x / size.x : 0f,
size.y > 1f ? offset.y / size.y : 0f,
size.z > 1f ? offset.z / size.z : 0f );
if ( MathF.Abs( share.x ) >= MathF.Abs( share.y ) && MathF.Abs( share.x ) >= MathF.Abs( share.z ) )
{
return share.x >= 0f ? "East" : "West";
}
if ( MathF.Abs( share.y ) >= MathF.Abs( share.z ) )
{
return share.y >= 0f ? "North" : "South";
}
return share.z >= 0f ? "Upper" : "Lower";
}
static string Roled( List<ArchFaceDetail> lump )
{
return lump
.GroupBy( face => face.Role )
.OrderByDescending( role => role.Count() )
.Select( role => role.Key )
.First();
}
static BBox Extent( List<ArchFaceDetail> faces )
{
var extent = new ArchExtent();
foreach ( var corner in faces.SelectMany( face => face.Corners ) )
{
extent.Add( corner );
}
return extent.Box;
}
static int Longest( BBox bounds )
{
if ( bounds.Size.x >= bounds.Size.y && bounds.Size.x >= bounds.Size.z )
{
return 0;
}
return bounds.Size.y >= bounds.Size.z ? 1 : 2;
}
static float Component( Vector3 point, int axis ) => axis switch { 0 => point.x, 1 => point.y, _ => point.z };
// EVERY face the cursor is over, nearest first - never just the closest one. Two faces sharing a
// plane are the same distance along the ray, so a picker that kept one winner could never hand over
// the other half of a z-fight, which is the pair you actually want to talk about.
//
// Worked out against the faces themselves rather than by tracing: a generated piece has no collider,
// and a plane the ray crosses OUTSIDE the polygon is not a hit however close it passes.
public static List<ArchFaceDetail> FacesAt( IEnumerable<ArchFacePiece> pieces, Ray ray )
{
var hits = new List<(float Along, ArchFaceDetail Face)>();
foreach ( var face in pieces.SelectMany( piece => piece.Faces ) )
{
var facing = Vector3.Dot( face.Normal, ray.Forward );
if ( MathF.Abs( facing ) < 0.001f )
{
continue;
}
var along = Vector3.Dot( face.Centre - ray.Position, face.Normal ) / facing;
if ( along <= 1f )
{
continue;
}
if ( !Inside( face, ray.Position + ray.Forward * along ) )
{
continue;
}
hits.Add( (along, face) );
}
return hits
.OrderBy( hit => hit.Along )
.Select( hit => hit.Face )
.ToList();
}
static bool Inside( ArchFaceDetail face, Vector3 at )
{
Basis( ArchMeshContactService.Canonical( face.Normal ), out var right, out var up );
return Contains( Flatten( face.Corners, face.Centre, right, up ), Flatten( new[] { at }, face.Centre, right, up )[0] );
}
// What this face z-fights: same plane, overlapping there. Answered off the same probe the rows and
// the picker read, so a partner can be selected and copied rather than only named.
public static List<ArchFaceDetail> Fighting( IEnumerable<ArchFacePiece> pieces, ArchFaceDetail face )
{
var plane = ArchMeshContactService.PlaneKey( face.Normal, face.Centre );
// By id, not by reference: a query that probed the target separately from the tool's own cache
// holds different objects for the same face, and the face then reported fighting ITSELF.
return pieces
.SelectMany( piece => piece.Faces )
.Where( other => other.Id != face.Id
&& ArchMeshContactService.PlaneKey( other.Normal, other.Centre ) == plane
&& Overlapping( face.Corners, other.Corners, face.Normal, face.Centre ) )
.ToList();
}
// What is WRONG with these faces, resolved against the WHOLE build rather than the target: the
// other half of a z-fight is almost never in the piece that was picked, and a border's missing
// partner never is. This is the text the panel copies and arch_faces returns - one answer, so a
// paste from the editor and a query over MCP cannot describe the same face differently.
public static string FaceDebug( ArchTool tool, IReadOnlyList<ArchFaceDetail> faces )
{
if ( faces is not { Count: > 0 } )
{
return "no faces picked - open a layer's GEOMETRY rows in Plan Layers and select one or more faces";
}
var context = tool.EveryBuiltPiece().ToList();
var lines = new List<string>
{
$"# arch face debug · {faces.Count} face(s) · {context.Count} built pieces in the scene"
};
foreach ( var group in faces.GroupBy( face => face.Piece ) )
{
lines.Add( "" );
lines.Add( $"## {group.Key}{Named( tool, group.First().LayerId )}" );
foreach ( var face in group )
{
Describe( lines, face, context );
}
}
return string.Join( "\n", lines );
}
static void Describe( List<string> lines, ArchFaceDetail face, List<ArchFacePiece> context )
{
lines.Add( $"[{face.Id}] {face.Label} · normal {Say( face.Normal )} · centre {Say( face.Centre )} · {face.Area:0.#} sq in" );
lines.Add( $" corners {string.Join( " / ", face.Corners.Select( Say ) )}" );
var fought = Fighting( context, face );
foreach ( var other in fought )
{
var facing = Vector3.Dot( face.Normal, other.Normal ) < 0f ? "back to back" : "stacked";
lines.Add( $" Z-FIGHT {facing} with {other.Piece} face {other.Index} ({other.Role}) on the same plane, {other.Area:0.#} sq in" );
}
if ( fought.Count == 0 )
{
lines.Add( " nothing else lies on this plane where this face is" );
}
foreach ( var piece in Through( face, context ) )
{
lines.Add( $" WITHIN the extent of {piece} - not proof it is buried, but nothing that reads as a finished surface should be standing inside another part" );
}
}
// The same facts, written to be ACTED on rather than read: a verdict first, then one line per face
// keyed by an id that can be asked about again, then the relations between them. The prose report
// repeats every corner of every face and buries the one sentence that matters.
public static string FaceBriefing( ArchTool tool, IReadOnlyList<ArchFaceDetail> faces )
{
if ( faces is not { Count: > 0 } )
{
return "no faces picked - pick them in the viewport with Selection > Geometry, or in the Plan Layers stack";
}
var context = tool.EveryBuiltPiece().ToList();
var fights = new List<string>();
var inside = new List<string>();
var rows = new List<string>();
// A pair is one defect however many of its halves were picked, and counting it twice overstates
// the verdict.
var paired = new HashSet<string>();
foreach ( var face in faces )
{
rows.Add( $"{face.Id} · {face.Role} · {face.Plane} · {face.Size} · c {Say( face.Centre )} · n {Say( face.Normal )} · {face.Area:0.#} sq in" );
foreach ( var other in Fighting( context, face ) )
{
if ( !paired.Add( string.CompareOrdinal( face.Id, other.Id ) < 0 ? $"{face.Id}|{other.Id}" : $"{other.Id}|{face.Id}" ) )
{
continue;
}
var facing = Vector3.Dot( face.Normal, other.Normal ) < 0f ? "back-to-back" : "stacked";
fights.Add( $"{face.Id} × {other.Id} · {facing} on {face.Plane} · {other.Role} · {other.Area:0.#} sq in" );
}
foreach ( var piece in Through( face, context ) )
{
inside.Add( $"{face.Id} inside {piece}" );
}
}
var lines = new List<string>
{
"# ARCH FACE BRIEFING",
"",
"WHAT THIS IS. A measurement report from the Risk of Pain Architecture tool - the editor-only",
"building generator in the Sunless arch libraries. Someone picked these faces in the editor because",
"something about them looks wrong on screen, and pasted this to you. Alongside it they will",
"usually say what they are seeing, or show a picture; this is the measured half of that. Every",
"number is read off the geometry standing in the open scene, not inferred.",
"",
$"SCOPE. {faces.Count} face(s) picked, out of {context.Count} built pieces in the scene.",
$"VERDICT: {Verdict( fights.Count, inside.Count )}",
"",
"HOW TO READ IT",
"- FACES: one line per picked face. The id is layer:piece:index and can be asked about again.",
" plane is the single number locating an axis-aligned face - what two fighting faces have in",
" common. size is its two real extents, c its centre, n its normal. Units are inches.",
"- SHARES A PLANE: two faces lying on one plane AND overlapping there. That is a z-fight: it",
" flickers as the camera moves. back-to-back means they face away from each other, stacked",
" means the same way. Every bite and inset constant in the generators exists to avoid this.",
"- STANDS INSIDE ANOTHER PART: this face's centre falls within another piece's extent, so it is",
" buried or driving through it. Not proof on its own, but no finished surface should be there.",
"- LAYERS: where each face's layer sits in the authored plan, as a path.",
"",
"START HERE, IN THIS ORDER",
"1. Load the `architecture` skill. It is the map to this generator; without it you will read a",
" lot of files to learn what one page already says.",
"2. Re-query these exact faces by id over the editor's MCP server (registered as `sbox`) - do",
" not go looking for them yourself, they are already identified:",
$" arch_faces target={string.Join( ",", faces.Select( face => face.Id ) )}",
" That returns this same report, live, and every id in it can be asked about the same way.",
"3. Only then widen, and only with measurements:",
" arch_audit target=<part> checks=coplanar,gaps,intersections",
" arch_measure target=<part> planned extent beside built extent, per role",
" Targets take 'wall:18', 'room:17', 'walkway:17', a kind keyword like 'walkways', or",
" 'x,y,z@radius' for everything near a point. Call arch_activate first if a call comes back",
" saying the tool is not active.",
"",
"DO NOT render pictures of your own to work this out - arch_view exists but the numbers here are",
"the evidence, and a render only shows what you framed. Generate an image only if the person",
"explicitly asks to see one.",
"",
"The fix belongs in whatever GENERATOR emitted the face, in the arch libraries, never in the",
"scene: the scene is regenerated from the plan and hand edits are thrown away.",
"",
"FACES id · role · plane · size · centre · normal · area"
};
lines.AddRange( rows );
lines.Add( "" );
lines.Add( "SHARES A PLANE (z-fights)" );
lines.AddRange( fights.Count > 0 ? fights : new List<string> { "none" } );
lines.Add( "" );
lines.Add( "STANDS INSIDE ANOTHER PART" );
lines.AddRange( inside.Count > 0 ? inside : new List<string> { "none" } );
lines.Add( "" );
lines.Add( "LAYERS" );
lines.AddRange( faces
.Select( face => face.LayerId )
.Distinct()
.Select( layer => $"{layer} · {Breadcrumb( tool, layer )}" ) );
return string.Join( "\n", lines );
}
static string Verdict( int fights, int inside )
{
if ( fights == 0 && inside == 0 )
{
return "nothing shares a plane with these and none stands inside another part - whatever is wrong here is not a z-fight";
}
var said = new List<string>();
if ( fights > 0 )
{
said.Add( $"{fights} coplanar overlap(s) - these WILL flicker" );
}
if ( inside > 0 )
{
said.Add( $"{inside} face(s) standing inside another part - buried, or driving through it" );
}
return string.Join( ", ", said );
}
// Named by bounds, not by ray: a face sitting inside another part's box is the thing that reads as
// "the wall drives through the house", and it wants saying even when a trace would miss it. The box
// closes by a contact first, or every cap butted onto the face it dies against reads as buried in it.
static IEnumerable<string> Through( ArchFaceDetail face, List<ArchFacePiece> context )
{
foreach ( var piece in context )
{
if ( piece.Name == face.Piece || !piece.Bounds.Grow( -Closing ).Contains( face.Centre ) )
{
continue;
}
yield return piece.Name;
}
}
static List<ArchFaceDetail> Detailed( Piece piece, IReadOnlyDictionary<string, string> roles )
{
var faces = new List<ArchFaceDetail>();
var layer = LayerOf( piece.Name );
for ( var index = 0; index < piece.Faces.Count; index++ )
{
var face = piece.Faces[index];
var extent = new ArchExtent();
foreach ( var corner in face.Corners )
{
extent.Add( corner );
}
faces.Add( new ArchFaceDetail
{
Piece = piece.Name,
Index = index,
HandleIndex = face.HandleIndex,
LayerId = layer,
Material = face.Material,
Role = roles.TryGetValue( face.Material, out var role ) ? role : "unbound",
Plane = Stated( face.Normal, face.Centre ),
Normal = face.Normal,
Centre = face.Centre,
TextureAxisU = face.TextureAxisU,
TextureAxisV = face.TextureAxisV,
Area = face.Area,
Bounds = extent.Box,
Corners = face.Corners
} );
}
return faces;
}
// An axis-aligned plane is worth stating as the one number that locates it, because that number is
// what two fighting faces have in common and what a bite constant is supposed to have moved.
static string Stated( Vector3 normal, Vector3 centre )
{
if ( MathF.Abs( normal.x ) > 0.999f )
{
return $"x = {centre.x:0.##}";
}
if ( MathF.Abs( normal.y ) > 0.999f )
{
return $"y = {centre.y:0.##}";
}
if ( MathF.Abs( normal.z ) > 0.999f )
{
return $"z = {centre.z:0.##}";
}
return $"n {Say( normal )} · d {Vector3.Dot( normal, centre ):0.##}";
}
// The generated name carries its layer id, and the deepest segment that has one is the layer that
// emitted the piece - a wall's own id, not the room it hangs in.
public static int LayerOf( string piece )
{
var segments = (piece ?? string.Empty).Split( '/', StringSplitOptions.RemoveEmptyEntries );
for ( var index = segments.Length - 1; index >= 0; index-- )
{
if ( ArchNames.TrySourceId( segments[index], out var id ) )
{
return id;
}
}
return 0;
}
static string Named( ArchTool tool, int layerId )
{
if ( layerId == 0 || tool.LayerTree.Find( layerId ) is null )
{
return "";
}
return $" · layer {layerId} · {Breadcrumb( tool, layerId )}";
}
static string Breadcrumb( ArchTool tool, int layerId )
{
return tool.LayerTree.Find( layerId ) is { } node ? tool.LayerTree.Breadcrumb( node ) : "not in the plan";
}
// Resolves an id from a briefing back to the face it named, so a report can be asked about again.
public static ArchFaceDetail FaceById( ArchTool tool, string id )
{
var parts = (id ?? string.Empty).Split( ':' );
if ( parts.Length != 3 || !int.TryParse( parts[0], out var layer ) || !int.TryParse( parts[2], out var index ) )
{
return null;
}
return tool.BuiltFaces( layer )
.Where( piece => piece.Name.Split( '/' )[^1] == parts[1] )
.SelectMany( piece => piece.Faces )
.FirstOrDefault( face => face.Index == index );
}
}