Editor/Effigy/CoplanarMerge.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy;
/// <summary>
/// Put a face back together after something has cut it into coplanar pieces.
///
/// WHY THIS EXISTS, AND WHY THE EXISTING REPAIR DOES NOT COVER IT. Two things can happen to the
/// face a boolean cuts through, and they need opposite fixes. If the engine returns it as ONE loop
/// that runs out to the hole and back along a seam, that is a bridged face and
/// Triangulate.SplitBridgedFace takes it apart into the n+1 n-gons a face with n holes has to be.
/// If instead the engine returns it as MANY separate faces that happen to share a plane, nothing
/// looked at it at all - and that is the case measured on a real part: an extrude cut into the side
/// of a body came back as 88 triangles and quads lying in a single plane, with `bridged faces: 0`.
///
/// Both produce a mesh that is closed, manifold, Euler-correct and exactly the right volume. The
/// cost is paid somewhere no geometric check looks: a Face is the unit of SELECTION and of material
/// assignment, so clicking that wall to paint it painted one fragment out of 88. Same lesson as the
/// bridged-face work, arriving from the other side.
///
/// So this walks the other direction from Triangulate.SplitBridgedFace. Faces that share an edge,
/// share a plane and share a material slot are one surface that got fragmented, and they are welded
/// back into the largest n-gons that surface can be - one, or n+1 when the surface has n holes in
/// it, which is the same floor the split path lands on and for the same reason: a face is one loop
/// of corners, so a surface with a hole in it is never one face at any price.
///
/// IT REFUSES RATHER THAN GUESSES, exactly like MeshHoleRepair. A group whose boundary does not
/// chain into clean loops, or whose merged area does not add up to the pieces it replaced, is left
/// as it was. A wrong merge is a self-intersecting face that passes every validator, and coarse
/// geometry that selects badly is far better than geometry that is quietly wrong.
///
/// MATERIAL SLOTS ARE PART OF THE IDENTITY, not an afterthought. Two coplanar neighbours painted
/// different colours are two faces because the user made them two faces, and merging them would
/// throw away the assignment being protected here.
/// </summary>
public static class CoplanarMerge
{
/// <summary>Cosine limit for "these two faces lie in the same plane". Compared on the signed
/// dot rather than its absolute value: two fragments of one surface face the same way, and a
/// pair that face opposite ways are the two sides of a zero-thickness sliver, which must never
/// be welded into one face.</summary>
const float NormalTolerance = 0.9995f;
/// <summary>How far apart the two planes may sit and still be one plane. A float-drift
/// tolerance, not a modelling one - the fragments are exactly coplanar in exact arithmetic and
/// a few ulps apart after a boolean has recomputed their corners.</summary>
const float PlaneTolerance = 1e-3f;
/// <summary>How far the merged area may drift from the area of the pieces it replaces, as a
/// fraction. The merge is a repartition of the same surface, so the two agree to rounding or
/// the loops were chained wrong.</summary>
const float AreaTolerance = 1e-3f;
/// <summary>
/// Weld every fragmented surface in the mesh back together, in place. Returns how many faces
/// were removed - zero when there was nothing to do, so a caller can report having acted
/// rather than guessing.
/// </summary>
public static int Merge( PolyMesh mesh )
{
if ( mesh is null || mesh.FaceCount < 2 )
return 0;
var groups = Group( mesh );
if ( groups is null )
return 0;
// EVERY GROUP IS DECIDED BEFORE ANY FACE MOVES. Rebuilding the list once at the end keeps
// face indices - which the edge map and the grouping both hold - valid the whole way
// through, and means a group that refuses halfway leaves nothing behind.
var replacements = new Dictionary<int, List<Face>>();
foreach ( var (root, members) in groups )
{
if ( members.Count < 2 )
continue;
var merged = TryMerge( mesh, members );
if ( merged is not null )
replacements[root] = merged;
}
if ( replacements.Count == 0 )
return 0;
var before = mesh.FaceCount;
var replaced = new HashSet<int>();
foreach ( var (root, _) in replacements )
{
foreach ( var index in groups[root] )
replaced.Add( index );
}
var faces = new List<Face>( mesh.FaceCount );
for ( var i = 0; i < mesh.FaceCount; i++ )
{
if ( !replaced.Contains( i ) )
faces.Add( mesh.Faces[i] );
}
foreach ( var (_, merged) in replacements )
faces.AddRange( merged );
mesh.Faces = faces;
return before - mesh.FaceCount;
}
// --- grouping -----------------------------------------------------------------------------
/// <summary>
/// The biggest group of faces that are still one surface pretending to be several. Zero or one
/// means nothing is fragmented; anything higher is how many clicks it takes to paint the worst
/// face on the model.
///
/// PUBLIC BECAUSE THE DEFECT WAS INVISIBLE. Every existing measure - closed, manifold, Euler,
/// volume, largest face - passed on a mesh with 88 fragments of one wall in it. This is the
/// number that would have shown it, so the diagnostics print it.
/// </summary>
public static int LargestFragmentedSurface( PolyMesh mesh )
{
if ( mesh is null || mesh.FaceCount < 2 )
return 0;
var groups = Group( mesh );
var largest = 0;
if ( groups is not null )
{
foreach ( var (_, members) in groups )
largest = Math.Max( largest, members.Count );
}
return largest;
}
/// <summary>
/// Union faces that share an edge, a plane and a material into connected surfaces.
///
/// Adjacency comes off the shared edge rather than off the plane alone, because two fragments
/// of the same plane on opposite sides of a part are not one face however well their planes
/// agree - the top of a step and the floor it steps down to can be coplanar and are separate
/// surfaces with a gap between them.
/// </summary>
static Dictionary<int, List<int>> Group( PolyMesh mesh )
{
var normals = new Vec3[mesh.FaceCount];
var offsets = new float[mesh.FaceCount];
var usable = new bool[mesh.FaceCount];
for ( var i = 0; i < mesh.FaceCount; i++ )
{
var face = mesh.Faces[i];
if ( face.Count < 3 )
continue;
var normal = mesh.FaceNormal( face );
if ( normal.LengthSquared < 1e-20f )
continue;
normals[i] = normal.Normal;
offsets[i] = Vec3.Dot( normals[i], mesh.FaceCentroid( face ) );
usable[i] = true;
}
var parent = new int[mesh.FaceCount];
for ( var i = 0; i < parent.Length; i++ )
parent[i] = i;
var merged = false;
foreach ( var (_, faces) in mesh.BuildEdgeFaces() )
{
// Only a manifold edge joins two faces into one surface. A boundary edge has nothing on
// the other side, and a non-manifold one has no single other side to pick.
if ( faces.Count != 2 )
continue;
var a = faces[0];
var b = faces[1];
if ( !usable[a] || !usable[b] )
continue;
if ( mesh.Faces[a].Material != mesh.Faces[b].Material )
continue;
if ( Vec3.Dot( normals[a], normals[b] ) < NormalTolerance )
continue;
if ( MathF.Abs( offsets[a] - offsets[b] ) > PlaneTolerance )
continue;
if ( Union( parent, a, b ) )
merged = true;
}
if ( !merged )
return null;
var groups = new Dictionary<int, List<int>>();
for ( var i = 0; i < mesh.FaceCount; i++ )
{
if ( !usable[i] )
continue;
var root = Find( parent, i );
if ( !groups.TryGetValue( root, out var list ) )
groups[root] = list = new List<int>();
list.Add( i );
}
return groups;
}
static int Find( int[] parent, int i )
{
while ( parent[i] != i )
i = parent[i] = parent[parent[i]];
return i;
}
static bool Union( int[] parent, int a, int b )
{
var ra = Find( parent, a );
var rb = Find( parent, b );
if ( ra == rb )
return false;
parent[rb] = ra;
return true;
}
// --- merging one group --------------------------------------------------------------------
/// <summary>
/// The faces one fragmented surface should have become, or null to leave it alone.
///
/// Null at the first thing that cannot be established rather than at the first thing that looks
/// wrong - everything here comes out of a boolean, and the difference between "this is not a
/// simple planar patch" and "this is a simple planar patch I have mis-walked" is not something
/// the code can tell from the inside.
/// </summary>
static List<Face> TryMerge( PolyMesh mesh, List<int> members )
{
var loops = BoundaryLoops( mesh, members );
if ( loops is null || loops.Count == 0 )
return null;
var material = mesh.Faces[members[0]].Material;
var normal = GroupNormal( mesh, members );
if ( normal.LengthSquared < 1e-20f )
return null;
normal = normal.Normal;
Basis( normal, out var u, out var v );
var flat = new List<List<Vec2>>( loops.Count );
foreach ( var loop in loops )
{
var points = new List<Vec2>( loop.Count );
foreach ( var index in loop )
points.Add( Flatten( mesh.Positions[index], u, v ) );
flat.Add( points );
}
// The surface's own area, measured off the pieces, to check the answer against.
var target = 0f;
foreach ( var index in members )
{
var face = mesh.Faces[index];
var points = new List<Vec2>( face.Count );
foreach ( var corner in face.Indices )
points.Add( Flatten( mesh.Positions[corner], u, v ) );
target += MathF.Abs( SignedArea( points ) );
}
var uvs = CornerUVs( mesh, members );
// ONE LOOP: a plain patch with no hole in it, which is one n-gon.
if ( loops.Count == 1 )
{
var loop = loops[0];
if ( SignedArea( flat[0] ) < 0f )
{
loop = new List<int>( loop );
loop.Reverse();
}
var face = BuildFace( loop, uvs, material );
return face is not null && Agrees( AreaOf( mesh, loop, u, v ), target )
? new List<Face> { face }
: null;
}
// SEVERAL LOOPS: an outer boundary and n holes, which is n+1 faces. Same floor
// Triangulate.SplitBridgedFace lands on, reached from the other direction.
var outer = OuterLoop( flat );
if ( outer < 0 )
return null;
var outerPoints = flat[outer];
var holePoints = new List<IReadOnlyList<Vec2>>( flat.Count - 1 );
var holeLoops = new List<List<int>>( flat.Count - 1 );
for ( var i = 0; i < flat.Count; i++ )
{
if ( i == outer )
continue;
holePoints.Add( flat[i] );
holeLoops.Add( loops[i] );
}
var split = Triangulate.SplitWithHoles( outerPoints, holePoints );
if ( split is null || split.Count == 0 )
return null;
// SplitWithHoles indexes the concatenated list it was handed - outer first, then each hole
// in order - so the map back is positional, the same contract MeshHoleRepair relies on.
var combined = new List<int>( loops[outer] );
foreach ( var hole in holeLoops )
combined.AddRange( hole );
var built = new List<Face>( split.Count );
var area = 0f;
foreach ( var piece in split )
{
var loop = new List<int>( piece.Count );
foreach ( var i in piece )
loop.Add( combined[i] );
var face = BuildFace( loop, uvs, material );
if ( face is null )
return null;
built.Add( face );
area += AreaOf( mesh, loop, u, v );
}
return Agrees( area, target ) ? built : null;
}
/// <summary>Null for anything PolyMesh would be right to reject - too few corners, or a corner
/// visited twice, which is the pinched face this whole path exists to avoid producing.</summary>
static Face BuildFace( List<int> loop, Dictionary<int, Vec2> uvs, int material )
{
if ( loop.Count < 3 || loop.Distinct().Count() != loop.Count )
return null;
var indices = loop.ToArray();
var corners = new Vec2[indices.Length];
for ( var i = 0; i < indices.Length; i++ )
corners[i] = uvs.TryGetValue( indices[i], out var uv ) ? uv : Vec2.Zero;
return new Face( indices, corners, material );
}
/// <summary>
/// Chain the group's boundary into closed loops, or null if it is not a clean planar patch.
///
/// A boundary edge is one used by exactly one face OF THE GROUP: an edge shared by two members
/// is interior and disappears into the merged face, and an edge shared with a face outside the
/// group is the surface's own rim. Anything used more than twice is non-manifold within the
/// patch and there is no single boundary to walk.
/// </summary>
static List<List<int>> BoundaryLoops( PolyMesh mesh, List<int> members )
{
var counts = new Dictionary<EdgeKey, int>();
foreach ( var index in members )
{
var face = mesh.Faces[index];
for ( var i = 0; i < face.Count; i++ )
{
var key = new EdgeKey( face.Indices[i], face.Indices[(i + 1) % face.Count] );
counts.TryGetValue( key, out var count );
if ( count >= 2 )
return null;
counts[key] = count + 1;
}
}
var atVertex = new Dictionary<int, List<int>>();
foreach ( var (key, count) in counts )
{
if ( count != 1 )
continue;
Link( key.A, key.B );
Link( key.B, key.A );
}
// Every boundary vertex needs exactly two boundary edges or the walk has a choice to make,
// and a choice here is a guess. Same refusal MeshHoleRepair makes for the same reason.
foreach ( var (_, neighbours) in atVertex )
{
if ( neighbours.Count != 2 )
return null;
}
var loops = new List<List<int>>();
var visited = new HashSet<int>();
foreach ( var start in atVertex.Keys )
{
if ( !visited.Add( start ) )
continue;
var loop = new List<int> { start };
var current = start;
var previous = -1;
while ( true )
{
var neighbours = atVertex[current];
var next = neighbours[0] == previous ? neighbours[1] : neighbours[0];
if ( next == start )
break;
if ( !visited.Add( next ) )
return null;
loop.Add( next );
previous = current;
current = next;
// A patch cannot have a boundary longer than every boundary vertex it owns.
if ( loop.Count > atVertex.Count )
return null;
}
if ( loop.Count < 3 )
return null;
loops.Add( loop );
}
return loops;
void Link( int from, int to )
{
if ( !atVertex.TryGetValue( from, out var list ) )
atVertex[from] = list = new List<int>( 2 );
if ( !list.Contains( to ) )
list.Add( to );
}
}
/// <summary>
/// Which loop contains all the others. Area alone is not enough - a loop can be larger than
/// another and beside it rather than around it - so containment is tested outright, and a set
/// of loops with no single container is refused.
/// </summary>
static int OuterLoop( List<List<Vec2>> loops )
{
for ( var i = 0; i < loops.Count; i++ )
{
var contains = true;
for ( var j = 0; j < loops.Count && contains; j++ )
{
if ( i == j )
continue;
foreach ( var point in loops[j] )
{
if ( PointInPolygon( loops[i], point ) )
continue;
contains = false;
break;
}
}
if ( contains )
return i;
}
return -1;
}
/// <summary>Area-weighted normal of the whole group, so one tiny fragment with a noisy normal
/// cannot tilt the plane every corner is about to be flattened onto.</summary>
static Vec3 GroupNormal( PolyMesh mesh, List<int> members )
{
var sum = Vec3.Zero;
foreach ( var index in members )
{
var face = mesh.Faces[index];
if ( face.Count < 3 )
continue;
// FaceNormal normalises before returning, so the weighting has to be applied here -
// summing them raw would let a sliver count as much as the face it sits beside.
sum += mesh.FaceNormal( face ) * mesh.FaceArea( face );
}
return sum;
}
/// <summary>
/// One UV per vertex, taken from the group's own corners.
///
/// A fragmented surface is one planar patch that the boolean gave a single projected mapping,
/// so every fragment agrees about the UV at a shared corner and the first answer found is the
/// answer. Per-corner UVs exist for seams between faces that DISAGREE, and a seam inside one
/// flat surface is exactly what is being removed here.
/// </summary>
static Dictionary<int, Vec2> CornerUVs( PolyMesh mesh, List<int> members )
{
var uvs = new Dictionary<int, Vec2>();
foreach ( var index in members )
{
var face = mesh.Faces[index];
for ( var i = 0; i < face.Count; i++ )
{
var vertex = face.Indices[i];
if ( !uvs.ContainsKey( vertex ) && face.UVs is not null && i < face.UVs.Length )
uvs[vertex] = face.UVs[i];
}
}
return uvs;
}
static float AreaOf( PolyMesh mesh, List<int> loop, Vec3 u, Vec3 v )
{
var points = new List<Vec2>( loop.Count );
foreach ( var index in loop )
points.Add( Flatten( mesh.Positions[index], u, v ) );
return MathF.Abs( SignedArea( points ) );
}
static bool Agrees( float area, float target ) =>
MathF.Abs( area - target ) <= AreaTolerance * MathF.Max( target, 1f );
static float SignedArea( List<Vec2> points )
{
var sum = 0f;
for ( int i = 0, j = points.Count - 1; i < points.Count; j = i++ )
sum += (points[j].x - points[i].x) * (points[j].y + points[i].y);
return sum * 0.5f;
}
/// <summary>Right-handed, so u cross v is the normal and a counter-clockwise loop in this basis
/// is one wound the way the surface faces. Same construction as MeshHoleRepair's.</summary>
static void Basis( Vec3 normal, out Vec3 u, out Vec3 v )
{
var n = normal.Normal;
var seed = MathF.Abs( n.z ) < 0.9f ? new Vec3( 0, 0, 1 ) : new Vec3( 1, 0, 0 );
u = Vec3.Cross( seed, n ).Normal;
v = Vec3.Cross( n, u );
}
static Vec2 Flatten( Vec3 p, Vec3 u, Vec3 v ) => new( Vec3.Dot( p, u ), Vec3.Dot( p, v ) );
static bool PointInPolygon( List<Vec2> polygon, Vec2 point )
{
var inside = false;
for ( int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++ )
{
var a = polygon[i];
var b = polygon[j];
if ( a.y > point.y != b.y > point.y
&& point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x )
inside = !inside;
}
return inside;
}
}