Editor/Effigy/MeshHoleRepair.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// Close a surface whose holes arrived as open boundary loops instead of as holes in a face.
///
/// WHY THIS EXISTS. s&box's mesh boolean cuts correctly and then cannot fully describe what it
/// cut. A half-edge face is one closed loop of half-edges, so a face with a hole in it has no
/// representation, and reading such a face back through GetFaceVertices returns its OUTER contour
/// only — the inner loop is simply not in the answer. There is no hole API on PolygonMesh to ask
/// instead; the shipped XML documentation lists none.
///
/// What comes out is therefore a mesh that is correct everywhere except at the mouth of the cut:
/// the tunnel walls are all there, the far cap is there, and the face the cut entered through is a
/// plain polygon with no opening in it. The hole shows up as a RING OF BOUNDARY EDGES that no face
/// closes — the walls claim those edges once each and nothing claims them a second time. On screen
/// that is a hole you can see the tunnel through and cannot see into: the opening is covered.
///
/// This puts the opening back. Every boundary loop is matched to the face it lies inside and
/// spliced into it as a hole, using the same Triangulate.WithHoles that Effigy's own holed caps go
/// through — so a repaired cut and a sketched hole end up built the same way.
///
/// IT IS DELIBERATELY CONSERVATIVE. A loop is only spliced when it is unambiguously inside exactly
/// one coplanar face; anything else is left alone as an honest open boundary rather than guessed
/// at. A wrong repair welds a surface shut that was meant to be open, and that is far worse than
/// an opening someone can see is missing.
/// </summary>
public static class MeshHoleRepair
{
/// <summary>How far off a face's plane a boundary loop may sit and still be counted as lying
/// in it. The cut's mouth is exactly coplanar in exact arithmetic and a few ulps off in
/// practice, so this is a float-drift tolerance and not a modelling one.</summary>
const float PlaneTolerance = 1e-3f;
/// <summary>Cosine limit for "the loop's plane and the face's plane are the same plane".
/// Compared on the absolute dot, because the loop bounds a hole and is wound against the face
/// that will contain it.</summary>
const float NormalTolerance = 0.999f;
/// <summary>
/// Splice every unambiguous boundary loop into the face that contains it. Returns how many
/// were closed, so a caller can report having done something rather than guessing.
/// </summary>
public static int CloseBoundaryLoopsIntoFaces( PolyMesh mesh )
{
if ( mesh is null || mesh.FaceCount == 0 )
return 0;
var loops = BoundaryLoops( mesh );
if ( loops.Count == 0 )
return 0;
var closed = 0;
foreach ( var loop in loops )
{
if ( loop.Count < 3 )
continue;
var host = FindContainingFace( mesh, loop );
if ( host < 0 )
continue;
if ( SpliceIntoFace( mesh, host, loop ) )
closed++;
}
// Whatever is left is a mouth no single face contains. Most of those are a cut that landed
// where two coplanar faces meet, which MeshHoleRepairSpan closes by notching both rather than
// by loosening the containment test above - see that file for why loosening it would be wrong.
closed += MeshHoleRepairSpan.CloseLoopsSpanningFaces( mesh );
// And what is left after THAT is a mouth across more than two faces, or across faces that
// do not share a plane at all - a cut through a curved surface, or a second cut into a face
// a first repair already triangulated. MeshHoleRepairCurved handles both by giving each face
// the piece of the loop that lies in it, and checks its own work before keeping it.
closed += MeshHoleRepairCurved.CloseCurvedLoops( mesh );
// And last, the mouth that crosses nothing because the surface under it is already in
// pieces: a second cut into a face this repair triangulated the first time. That one is
// closed by taking the whole coplanar group as one region and putting the mouth in as one
// more hole - a bigger hammer than the three above, which is why it goes last.
closed += MeshHoleRepairFragment.CloseLoopsInFragments( mesh );
return closed;
}
/// <summary>
/// Chain the mesh's boundary edges into closed loops.
///
/// Boundary edges are the ones exactly one face uses. Walking them is only well defined when
/// each boundary vertex has exactly two of them — a vertex with four is two openings meeting at
/// a point, and there is no way to tell which pairs with which. Those are abandoned rather than
/// walked, for the same reason ShellOperation refuses openings that meet at a vertex.
/// </summary>
static List<List<int>> BoundaryLoops( PolyMesh mesh )
{
var atVertex = new Dictionary<int, List<int>>();
foreach ( var (key, faces) in mesh.BuildEdgeFaces() )
{
if ( faces.Count != 1 )
continue;
Link( key.A, key.B );
Link( key.B, key.A );
}
var loops = new List<List<int>>();
var used = new HashSet<EdgeKey>();
foreach ( var start in atVertex.Keys )
{
if ( atVertex[start].Count != 2 )
continue;
var loop = new List<int>();
var current = start;
var previous = -1;
while ( true )
{
loop.Add( current );
if ( !atVertex.TryGetValue( current, out var neighbours ) || neighbours.Count != 2 )
{
loop.Clear();
break;
}
var next = neighbours[0] == previous ? neighbours[1] : neighbours[0];
var edge = new EdgeKey( current, next );
if ( used.Contains( edge ) )
break;
used.Add( edge );
previous = current;
current = next;
if ( current == start )
break;
// A walk longer than the whole boundary is a bookkeeping fault, not a shape.
if ( loop.Count > used.Count + mesh.VertexCount )
{
loop.Clear();
break;
}
}
if ( loop.Count >= 3 )
loops.Add( loop );
}
return loops;
void Link( int from, int to )
{
if ( !atVertex.TryGetValue( from, out var list ) )
{
list = new List<int>( 2 );
atVertex[from] = list;
}
list.Add( to );
}
}
/// <summary>
/// The one face this loop is a hole in, or -1.
///
/// Coplanar, containing, and UNIQUE. Two candidate faces means the answer is a guess, and a
/// guess here silently seals a surface the wrong way — so it declines instead.
/// </summary>
static int FindContainingFace( PolyMesh mesh, List<int> loop )
{
var loopNormal = LoopNormal( mesh, loop );
if ( loopNormal.LengthSquared < 1e-20f )
return -1;
loopNormal = loopNormal.Normal;
var found = -1;
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
var face = mesh.Faces[fi];
if ( face.Count < 3 )
continue;
// A face that already contains one of the loop's vertices is the wall the loop came
// from, not the surface it is a hole in.
if ( SharesAnyVertex( face, loop ) )
continue;
var normal = mesh.FaceNormal( face );
if ( MathF.Abs( Vec3.Dot( normal, loopNormal ) ) < NormalTolerance )
continue;
var centroid = mesh.FaceCentroid( face );
if ( MathF.Abs( Vec3.Dot( mesh.Positions[loop[0]] - centroid, normal ) ) > PlaneTolerance )
continue;
if ( !LoopIsInsideFace( mesh, face, normal, loop ) )
continue;
// Two candidates and there is no way to choose. Leave it open.
if ( found >= 0 )
return -1;
found = fi;
}
return found;
}
static bool SharesAnyVertex( Face face, List<int> loop )
{
foreach ( var index in face.Indices )
{
if ( loop.Contains( index ) )
return true;
}
return false;
}
/// <summary>Newell normal of a loop of mesh vertices — the plane it bounds, whichever way it
/// happens to be wound.</summary>
static Vec3 LoopNormal( PolyMesh mesh, List<int> loop )
{
var n = new Vec3( 0, 0, 0 );
for ( var i = 0; i < loop.Count; i++ )
{
var a = mesh.Positions[loop[i]];
var b = mesh.Positions[loop[(i + 1) % loop.Count]];
n = new Vec3(
n.x + (a.y - b.y) * (a.z + b.z),
n.y + (a.z - b.z) * (a.x + b.x),
n.z + (a.x - b.x) * (a.y + b.y) );
}
return n;
}
/// <summary>Every point of the loop inside the face's outline, tested in the face's own plane.
/// All of them rather than one, so a loop straddling an edge is refused.</summary>
static bool LoopIsInsideFace( PolyMesh mesh, Face face, Vec3 normal, List<int> loop )
{
Basis( normal, out var u, out var v );
var outline = new List<Vec2>( face.Count );
foreach ( var index in face.Indices )
outline.Add( Flatten( mesh.Positions[index], u, v ) );
foreach ( var index in loop )
{
if ( !PointInPolygon( outline, Flatten( mesh.Positions[index], u, v ) ) )
return false;
}
return true;
}
/// <summary>Replace the face with a triangulation of itself carrying the loop as a hole.</summary>
static bool SpliceIntoFace( PolyMesh mesh, int faceIndex, List<int> loop )
{
var face = mesh.Faces[faceIndex];
var normal = mesh.FaceNormal( face );
Basis( normal, out var u, out var v );
var outer = new List<Vec2>( face.Count );
foreach ( var index in face.Indices )
outer.Add( Flatten( mesh.Positions[index], u, v ) );
var hole = new List<Vec2>( loop.Count );
foreach ( var index in loop )
hole.Add( Flatten( mesh.Positions[index], u, v ) );
var triangles = Triangulate.WithHoles( outer, new List<IReadOnlyList<Vec2>> { hole } );
if ( triangles.Count == 0 )
return false;
// WithHoles indexes outer first, then each hole in order — the contract its own callers in
// SketchFeatures rely on, so the mapping back is positional and needs no search.
var combined = new int[face.Count + loop.Count];
for ( var i = 0; i < face.Count; i++ )
combined[i] = face.Indices[i];
for ( var i = 0; i < loop.Count; i++ )
combined[face.Count + i] = loop[i];
var material = face.Material;
// The old face goes; its triangles take its place. Removing before adding keeps the face
// list free of a stale polygon that would claim the same edges.
mesh.Faces.RemoveAt( faceIndex );
foreach ( var (a, b, c) in triangles )
{
var ia = combined[a];
var ib = combined[b];
var ic = combined[c];
if ( ia == ib || ib == ic || ia == ic )
continue;
mesh.AddFace( new[] { ia, ib, ic }, null, material );
}
return true;
}
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;
}
}