Editor/Effigy/Sketch/FaceSurface.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// The flat SURFACE a face belongs to, rather than the one n-gon a raycast happened to land on.
///
/// WHY THIS EXISTS. A Face is a unit of mesh storage; a surface is what somebody looking at the
/// model calls "that face". They agree on a primitive and stop agreeing the moment anything cuts
/// the model: a boolean routinely returns one wall as dozens of coplanar triangles and quads that
/// share a plane, a normal and a material, and CoplanarMerge's own header records eighty-eight of
/// them on a real part. Everything that pointed at Faces[i] then pointed at one eighty-eighth of a
/// wall — the hover highlight lit a triangle, the edge picker offered the triangulation seams as
/// though they were edges of the part, and the sketch grid that DID work on the whole wall
/// disagreed with both of them on screen at the same time.
///
/// CoplanarMerge fixes this in the mesh where it can, and REFUSES where it cannot — a group whose
/// boundary does not chain into clean loops is left fragmented on purpose, because a wrong merge
/// is a self-intersecting face that passes every validator. So fragments survive into the viewport
/// by design, and the viewport needs its own answer rather than a promise that they never happen.
/// This is that answer, and it is read-only: nothing here touches the mesh.
///
/// THE IDENTITY IS CoplanarMerge'S, DELIBERATELY. Same plane, same facing, same material slot,
/// reachable across shared edges. Copying the rule rather than inventing a looser one is what
/// keeps "what lit up when I hovered" and "what got painted when I clicked" the same set of
/// triangles — two nearly-identical rules that drift apart show up as a highlight covering more
/// than the click does, which is the kind of bug nobody can describe.
///
/// EDGE-CONNECTED, WHICH A PLANE TEST ALONE IS NOT. Two towers standing on a slab have coplanar
/// tops; a rule that only asked "is it in this plane" would hand back both of them as one surface
/// and outline a face the cursor is nowhere near. Reachability is what makes the answer local.
/// </summary>
public sealed class FaceSurface
{
public readonly PolyMesh Mesh;
/// <summary>The face this was grown from — always a member, whatever else joins it.</summary>
public readonly int Seed;
/// <summary>Outward normal of the surface, taken from the seed face.</summary>
public readonly Vec3 Normal;
/// <summary>A point on the surface: the seed face's centroid.</summary>
public readonly Vec3 Origin;
/// <summary>Every face of the surface, in ascending index order so two identical calls hand
/// back an identically-ordered list. Anything holding a position in this list across a frame
/// is otherwise holding a number that means something else next frame.</summary>
public readonly List<int> Faces = new();
/// <summary>
/// The surface's silhouette: the edges used exactly ONCE across its faces, as mesh vertex
/// index pairs. That is precisely the outline including the rim of any hole through it, and it
/// excludes every interior seam for free — an edge between two fragments of one surface is
/// used twice and drops out.
/// </summary>
public readonly List<(int A, int B)> Boundary = new();
FaceSurface( PolyMesh mesh, int seed, Vec3 normal, Vec3 origin )
{
Mesh = mesh;
Seed = seed;
Normal = normal;
Origin = origin;
}
public bool IsEmpty => Faces.Count == 0;
/// <summary>Whether a face index is part of this surface. Linear over a list that is one face
/// long in the ordinary case and a few dozen at worst.</summary>
public bool Contains( int faceIndex ) => Faces.Contains( faceIndex );
/// <summary>The two endpoints of a boundary edge, in model space. Bounds-checked to a
/// zero-length segment rather than throwing: this is drawn every frame from indices a rebuild
/// underneath could have invalidated, and a viewport that throws once per frame is worse than
/// one that briefly draws nothing.</summary>
public (Vec3 A, Vec3 B) Segment( int index )
{
if ( Mesh is null || index < 0 || index >= Boundary.Count )
return (Vec3.Zero, Vec3.Zero);
var (a, b) = Boundary[index];
if ( a < 0 || a >= Mesh.Positions.Count || b < 0 || b >= Mesh.Positions.Count )
return (Vec3.Zero, Vec3.Zero);
return (Mesh.Positions[a], Mesh.Positions[b]);
}
/// <summary>
/// The nearest boundary edge to a point, which is the one an edge pick should offer.
///
/// SEAMS ARE NOT CANDIDATES, and that is the whole reason this exists rather than a scan over
/// the hit face's own edges. A seam is an edge of the mesh but not an edge of the part: it sits
/// in the middle of a flat surface, filleting along it does nothing, and offering it lit up in
/// pick blue is a promise the model cannot keep.
/// </summary>
public bool TryClosestEdge( Vec3 point, out EdgeKey key, out float distance )
{
key = default;
distance = float.MaxValue;
var found = false;
for ( var i = 0; i < Boundary.Count; i++ )
{
var (a, b) = Segment( i );
var ab = b - a;
var lengthSquared = ab.LengthSquared;
if ( lengthSquared < 1e-20f )
continue;
var t = Vec3.Dot( point - a, ab ) / lengthSquared;
t = t < 0f ? 0f : t > 1f ? 1f : t;
var d = (a + ab * t - point).Length;
if ( d >= distance )
continue;
distance = d;
key = new EdgeKey( Boundary[i].A, Boundary[i].B );
found = true;
}
return found;
}
/// <summary>Cosine limit for "these two faces face the same way". CoplanarMerge's number, and
/// signed rather than absolute for CoplanarMerge's reason: the two sides of a zero-thickness
/// sliver are never one surface.</summary>
const float NormalTolerance = 0.9995f;
/// <summary>
/// Grow the surface containing <paramref name="faceIndex"/>.
///
/// Never fails: a degenerate or out-of-range seed comes back empty, and a seed whose
/// neighbours all disagree comes back as itself alone. That matters more than it sounds —
/// every caller here is a draw call, and "no surface" has to mean "draw the one face" rather
/// than "draw nothing on the thing under the cursor".
/// </summary>
public static FaceSurface FromFace( PolyMesh mesh, int faceIndex ) =>
mesh is null
? new FaceSurface( null, -1, new Vec3( 0, 0, 1 ), Vec3.Zero )
: FromFace( SurfaceIndex.Build( mesh ), faceIndex );
/// <summary>
/// <see cref="FromFace(PolyMesh, int)"/> against groundwork that has already been done.
///
/// THIS IS THE OVERLOAD HOVER SHOULD CALL. The other one builds a <see cref="SurfaceIndex"/>
/// and throws it away, which is three passes over the whole mesh for an answer about one face
/// - fine for a one-off, ruinous at the frame rate. See SurfaceIndex's header.
/// </summary>
public static FaceSurface FromFace( SurfaceIndex index, int faceIndex )
{
if ( index is null )
throw new ArgumentNullException( nameof( index ) );
var mesh = index.Mesh;
if ( faceIndex < 0 || faceIndex >= mesh.Faces.Count )
return new FaceSurface( mesh, -1, new Vec3( 0, 0, 1 ), Vec3.Zero );
var seed = mesh.Faces[faceIndex];
if ( seed.Count < 3 )
return new FaceSurface( mesh, faceIndex, new Vec3( 0, 0, 1 ), Vec3.Zero );
var normal = mesh.FaceNormal( seed );
var origin = mesh.FaceCentroid( seed );
var surface = new FaceSurface( mesh, faceIndex, normal, origin );
var tolerance = index.Tolerance;
var members = new HashSet<int> { faceIndex };
var queue = new Queue<int>();
queue.Enqueue( faceIndex );
while ( queue.Count > 0 )
{
var current = mesh.Faces[queue.Dequeue()];
for ( var c = 0; c < current.Count; c++ )
{
var touching = index.FacesOn( index.EdgeAt( current, c ) );
for ( var t = 0; t < touching.Length; t++ )
{
var candidate = touching[t];
if ( members.Contains( candidate ) )
continue;
if ( !SameSurface( mesh, mesh.Faces[candidate], seed, normal, origin, tolerance ) )
continue;
members.Add( candidate );
queue.Enqueue( candidate );
}
}
}
foreach ( var member in members )
surface.Faces.Add( member );
surface.Faces.Sort();
surface.BuildBoundary( index );
return surface;
}
/// <summary>Whether a face joins the surface: same material slot, same facing, and every corner
/// in the seed's plane. Flatness is measured against the SEED rather than against each
/// neighbour in turn, so a barely-curved tessellation cannot creep around a cylinder one
/// tolerance at a time.</summary>
static bool SameSurface( PolyMesh mesh, Face candidate, Face seed, Vec3 normal, Vec3 origin,
float tolerance )
{
if ( candidate.Count < 3 || candidate.Material != seed.Material )
return false;
if ( Vec3.Dot( mesh.FaceNormal( candidate ), normal ) < NormalTolerance )
return false;
for ( var c = 0; c < candidate.Count; c++ )
{
if ( MathF.Abs( Vec3.Dot( mesh.Positions[candidate.Indices[c]] - origin, normal ) ) > tolerance )
return false;
}
return true;
}
/// <summary>
/// Collect the edges used once across the surface's faces.
///
/// Counted on WELDED keys and reported as the mesh indices that produced them, so a caller can
/// read positions straight out of the mesh without knowing welding happened. Faces are walked
/// in index order rather than over the dictionary, because dictionary order is not promised and
/// an outline whose edges renumber between two identical calls makes every index a caller is
/// holding meaningless.
/// </summary>
void BuildBoundary( SurfaceIndex adjacency )
{
var uses = new Dictionary<EdgeKey, int>();
var first = new Dictionary<EdgeKey, (int A, int B)>();
foreach ( var index in Faces )
{
var face = Mesh.Faces[index];
for ( var c = 0; c < face.Count; c++ )
{
var key = adjacency.EdgeAt( face, c );
uses[key] = uses.TryGetValue( key, out var n ) ? n + 1 : 1;
if ( !first.ContainsKey( key ) )
first[key] = (face.Indices[c], face.Indices[(c + 1) % face.Count]);
}
}
var taken = new HashSet<EdgeKey>();
foreach ( var index in Faces )
{
var face = Mesh.Faces[index];
for ( var c = 0; c < face.Count; c++ )
{
var key = adjacency.EdgeAt( face, c );
if ( uses[key] != 1 || !taken.Add( key ) )
continue;
var (a, b) = first[key];
if ( a != b )
Boundary.Add( (a, b) );
}
}
}
}