Editor/Effigy/MeshHoleRepairSpan.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// Closing a mouth that lies across TWO faces rather than inside one.
///
/// WHAT WAS DECLINED AND WHY. `MeshHoleRepair.FindContainingFace` wants one coplanar face that
/// contains the whole loop, uniquely — and it is right to, because a guess there seals a surface the
/// wrong way and the result is closed, manifold and wrong. But a cut that lands where two coplanar
/// faces meet has a mouth in both of them and no single containing face exists, so the repair walked
/// away and the opening stayed open. A cut meeting an edge so the mouth spans two faces
/// needs the loop split where it crosses that edge.
///
/// THE FIX IS A DETOUR, NOT A PATCH. Each face keeps its own boundary; where that boundary runs
/// along the shared edge, it detours around the half of the mouth on its side. A left quad whose
/// edge runs (0,-2) to (0,2) with a mouth crossing at (0,-1) and (0,1) becomes:
///
/// ... (0,-2) -> (0,-1) -> [the arc through the left half of the mouth] -> (0,1) -> (0,2) ...
///
/// which is one notched face, not a face plus a patch. Nothing is added, nothing is triangulated,
/// and the two faces still meet along what is left of their shared edge.
///
/// WHAT IT STILL DECLINES, and deliberately:
///
/// - a loop crossing a face boundary somewhere that is not a vertex of the loop. The crossing point
/// has to already exist as a vertex, because inventing one means splitting a face this repair was
/// not asked to touch and cannot see the consequences of.
/// - a loop entering and leaving one face more than once. That is a face with two notches or a
/// notch and a hole, and telling those apart needs the containment test the single-face path
/// already does properly.
/// - anything non-planar. A mouth on a curved surface is a different problem and is still open.
/// </summary>
public static class MeshHoleRepairSpan
{
const float PlaneTolerance = 1e-4f;
const float NormalTolerance = 0.999f;
/// <summary>
/// Close every boundary loop that straddles exactly two coplanar faces. Returns how many.
///
/// Run AFTER the single-face repair, so it only ever sees what that one declined.
/// </summary>
public static int CloseLoopsSpanningFaces( PolyMesh mesh )
{
if ( mesh is null || mesh.FaceCount == 0 )
return 0;
var closed = 0;
// Re-derived each time round: closing one loop changes the faces, and a stale list would
// splice the next loop into a face that no longer looks like that.
while ( true )
{
var loops = BoundaryLoops( mesh );
var progressed = false;
foreach ( var loop in loops )
{
if ( loop.Count < 3 || !TryCloseAcross( mesh, loop ) )
continue;
closed++;
progressed = true;
break;
}
if ( !progressed )
return closed;
}
}
static bool TryCloseAcross( PolyMesh mesh, List<int> loop )
{
var normal = LoopNormal( mesh, loop );
if ( normal.LengthSquared < 1e-20f )
return false;
normal = normal.Normal;
Basis( normal, out var u, out var v );
var plane = Vec3.Dot( mesh.Positions[loop[0]], normal );
var candidates = new List<int>();
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
var face = mesh.Faces[fi];
if ( face.Count < 3 )
continue;
var faceNormal = mesh.FaceNormal( face );
if ( faceNormal.LengthSquared < 1e-20f )
continue;
if ( MathF.Abs( Vec3.Dot( faceNormal.Normal, normal ) ) < NormalTolerance )
continue;
if ( MathF.Abs( Vec3.Dot( mesh.Positions[face.Indices[0]], normal ) - plane ) > PlaneTolerance )
continue;
// A face already using a loop vertex as one of its own corners is the wall the loop came
// from, not the surface it is a mouth in.
if ( SharesAnyVertex( face, loop ) )
continue;
candidates.Add( fi );
}
// Exactly two. One is the single-face case, which the other repair does properly; three or
// more is a mouth crossing a corner, and which face owns which arc stops being obvious.
if ( candidates.Count != 2 )
return false;
var first = candidates[0];
var second = candidates[1];
if ( !SplitAcross( mesh, loop, first, second, u, v, out var arcs ) )
return false;
// Both notches built before either is written. A half-applied repair leaves a mesh that is
// worse than the one that came in, and this is exactly where that could happen.
if ( !BuildNotched( mesh, first, arcs.First, u, v, out var firstFace ) )
return false;
if ( !BuildNotched( mesh, second, arcs.Second, u, v, out var secondFace ) )
return false;
mesh.Faces[first] = firstFace;
mesh.Faces[second] = secondFace;
return true;
}
/// <summary>
/// Cut the loop into the two arcs that belong to the two faces.
///
/// The joins are the loop vertices that sit on BOTH faces' boundaries — the points where the
/// mouth crosses the shared edge. There have to be exactly two of them; one means the loop only
/// touches the edge, and more means it weaves back and forth, which is the case this declines.
/// </summary>
static bool SplitAcross( PolyMesh mesh, List<int> loop, int a, int b, Vec3 u, Vec3 v,
out (List<int> First, List<int> Second) arcs )
{
arcs = (null, null);
var polygonA = Polygon( mesh, mesh.Faces[a], u, v );
var polygonB = Polygon( mesh, mesh.Faces[b], u, v );
var joins = new List<int>();
var side = new int[loop.Count];
for ( var i = 0; i < loop.Count; i++ )
{
var p = Flatten( mesh.Positions[loop[i]], u, v );
var onA = OnBoundary( polygonA, p );
var onB = OnBoundary( polygonB, p );
if ( onA && onB )
{
joins.Add( i );
side[i] = 0;
continue;
}
var inA = PointInPolygon( polygonA, p );
var inB = PointInPolygon( polygonB, p );
// A vertex in neither face means the loop leaves the two faces entirely, and a vertex in
// both that is not on a shared boundary means they overlap - neither is this case.
if ( inA == inB )
return false;
side[i] = inA ? 1 : 2;
}
if ( joins.Count != 2 )
return false;
var firstArc = Arc( loop, side, joins[0], joins[1] );
var secondArc = Arc( loop, side, joins[1], joins[0] );
if ( firstArc is null || secondArc is null )
return false;
// One arc per face, sorted by which side its interior vertices sat on.
var firstSide = InteriorSide( side, loop.Count, joins[0], joins[1] );
var secondSide = InteriorSide( side, loop.Count, joins[1], joins[0] );
if ( firstSide == 0 || secondSide == 0 || firstSide == secondSide )
return false;
arcs = firstSide == 1 ? (firstArc, secondArc) : (secondArc, firstArc);
return true;
}
/// <summary>The run of loop vertices from one join to the next, inclusive of both.</summary>
static List<int> Arc( List<int> loop, int[] side, int from, int to )
{
var arc = new List<int>();
var i = from;
for ( var guard = 0; guard <= loop.Count; guard++ )
{
arc.Add( loop[i] );
if ( i == to && arc.Count > 1 )
return arc;
i = (i + 1) % loop.Count;
}
return null;
}
/// <summary>Which face the vertices strictly between two joins belong to, or 0 if they disagree.</summary>
static int InteriorSide( int[] side, int count, int from, int to )
{
var found = 0;
for ( var i = (from + 1) % count; i != to; i = (i + 1) % count )
{
if ( side[i] == 0 )
continue;
if ( found == 0 )
found = side[i];
else if ( found != side[i] )
return 0;
}
return found;
}
/// <summary>
/// The face, re-walked with the arc spliced in where its boundary passes the mouth.
///
/// The arc's two ends lie ON one of the face's edges, so the new boundary follows the face until
/// it reaches that edge, detours along the arc, and picks the face up again. The arc is taken in
/// whichever direction leaves the face wound the way it already was — a notch that reverses the
/// winding is a face pointing into the solid, which renders black and looks fine in wireframe.
/// </summary>
static bool BuildNotched( PolyMesh mesh, int faceIndex, List<int> arc, Vec3 u, Vec3 v, out Face result )
{
result = null;
var face = mesh.Faces[faceIndex];
var start = arc[0];
var end = arc[^1];
var startEdge = EdgeCarrying( mesh, face, start, u, v );
var endEdge = EdgeCarrying( mesh, face, end, u, v );
if ( startEdge < 0 || endEdge < 0 || startEdge != endEdge )
return false;
var a = Flatten( mesh.Positions[face.Indices[startEdge]], u, v );
var from = Flatten( mesh.Positions[start], u, v );
var to = Flatten( mesh.Positions[end], u, v );
// Which end of the arc the face's own winding reaches first along that edge.
var startFirst = (from - a).LengthSquared <= (to - a).LengthSquared;
var walk = new List<int>( face.Count + arc.Count );
for ( var i = 0; i < face.Count; i++ )
{
walk.Add( face.Indices[i] );
if ( i != startEdge )
continue;
if ( startFirst )
{
walk.AddRange( arc );
}
else
{
for ( var j = arc.Count - 1; j >= 0; j-- )
walk.Add( arc[j] );
}
}
// A repeated index means the arc met the face at a corner it already owns, and the polygon
// would pinch there. Refuse rather than emit a face that touches itself.
var seen = new HashSet<int>();
foreach ( var index in walk )
{
if ( !seen.Add( index ) )
return false;
}
result = new Face( walk.ToArray(), NewUVs( mesh, face, walk, u, v ), face.Material );
return true;
}
/// <summary>
/// UVs for the rebuilt face, carried across rather than reset.
///
/// The face's own corners keep the UVs they had. The arc's vertices are new to this face and get
/// theirs by the same planar mapping the original corners already follow, so a notch does not
/// smear the texture across the surface it was cut into.
/// </summary>
static Vec2[] NewUVs( PolyMesh mesh, Face face, List<int> walk, Vec3 u, Vec3 v )
{
var known = new Dictionary<int, Vec2>();
for ( var i = 0; i < face.Count; i++ )
known[face.Indices[i]] = face.UVs[i];
// Two corners are enough to fix a linear map from plane coordinates to UV, provided they are
// not the same point. Anything less and the face was degenerate before this touched it.
var uvs = new Vec2[walk.Count];
var origin = Flatten( mesh.Positions[face.Indices[0]], u, v );
var originUv = face.UVs[0];
var scaleU = 0f;
var scaleV = 0f;
for ( var i = 1; i < face.Count; i++ )
{
var p = Flatten( mesh.Positions[face.Indices[i]], u, v ) - origin;
var duv = new Vec2( face.UVs[i].x - originUv.x, face.UVs[i].y - originUv.y );
if ( MathF.Abs( p.x ) > 1e-6f && scaleU == 0f )
scaleU = duv.x / p.x;
if ( MathF.Abs( p.y ) > 1e-6f && scaleV == 0f )
scaleV = duv.y / p.y;
}
for ( var i = 0; i < walk.Count; i++ )
{
if ( known.TryGetValue( walk[i], out var existing ) )
{
uvs[i] = existing;
continue;
}
var p = Flatten( mesh.Positions[walk[i]], u, v ) - origin;
uvs[i] = new Vec2( originUv.x + p.x * scaleU, originUv.y + p.y * scaleV );
}
return uvs;
}
/// <summary>Index of the face edge this point lies on, or -1.</summary>
static int EdgeCarrying( PolyMesh mesh, Face face, int vertex, Vec3 u, Vec3 v )
{
var p = Flatten( mesh.Positions[vertex], u, v );
for ( var i = 0; i < face.Count; i++ )
{
var a = Flatten( mesh.Positions[face.Indices[i]], u, v );
var b = Flatten( mesh.Positions[face.Indices[(i + 1) % face.Count]], u, v );
if ( OnSegment( a, b, p ) )
return i;
}
return -1;
}
static List<Vec2> Polygon( PolyMesh mesh, Face face, Vec3 u, Vec3 v )
{
var polygon = new List<Vec2>( face.Count );
foreach ( var index in face.Indices )
polygon.Add( Flatten( mesh.Positions[index], u, v ) );
return polygon;
}
static bool OnBoundary( List<Vec2> polygon, Vec2 p )
{
for ( var i = 0; i < polygon.Count; i++ )
{
if ( OnSegment( polygon[i], polygon[(i + 1) % polygon.Count], p ) )
return true;
}
return false;
}
static bool OnSegment( Vec2 a, Vec2 b, Vec2 p )
{
var along = b - a;
var length = along.Length;
if ( length < 1e-9f )
return (p - a).Length < 1e-5f;
var cross = along.x * (p.y - a.y) - along.y * (p.x - a.x);
if ( MathF.Abs( cross ) / length > 1e-4f )
return false;
var t = ((p.x - a.x) * along.x + (p.y - a.y) * along.y) / (length * length);
return t >= -1e-5f && t <= 1f + 1e-5f;
}
// --- shared with MeshHoleRepair, kept here so this file stands alone -------------------------
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 );
var next = -1;
foreach ( var candidate in atVertex[current] )
{
if ( candidate == previous || used.Contains( new EdgeKey( current, candidate ) ) )
continue;
next = candidate;
break;
}
if ( next < 0 )
break;
used.Add( new EdgeKey( current, next ) );
previous = current;
current = next;
if ( current == start )
break;
}
if ( loop.Count >= 3 && current == start )
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 );
}
}
static bool SharesAnyVertex( Face face, List<int> loop )
{
foreach ( var index in face.Indices )
{
if ( loop.Contains( index ) )
return true;
}
return false;
}
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(
(a.y - b.y) * (a.z + b.z),
(a.z - b.z) * (a.x + b.x),
(a.x - b.x) * (a.y + b.y) );
}
return n;
}
static void Basis( Vec3 normal, out Vec3 u, out Vec3 v )
{
var seed = MathF.Abs( normal.z ) < 0.9f ? new Vec3( 0, 0, 1 ) : new Vec3( 1, 0, 0 );
u = Vec3.Cross( seed, normal ).Normal;
v = Vec3.Cross( normal, u ).Normal;
}
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;
}
}