Editor/Effigy/Sketch/SurfaceIndex.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// The mesh-wide groundwork <see cref="FaceSurface.FromFace(SurfaceIndex, int)"/> needs, computed
/// once for a mesh instead of once per face.
///
/// WHY THIS WAS SPLIT OUT. Growing the surface under the cursor is a flood fill that visits the
/// surface and stops - a few dozen faces on a real part. Everything it needs in order to START,
/// though, is global: the weld tolerance comes from the mesh's bounds, the weld map compares every
/// vertex against its neighbours, and the adjacency it walks is built from every face. So the
/// local answer was costing three full passes over the mesh, and FromFace was called once per face
/// the cursor moved onto.
///
/// On a part out of the sketcher that is invisible. On a 500k-face import it is roughly a second
/// of work per newly hovered face, repeated, with the viewport frozen for each one - the tool
/// looks hung while the mouse is moving and recovers the moment it stops. The surface cache above
/// it did not help, because a cache keyed on the face you just moved onto misses by construction.
///
/// Held by whoever owns the mesh and dropped when the mesh instance changes. Bodies are rebuilt
/// from scratch on every edit, so a new PolyMesh IS the invalidation signal - the same bargain the
/// viewport's pick trees make.
/// </summary>
public sealed class SurfaceIndex
{
public readonly PolyMesh Mesh;
/// <summary>Vertex index to the index of the first vertex sharing its position. See
/// <see cref="FaceSurface"/> on why the surface walk is welded rather than indexed.</summary>
public readonly int[] Weld;
/// <summary>Plane-distance tolerance, scaled to the part's size.</summary>
public readonly float Tolerance;
// Welded edge to the faces using it, in compressed sparse row form: the dictionary gives an
// edge its slot, and that slot's faces are _items[_offsets[slot].._offsets[slot + 1]].
//
// A Dictionary<EdgeKey, List<int>> is the obvious shape and allocates a List plus its backing
// array for every edge in the mesh - about two million objects on a dense import, all of them
// holding two ints. This holds the same answer in three arrays.
readonly Dictionary<EdgeKey, int> _slots;
readonly int[] _offsets;
readonly int[] _items;
SurfaceIndex( PolyMesh mesh, int[] weld, float tolerance,
Dictionary<EdgeKey, int> slots, int[] offsets, int[] items )
{
Mesh = mesh;
Weld = weld;
Tolerance = tolerance;
_slots = slots;
_offsets = offsets;
_items = items;
}
/// <summary>The faces sharing a welded edge, as a view into the flat array. Empty if the edge
/// is not one of the mesh's.</summary>
public ReadOnlySpan<int> FacesOn( EdgeKey key )
{
if ( !_slots.TryGetValue( key, out var slot ) )
return ReadOnlySpan<int>.Empty;
return new ReadOnlySpan<int>( _items, _offsets[slot], _offsets[slot + 1] - _offsets[slot] );
}
/// <summary>The welded key for a face's corner-th edge.</summary>
public EdgeKey EdgeAt( Face face, int corner ) => new(
Weld[face.Indices[corner]],
Weld[face.Indices[corner + 1 == face.Count ? 0 : corner + 1]] );
public static SurfaceIndex Build( PolyMesh mesh )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
// Scaled to the part, for the reason every other tolerance in the sketcher is: a constant
// generous on a 100-unit block silently welds every vertex of a 0.1-unit one.
var tolerance = MathF.Max( mesh.BoundsDiagonal * 1e-4f, 1e-5f );
var weld = BuildWeld( mesh, tolerance );
// Pass one: give each distinct welded edge a slot, and count its faces.
//
// The slot each corner resolved to is REMEMBERED rather than looked up again below. The
// scatter pass needs the same answer for the same corners, and re-deriving it costs a
// second welded-key construction and a second dictionary probe for every corner in the
// mesh - four million of each on a dense import, which was most of this function. An int
// per corner is a few megabytes to not do that.
var cornerCount = 0;
for ( var i = 0; i < mesh.Faces.Count; i++ )
{
var face = mesh.Faces[i];
if ( face.Count >= 3 )
cornerCount += face.Count;
}
var slots = new Dictionary<EdgeKey, int>( cornerCount );
var cornerSlots = new int[cornerCount];
var counts = new List<int>( cornerCount );
var corner = 0;
for ( var i = 0; i < mesh.Faces.Count; i++ )
{
var face = mesh.Faces[i];
if ( face.Count < 3 )
continue;
for ( var c = 0; c < face.Count; c++ )
{
var key = new EdgeKey(
weld[face.Indices[c]],
weld[face.Indices[c + 1 == face.Count ? 0 : c + 1]] );
if ( slots.TryGetValue( key, out var slot ) )
{
counts[slot]++;
}
else
{
slot = counts.Count;
slots[key] = slot;
counts.Add( 1 );
}
cornerSlots[corner++] = slot;
}
}
var offsets = new int[counts.Count + 1];
for ( var s = 0; s < counts.Count; s++ )
offsets[s + 1] = offsets[s] + counts[s];
// Pass two: scatter, straight off the remembered slots. No hashing here at all.
var items = new int[offsets[counts.Count]];
var cursor = new int[counts.Count];
Array.Copy( offsets, cursor, counts.Count );
corner = 0;
for ( var i = 0; i < mesh.Faces.Count; i++ )
{
var face = mesh.Faces[i];
if ( face.Count < 3 )
continue;
for ( var c = 0; c < face.Count; c++ )
items[cursor[cornerSlots[corner++]]++] = i;
}
return new SurfaceIndex( mesh, weld, tolerance, slots, offsets, items );
}
/// <summary>
/// Vertex index to the index of the first vertex sharing its position, within
/// <paramref name="tolerance"/>.
///
/// A HASH GRID, AS BEFORE, BUT PROBED ONCE INSTEAD OF TWENTY-SEVEN TIMES.
///
/// The previous version made cells exactly one tolerance across, so a pair straddling a
/// boundary could be in any of the 26 neighbours and all 27 had to be looked up - 27 hashes of
/// a (int, int, int) tuple into a Dictionary of List buckets, per vertex. On a million-vertex
/// import that is 27 million probes and a couple of million List allocations, and it was two
/// seconds of the first hover.
///
/// Cells are four tolerances across here, and each vertex looks up only the cells its own
/// tolerance box actually touches. A box one tolerance wide inside a cell four wide is fully
/// contained unless it is near a face, so the overwhelming majority of vertices probe exactly
/// one cell and the worst case is eight. This is EXACT, not an approximation: the box is what
/// decides, so any cell that could hold a match is still visited.
///
/// The table itself is open-addressed over flat arrays with the cell coordinates packed into a
/// long, so there are no per-cell List objects and the whole structure is four allocations.
///
/// WHICH VERTEX OF A CLUSTER WINS can differ from the old version's answer when a vertex sits
/// within tolerance of two clusters that are not within tolerance of each other. That was
/// already decided by iteration order rather than by anything meaningful, and the case this is
/// actually for - a boolean leaving exactly coincident vertices behind - has no ambiguity in
/// it at all.
/// </summary>
static int[] BuildWeld( PolyMesh mesh, float tolerance )
{
var count = mesh.Positions.Count;
var weld = new int[count];
if ( count == 0 )
return weld;
var cell = MathF.Max( tolerance, 1e-6f ) * 4f;
var inverseCell = 1f / cell;
var toleranceSquared = tolerance * tolerance;
// Open addressing needs headroom or it degenerates into a linear scan; a power-of-two
// table at least twice the vertex count keeps the load factor under a half, which is where
// linear probing still behaves.
var capacity = 1;
while ( capacity < count * 2 ) capacity <<= 1;
var mask = capacity - 1;
var keys = new long[capacity];
var heads = new int[capacity];
var next = new int[count];
Array.Fill( heads, -1 );
for ( var i = 0; i < count; i++ )
{
var p = mesh.Positions[i];
weld[i] = i;
// The cells the tolerance box around p touches. Equal bounds - the common case - means
// one cell on that axis.
var minX = (int)MathF.Floor( (p.x - tolerance) * inverseCell );
var maxX = (int)MathF.Floor( (p.x + tolerance) * inverseCell );
var minY = (int)MathF.Floor( (p.y - tolerance) * inverseCell );
var maxY = (int)MathF.Floor( (p.y + tolerance) * inverseCell );
var minZ = (int)MathF.Floor( (p.z - tolerance) * inverseCell );
var maxZ = (int)MathF.Floor( (p.z + tolerance) * inverseCell );
var matched = false;
for ( var cx = minX; cx <= maxX && !matched; cx++ )
{
for ( var cy = minY; cy <= maxY && !matched; cy++ )
{
for ( var cz = minZ; cz <= maxZ && !matched; cz++ )
{
var slot = Find( keys, heads, Pack( cx, cy, cz ), mask );
if ( slot < 0 )
continue;
for ( var other = heads[slot]; other >= 0; other = next[other] )
{
if ( (mesh.Positions[other] - p).LengthSquared > toleranceSquared )
continue;
weld[i] = weld[other];
matched = true;
break;
}
}
}
}
if ( matched )
continue;
// Unmatched, so it becomes a representative and joins its own cell's chain.
var own = Insert( keys, heads, Pack(
(int)MathF.Floor( p.x * inverseCell ),
(int)MathF.Floor( p.y * inverseCell ),
(int)MathF.Floor( p.z * inverseCell ) ), mask );
next[i] = heads[own];
heads[own] = i;
}
return weld;
}
/// <summary>
/// Cell coordinates packed into one long, biased so negative coordinates keep their order and
/// stay distinct. 21 bits an axis covers a grid four million cells across, which at four
/// tolerances a cell is far more range than a model can use.
/// </summary>
static long Pack( int x, int y, int z )
{
var px = (long)(x + 1048576) & 0x1FFFFF;
var py = (long)(y + 1048576) & 0x1FFFFF;
var pz = (long)(z + 1048576) & 0x1FFFFF;
// Non-zero, so a cell at the origin does not hash to slot zero and pile up there.
return px | (py << 21) | (pz << 42) | 1L << 63;
}
/// <summary>Slot holding this key, or -1. Linear probing; an empty key is the terminator, and
/// Pack never returns zero so zero means empty.</summary>
static int Find( long[] keys, int[] heads, long key, int mask )
{
for ( var slot = Hash( key ) & mask; ; slot = (slot + 1) & mask )
{
var k = keys[slot];
if ( k == key ) return slot;
if ( k == 0 ) return -1;
}
}
/// <summary>Slot for this key, claiming an empty one if it is new. The table is sized so it
/// can never fill.</summary>
static int Insert( long[] keys, int[] heads, long key, int mask )
{
for ( var slot = Hash( key ) & mask; ; slot = (slot + 1) & mask )
{
var k = keys[slot];
if ( k == key ) return slot;
if ( k == 0 )
{
keys[slot] = key;
return slot;
}
}
}
/// <summary>Fibonacci hashing. The packed key's low bits are the x coordinate, so using them
/// directly would put a whole row of cells in consecutive slots and turn linear probing into a
/// scan; multiplying by the golden-ratio constant spreads them.</summary>
static int Hash( long key ) => (int)(((ulong)key * 11400714819323198485UL) >> 40) & int.MaxValue;
}