Editor tool that builds a read-only contact graph of selected MeshComponents. Scan pairs meshes by AABB overlap, computes exact triangle-to-triangle gaps (point, edge, and intersection tests) within a triangle budget, and groups meshes into contact islands with union-find. Sidebar buttons select a group, drop it onto a ground height, or snap it to the nearest gap, each wrapped in an undo scope.
namespace AdvancedMappingTools;
/// <summary>Read-only contact graph of selected mesh surfaces. Corrections require a separate undoable action.</summary>
public static class ConnectionInspector
{
public sealed record Contact( int A, int B, float Gap, Vector3 PointA, Vector3 PointB );
public sealed class Report
{
public List<MeshComponent> Meshes { get; } = [];
public List<List<int>> Groups { get; } = [];
public List<Contact> Near { get; } = [];
public bool Complete { get; set; } = true;
internal List<(Transform World, int Geometry)> Snapshots { get; } = [];
public bool Matches() => Meshes.Count == Snapshots.Count && Meshes.Select( ( m, i ) => m.IsValid() && m.Mesh != null && m.WorldTransform.Equals( Snapshots[i].World ) && PartHash.Geometry( m.Mesh ) == Snapshots[i].Geometry ).All( x => x );
public string Summary => $"{Meshes.Count} mesh parts; {Groups.Count} contact group(s)." + (Complete ? "" : " Limit reached: partial result; correction disabled.");
}
readonly record struct Tri( Vector3 A, Vector3 B, Vector3 C );
static Vector3 Closest( Vector3 p, Tri t )
{
var ab = t.B - t.A;
var ac = t.C - t.A;
var ap = p - t.A;
var d1 = ab.Dot( ap );
var d2 = ac.Dot( ap );
if ( d1 <= 0 && d2 <= 0 )
return t.A;
var bp = p - t.B;
var d3 = ab.Dot( bp );
var d4 = ac.Dot( bp );
if ( d3 >= 0 && d4 <= d3 )
return t.B;
var vc = d1 * d4 - d3 * d2;
if ( vc <= 0 && d1 >= 0 && d3 <= 0 )
return t.A + ab * (d1 / (d1 - d3));
var cp = p - t.C;
var d5 = ab.Dot( cp );
var d6 = ac.Dot( cp );
if ( d6 >= 0 && d5 <= d6 )
return t.C;
var vb = d5 * d2 - d1 * d6;
if ( vb <= 0 && d2 >= 0 && d6 <= 0 )
return t.A + ac * (d2 / (d2 - d6));
var va = d3 * d6 - d5 * d4;
if ( va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0 )
return t.B + (t.C - t.B) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
var denominator = va + vb + vc;
if ( MathF.Abs( denominator ) < 1e-12f )
return t.A;
return t.A + ab * (vb / denominator) + ac * (vc / denominator);
}
static bool Pierce( Vector3 a, Vector3 b, Tri t, out Vector3 point )
{
point = default;
var dir = b - a;
var edge = t.B - t.A;
var cross = Vector3.Cross( dir, t.C - t.A );
var det = edge.Dot( cross );
if ( MathF.Abs( det ) < 1e-7f )
return false;
var inv = 1 / det;
var p = a - t.A;
var u = p.Dot( cross ) * inv;
if ( u < 0 || u > 1 )
return false;
var q = Vector3.Cross( p, edge );
var v = dir.Dot( q ) * inv;
if ( v < 0 || u + v > 1 )
return false;
var d = (t.C - t.A).Dot( q ) * inv;
if ( d < 0 || d > 1 )
return false;
point = a + dir * d;
return true;
}
static (Vector3 A, Vector3 B) Edges( Vector3 p, Vector3 q, Vector3 a, Vector3 b )
{
var u = q - p;
var v = b - a;
var w = p - a;
var aa = u.Dot( u );
var bb = u.Dot( v );
var cc = v.Dot( v );
var dd = u.Dot( w );
var ee = v.Dot( w );
if ( aa < 1e-10f || cc < 1e-10f )
return (p, a);
var denominator = aa * cc - bb * bb;
var s = denominator > 1e-10f ? Math.Clamp( (bb * ee - cc * dd) / denominator, 0, 1 ) : 0;
var t = (bb * s + ee) / cc;
if ( t < 0 )
{
t = 0;
s = Math.Clamp( -dd / aa, 0, 1 );
}
else if ( t > 1 )
{
t = 1;
s = Math.Clamp( (bb - dd) / aa, 0, 1 );
}
return (p + u * s, a + v * t);
}
static Tri[] Triangles( MeshComponent mesh ) => mesh.Mesh.FaceHandles.SelectMany( face =>
{
var vertices = mesh.Mesh.CreateFace( face, mesh.WorldTransform, Color.White );
if ( vertices is null )
return Array.Empty<Tri>();
return Enumerable.Range( 0, vertices.Length / 3 ).Select( i => new Tri( vertices[i * 3].Position, vertices[i * 3 + 1].Position, vertices[i * 3 + 2].Position ) ).ToArray();
} ).ToArray();
static float BoxGap( BBox a, BBox b )
{
var d = Vector3.Max( Vector3.Max( a.Mins - b.Maxs, b.Mins - a.Maxs ), Vector3.Zero );
return d.Length;
}
public static Report Scan( IEnumerable<GameObject> selection, float tolerance = .25f, float maxGap = 32 )
{
var report = new Report();
var all = selection.Where( o => o.IsValid() ).SelectMany( o => o.Components.GetAll<MeshComponent>( FindMode.EverythingInSelfAndDescendants ) ).Where( m => m.IsValid() && m.Active && m.Mesh != null ).Distinct().ToList();
if ( all.Count > 256 )
report.Complete = false;
report.Meshes.AddRange( all.Take( 256 ) );
report.Snapshots.AddRange( report.Meshes.Select( m => (m.WorldTransform, PartHash.Geometry( m.Mesh )) ) );
var n = report.Meshes.Count;
var parents = Enumerable.Range( 0, n ).ToArray();
int Root( int i )
{
while ( parents[i] != i )
{
parents[i] = parents[parents[i]];
i = parents[i];
}
return i;
}
var triangles = report.Meshes.Select( Triangles ).ToArray();
var bounds = report.Meshes.Select( m => m.Mesh.CalculateBounds( m.WorldTransform ) ).ToArray();
var budget = 2000000;
for ( var i = 0; i < n; i++ )
for ( var j = i + 1; j < n; j++ )
{
if ( BoxGap( bounds[i], bounds[j] ) > MathF.Max( tolerance, maxGap ) )
continue;
var gap = float.PositiveInfinity;
var pa = Vector3.Zero;
var pb = Vector3.Zero;
void Keep( Vector3 a, Vector3 b )
{
var d = a.Distance( b );
if ( d < gap )
{
gap = d;
pa = a;
pb = b;
}
}
foreach ( var a in triangles[i] )
{
foreach ( var b in triangles[j] )
{
if ( --budget < 0 )
{
report.Complete = false;
break;
}
var av = new[]
{
a.A,
a.B,
a.C
};
var bv = new[]
{
b.A,
b.B,
b.C
};
foreach ( var p in av )
Keep( p, Closest( p, b ) );
foreach ( var p in bv )
Keep( Closest( p, a ), p );
for ( var k = 0; k < 3; k++ )
{
if ( Pierce( av[k], av[(k + 1) % 3], b, out var hit ) || Pierce( bv[k], bv[(k + 1) % 3], a, out hit ) )
{
Keep( hit, hit );
break;
}
for ( var l = 0; l < 3; l++ )
{
var pair = Edges( av[k], av[(k + 1) % 3], bv[l], bv[(l + 1) % 3] );
Keep( pair.A, pair.B );
}
}
if ( gap <= .0001f )
break;
}
if ( budget < 0 || gap <= .0001f )
break;
}
if ( budget < 0 )
continue;
if ( gap <= tolerance )
parents[Root( j )] = Root( i );
if ( gap <= maxGap )
report.Near.Add( new( i, j, gap, pa, pb ) );
}
report.Groups.AddRange( Enumerable.Range( 0, n ).GroupBy( Root ).Select( g => g.ToList() ).OrderByDescending( g => g.Count ) );
return report;
}
public static Contact NearestGap( Report report, int group, float tolerance = .25f )
{
if ( !report.Complete || group < 0 || group >= report.Groups.Count )
return null;
var ids = report.Groups[group].ToHashSet();
return report.Near.Where( c => ids.Contains( c.A ) != ids.Contains( c.B ) && c.Gap > tolerance ).OrderBy( c => c.Gap ).FirstOrDefault();
}
static bool Descends( GameObject child, GameObject ancestor )
{
for ( var p = child.Parent; p != null; p = p.Parent )
if ( p == ancestor )
return true;
return false;
}
public static List<GameObject> Movers( Report report, int group )
{
var objects = report.Groups[group].Select( i => report.Meshes[i].GameObject ).Distinct().ToList();
return objects.Where( o => !objects.Any( parent => parent != o && Descends( o, parent ) ) ).ToList();
}
public static Vector3 Correction( Report report, int group, Contact contact ) => report.Groups[group].Contains( contact.A ) ? contact.PointB - contact.PointA : contact.PointA - contact.PointB;
}
[ToolCategory( "Editing" ), Title( "Connection Inspector" ), Icon( "hub" )]
public sealed class ConnectionInspectorEditor( PrimitiveTool tool ) : BuilderEditor( tool )
{
[Property, Group( "Inspection" ), Range( .01f, 4, slider: false ), WideMode]
public float Tolerance { get; set; } = .25f;
[Property, Group( "Inspection" ), Range( 1, 128, slider: false ), WideMode]
public float MaximumGap { get; set; } = 32;
[Property, Group( "Inspection" ), Range( 0, 255, slider: false ), WideMode]
public int Group { get; set; } = 1;
[Property, Group( "Inspection" ), WideMode]
public bool ShowMarkers { get; set; } = true;
[Property, Group( "Ground Reference" ), WideMode]
public bool CheckGround { get; set; }
[Property, Group( "Ground Reference" ), Range( -8192, 8192, slider: false ), WideMode]
public float GroundHeight { get; set; }
public override IReadOnlySet<string> UnusedOptions => EditorTypeLibrary.GetType( typeof(BuilderEditor) ).Properties.Where( p => p.HasAttribute<PropertyAttribute>() ).Select( p => p.Name ).ToHashSet();
ConnectionInspector.Report report;
protected override int MinPoints => 0;
public override bool CanBuild => false;
public override PolygonMesh Build() => null;
protected override void BuildMesh( MeshKit kit )
{
}
public override void OnUpdate( SceneTrace trace )
{
if ( !ShowMarkers || report is null )
return;
for ( var g = 0; g < report.Groups.Count; g++ )
{
Gizmo.Draw.Color = g == 0 ? Color.Green : Color.Yellow;
foreach ( var i in report.Groups[g] )
{
var m = report.Meshes[i];
if ( m.IsValid() && m.Mesh != null )
DrawLabel( m.Mesh.CalculateBounds( m.WorldTransform ).Center, $"Contact group {g}" + (CheckGround && m.Mesh.CalculateBounds( m.WorldTransform ).Mins.z > GroundHeight + Tolerance ? " (above ground reference)" : ""), g == 0 ? Color.Green : Color.Yellow );
}
}
}
public override Widget CreateWidget() => new Sheet( this );
sealed class Sheet : ToolSidebarWidget
{
readonly SerializedObject _serialized;
readonly ConnectionInspectorEditor editor;
readonly Label status;
public Sheet( ConnectionInspectorEditor e )
{
editor = e;
var group = AddGroup( "Selected Mesh Connections" );
group.Add( new Label( "Select one assembly, then scan. Separate contact groups are candidates, not automatically defects. Scan never changes objects." ) { WordWrap = true } );
group.Add( new ListButton( "Scan Selection", "search" ) { Clicked = Scan } );
group.Add( new ListButton( "Select Contact Group", "select_all" ) { Clicked = Select } );
group.Add( new ListButton( "Place Group On Ground Reference", "vertical_align_bottom" ) { Clicked = Ground } );
group.Add( new ListButton( "Snap Group To Nearest Surface", "link" ) { Clicked = Snap } );
status = group.Add( new Label( "No scan yet." ) { WordWrap = true } );
_serialized = e.GetSerialized();
PropertySheet.Fill( this, _serialized, "Inspection Settings", e.HiddenOptions );
}
void Scan()
{
editor.report = ConnectionInspector.Scan( SceneEditorSession.Active.Selection.OfType<GameObject>(), editor.Tolerance, editor.MaximumGap );
status.Text = editor.report.Summary;
}
void Select()
{
var r = editor.report;
if ( r is null || editor.Group < 0 || editor.Group >= r.Groups.Count )
return;
var selection = SceneEditorSession.Active.Selection;
selection.Clear();
foreach ( var i in r.Groups[editor.Group] )
if ( r.Meshes[i].IsValid() )
selection.Add( r.Meshes[i].GameObject );
}
void Ground()
{
var r = editor.report;
if ( !editor.CheckGround || r is null || !r.Complete || editor.Group < 0 || editor.Group >= r.Groups.Count || !r.Matches() )
{
status.Text = "Enable ground reference and scan a valid group first.";
return;
}
var bottom = r.Groups[editor.Group].Min( i => r.Meshes[i].Mesh.CalculateBounds( r.Meshes[i].WorldTransform ).Mins.z );
var delta = Vector3.Up * (editor.GroundHeight - bottom);
var objects = ConnectionInspector.Movers( r, editor.Group );
using ( SceneEditorSession.Scope() )
using ( SceneEditorSession.Active.UndoScope( "Place Contact Group On Ground" ).WithGameObjectChanges( objects, GameObjectUndoFlags.All ).Push() )
foreach ( var go in objects )
go.WorldPosition += delta;
editor.report = ConnectionInspector.Scan( r.Meshes.Select( m => m.GameObject ), editor.Tolerance, editor.MaximumGap );
status.Text = editor.report.Summary;
}
void Snap()
{
var r = editor.report;
if ( r is null || !r.Matches() )
{
status.Text = "Scan again: objects changed.";
return;
}
r = ConnectionInspector.Scan( r.Meshes.Select( m => m.GameObject ), editor.Tolerance, editor.MaximumGap );
editor.report = r;
var contact = ConnectionInspector.NearestGap( r, editor.Group, editor.Tolerance );
if ( contact is null )
{
status.Text = "No safe nearby correction. Check group number or scan limits.";
return;
}
var movers = ConnectionInspector.Movers( r, editor.Group );
var delta = ConnectionInspector.Correction( r, editor.Group, contact );
using ( SceneEditorSession.Scope() )
using ( SceneEditorSession.Active.UndoScope( "Snap Contact Group" ).WithGameObjectChanges( movers, GameObjectUndoFlags.All ).Push() )
foreach ( var go in movers )
go.WorldPosition += delta;
editor.report = ConnectionInspector.Scan( r.Meshes.Select( m => m.GameObject ), editor.Tolerance, editor.MaximumGap );
status.Text = editor.report.Summary;
}
}
}