Effigy/Decimate.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// Quadric error metric decimation — the tool that takes triangles AWAY.
///
/// Everything else in this kernel adds density: Catmull-Clark multiplies it by four a level, the
/// sculpt subdivides to get somewhere to put detail, and a boolean splits faces to make the cut.
/// Nothing removed any, which was survivable while every body started as a primitive and stopped
/// being survivable the moment <see cref="ImportFeature"/> landed. A generated mesh — Meshy, a
/// photogrammetry scan, a sculpt somebody else finished — arrives at hundreds of thousands of
/// triangles because the generator had no reason to care, and at that size the mesh is not a cage:
/// you cannot subdivide it, you cannot sculpt on it, weight painting is a slideshow, and the
/// exporter writes a model nothing will load. The import is the shape you wanted and the wrong
/// number of triangles, and until now the tool had no answer to the second half.
///
/// GARLAND AND HECKBERT, 1997, and deliberately nothing cleverer. The cost of collapsing an edge is
/// the squared distance from the surviving vertex to the planes of every triangle the two endpoints
/// touched, which is a quadratic form and therefore a symmetric 4x4 matrix you can ADD — that is
/// the whole trick. Merge two vertices and their error matrices sum, so a vertex that has already
/// absorbed forty of its neighbours still remembers all forty original planes at the cost of one
/// matrix. Greedy, cheap per step, and it keeps silhouettes because a vertex on a crease has a
/// quadric that is expensive to move in two directions at once.
///
/// WHAT COMES OUT IS TRIANGLES. Even where quads went in. That is a real loss — see PolyMesh's note
/// on why every primitive here is quad-dominant, and the rig's on quads deforming where triangles
/// pinch — and it is why this is not a general "make it simpler" button to reach for on a CAD part.
/// Quad retopology is a different and much larger problem, and the honest thing is to say so rather
/// than to ship a triangle soup that claims to be a cage. Use this on the dense import it was
/// written for; build the cage the CAD way.
///
/// THINGS IT REFUSES TO DO, each of which is a way a naive collapse loop wrecks a mesh:
///
/// - <b>Punch through the surface.</b> The link condition (see <see cref="LinkConditionHolds"/>)
/// rejects a collapse that would weld two parts of the mesh that were only near each other, which
/// is what turns a manifold into a non-manifold and what makes the result unexportable.
/// - <b>Turn a triangle inside out.</b> Moving a vertex can flip a neighbour it was not part of the
/// decision about. Every surviving triangle is checked against its own old normal first.
/// - <b>Eat the border.</b> A boundary edge — one triangle, not two — and a material seam both get
/// a constraint plane standing perpendicular to the surface, so sliding along the border is cheap
/// and moving off it is not. An open part keeps its opening the shape it was.
///
/// It is a Feature, not a post-process, for the reason <see cref="SubdivideFeature"/> is: the
/// history has to stay rollable. Roll above the Remesh and the dense import is still there to
/// re-target at a different budget.
/// </summary>
public static class Decimate
{
/// <summary>What to aim for and what to protect. Defaults are the ones a dense import wants.</summary>
public sealed class Options
{
/// <summary>
/// Triangles to stop at. Zero means "use <see cref="Ratio"/> instead", which is the default
/// because a budget is only meaningful once you know what you started with.
/// </summary>
public int TargetTriangles;
/// <summary>Fraction of the input triangle count to keep, when <see cref="TargetTriangles"/>
/// is zero. 0.05 means "one twentieth of what came in".</summary>
public float Ratio = 0.5f;
/// <summary>
/// Weld vertices that share a position before starting.
///
/// ON BY DEFAULT BECAUSE AN UNWELDED MESH CANNOT BE DECIMATED AT ALL, and looks fine until
/// you try. Plenty of exporters write every triangle with its own three vertices; every edge
/// in that mesh has exactly one triangle, so every edge is a boundary, every collapse is
/// refused, and the operation returns the mesh unchanged with no error to explain itself.
/// Welding first is not a favour, it is the difference between working and quietly not.
///
/// It merges by POSITION only, so per-corner UVs and material seams survive it — that is
/// what per-corner UVs are for.
/// </summary>
public bool Weld = true;
/// <summary>
/// How close two positions have to be to weld, as a fraction of the mesh's bounds diagonal.
/// A fraction rather than a distance because Effigy's units are dimensionless — see
/// <see cref="PolyMesh.BoundsDiagonal"/>.
/// </summary>
public float WeldTolerance = 1e-5f;
/// <summary>
/// Hold open borders and material seams in place. Off, a decimated shell loses the shape of
/// its opening and a two-material part smears the line between them.
/// </summary>
public bool PreserveBoundary = true;
/// <summary>
/// Stop early if the cheapest collapse left costs more than this, even with the target
/// unmet. The unit is squared distance in model units, so it scales with the model and is
/// off by default: a target the user typed is a target, and silently stopping short of it
/// is worse than the extra error for the case this was written for.
/// </summary>
public float MaxError = float.MaxValue;
/// <summary>
/// How much a surviving triangle's normal may turn before the collapse is refused, as a
/// cosine. 0.2 lets a face rotate up to about 78 degrees, which sounds generous and is —
/// it is a check for INVERSION, not for accuracy, and the quadric is what keeps the surface
/// where it was. Tightening it toward 1 mostly stops the decimation early on a bumpy mesh.
/// </summary>
public float FlipThreshold = 0.2f;
}
/// <summary>What a run did, so a caller can report it rather than diff two meshes to find out.</summary>
public readonly struct Result
{
public readonly PolyMesh Mesh;
public readonly int FromTriangles;
public readonly int ToTriangles;
public readonly int Welded;
/// <summary>The cost of the most expensive collapse taken. Squared distance in model units;
/// compare it against the bounds diagonal to know whether it mattered.</summary>
public readonly float WorstError;
/// <summary>False when it ran out of legal collapses before reaching the target — the mesh
/// is as small as this algorithm can make it without breaking it.</summary>
public readonly bool ReachedTarget;
public Result( PolyMesh mesh, int from, int to, int welded, float worstError, bool reachedTarget )
{
Mesh = mesh;
FromTriangles = from;
ToTriangles = to;
Welded = welded;
WorstError = worstError;
ReachedTarget = reachedTarget;
}
}
/// <summary>The count of triangles a mesh of n-gons will decimate as. Faces are not triangles —
/// a quad cage of 500 faces is 1000 triangles — and a target typed against the wrong number is
/// off by a factor of two, so the UI asks this rather than counting faces.</summary>
public static int TriangleCount( PolyMesh mesh )
{
var count = 0;
foreach ( var f in mesh.Faces )
count += Math.Max( 0, f.Count - 2 );
return count;
}
/// <summary>Reduce to a triangle count. The short form of <see cref="Run"/> for callers with
/// nothing to protect and a number in mind.</summary>
public static PolyMesh ToTriangles( PolyMesh mesh, int targetTriangles ) =>
Run( mesh, new Options { TargetTriangles = targetTriangles } ).Mesh;
/// <summary>Reduce to a fraction of what came in.</summary>
public static PolyMesh ToRatio( PolyMesh mesh, float ratio ) =>
Run( mesh, new Options { Ratio = ratio } ).Mesh;
public static Result Run( PolyMesh mesh, Options options = null )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
options ??= new Options();
var work = new Work( mesh, options );
var from = work.LiveTriangles;
var target = Target( from, options );
var reached = work.Collapse( target );
return new Result( work.Build(), from, work.LiveTriangles, work.Welded, work.WorstError, reached );
}
/// <summary>
/// The triangle count to stop at. Never below 4 — three triangles cannot enclose anything, and
/// a target of zero asked for literally nothing rather than for "as small as possible".
/// </summary>
static int Target( int from, Options options )
{
var target = options.TargetTriangles > 0
? options.TargetTriangles
: (int)MathF.Round( from * Math.Clamp( options.Ratio, 0f, 1f ) );
return Math.Clamp( target, 4, from );
}
// --- the quadric --------------------------------------------------------------------------
/// <summary>
/// A symmetric 4x4 in its ten distinct entries, in doubles.
///
/// DOUBLES ON PURPOSE. The entries are sums of products of coordinates, so on a model a
/// thousand units across they are already around 10^6 before anything is added, and a vertex at
/// the end of a long collapse chain has summed a few hundred of them. In floats the error term
/// — which is a difference of large numbers that ought to cancel — comes out as noise, and the
/// symptom is a decimation that mysteriously prefers to eat the middle of flat regions.
/// </summary>
struct Quadric
{
public double A00, A01, A02, A03, A11, A12, A13, A22, A23, A33;
/// <summary>The fundamental error quadric of a plane, weighted. Outer product of
/// (a,b,c,d) with itself, which measures squared distance to that plane.</summary>
public static Quadric Plane( double a, double b, double c, double d, double weight )
{
return new Quadric
{
A00 = a * a * weight,
A01 = a * b * weight,
A02 = a * c * weight,
A03 = a * d * weight,
A11 = b * b * weight,
A12 = b * c * weight,
A13 = b * d * weight,
A22 = c * c * weight,
A23 = c * d * weight,
A33 = d * d * weight,
};
}
public void Add( in Quadric q )
{
A00 += q.A00; A01 += q.A01; A02 += q.A02; A03 += q.A03;
A11 += q.A11; A12 += q.A12; A13 += q.A13;
A22 += q.A22; A23 += q.A23;
A33 += q.A33;
}
/// <summary>v^T Q v — the squared distance to all the planes this quadric remembers.
/// Clamped at zero: it is a sum of squares and can only go negative through rounding.</summary>
public readonly double Error( double x, double y, double z )
{
var e = A00 * x * x + 2 * A01 * x * y + 2 * A02 * x * z + 2 * A03 * x
+ A11 * y * y + 2 * A12 * y * z + 2 * A13 * y
+ A22 * z * z + 2 * A23 * z
+ A33;
return e < 0 ? 0 : e;
}
/// <summary>
/// The point where the error is smallest, by solving the 3x3 gradient system. False when the
/// matrix is singular, which is the common case rather than the exotic one: it happens
/// wherever the planes do not pin the vertex in all three directions at once — anywhere flat,
/// and along any straight crease. The caller falls back to the endpoints there, which is
/// correct, because on a flat region every point on the edge is equally good.
/// </summary>
public readonly bool Solve( out double x, out double y, out double z )
{
// Cofactors of the upper-left 3x3.
var c00 = A11 * A22 - A12 * A12;
var c01 = A02 * A12 - A01 * A22;
var c02 = A01 * A12 - A02 * A11;
var det = A00 * c00 + A01 * c01 + A02 * c02;
// Scale-relative, because the entries grow with the square of the model's size and a
// fixed epsilon would call a large model singular and a small one well conditioned.
var scale = Math.Abs( A00 ) + Math.Abs( A11 ) + Math.Abs( A22 ) + 1e-30;
if ( Math.Abs( det ) < 1e-12 * scale * scale * scale )
{
x = y = z = 0;
return false;
}
var c11 = A00 * A22 - A02 * A02;
var c12 = A01 * A02 - A00 * A12;
var c22 = A00 * A11 - A01 * A01;
var inv = 1.0 / det;
// v = -A^-1 b, with b the (A03, A13, A23) column.
x = -inv * (c00 * A03 + c01 * A13 + c02 * A23);
y = -inv * (c01 * A03 + c11 * A13 + c12 * A23);
z = -inv * (c02 * A03 + c12 * A13 + c22 * A23);
return true;
}
}
// --- the heap -----------------------------------------------------------------------------
readonly struct Candidate
{
public readonly double Cost;
public readonly int A, B;
// The version of each endpoint when this cost was computed. A vertex bumps its version the
// moment it absorbs another, so an entry whose stored versions are behind is stale — its
// cost is no longer offered — and is dropped on pop rather than re-priced, because the fresh
// price has already been pushed by the collapse that bumped the version. This is the other
// half of the lazy-deletion trade: without it every stale pop is recomputed and re-pushed,
// which was growing the heap to several times the number of live edges.
public readonly int VersionA, VersionB;
public Candidate( double cost, int a, int b, int versionA, int versionB )
{
Cost = cost;
A = a;
B = b;
VersionA = versionA;
VersionB = versionB;
}
}
/// <summary>
/// A 4-ary min-heap over candidate edges.
///
/// Rolled here rather than taken from <c>PriorityQueue</c> so the kernel keeps compiling as
/// loose .cs files in whatever runtime it is pasted into — see the note in Effigy.Tests.csproj
/// about the kernel having no dependencies.
///
/// FOUR-ARY, NOT BINARY, because this heap is the single hottest object in a decimation: a dense
/// import pushes and pops it tens of millions of times. A d-ary heap trades comparisons against
/// levels — four children halves the depth at the cost of two more comparisons a level — and the
/// expensive half of a sift is the swap, which moves the whole candidate. Halving the swaps is
/// worth the extra compares.
///
/// STALE ENTRIES ARE LEFT IN IT. A collapse changes the cost of every edge around the surviving
/// vertex, and rewriting those in place would need a handle per edge and a decrease-key. The
/// fresh price is simply pushed; the stale one is recognised on pop by its version stamp and
/// dropped (see Candidate.VersionA), so the heap grows by a bounded multiple rather than needing
/// a book-keeping structure of its own.
/// </summary>
sealed class Heap
{
readonly List<Candidate> _items;
public Heap( int capacity ) => _items = new List<Candidate>( capacity );
public int Count => _items.Count;
public void Push( Candidate c )
{
_items.Add( c );
var i = _items.Count - 1;
while ( i > 0 )
{
var parent = (i - 1) >> 2;
if ( _items[parent].Cost <= _items[i].Cost )
break;
Swap( parent, i );
i = parent;
}
}
public Candidate Pop()
{
var top = _items[0];
var last = _items.Count - 1;
_items[0] = _items[last];
_items.RemoveAt( last );
var i = 0;
while ( true )
{
var first = (i << 2) + 1;
if ( first >= _items.Count )
break;
var end = first + 4 < _items.Count ? first + 4 : _items.Count;
var smallest = i;
for ( var c = first; c < end; c++ )
{
if ( _items[c].Cost < _items[smallest].Cost )
smallest = c;
}
if ( smallest == i )
break;
Swap( smallest, i );
i = smallest;
}
return top;
}
void Swap( int a, int b )
{
var t = _items[a];
_items[a] = _items[b];
_items[b] = t;
}
}
// --- the working mesh ---------------------------------------------------------------------
/// <summary>
/// The mesh flattened into the form the collapse loop can edit: triangles in a flat array,
/// vertices with adjacency, and nothing that has to stay consistent with a PolyMesh in between.
///
/// PolyMesh derives adjacency on demand and rebuilds it every time, which is exactly right for
/// the operations it was written for and exactly wrong for this one — half a million collapses
/// each needing the ring around two vertices. So the mesh is unpacked once, chewed, and packed
/// back up. See PolyMesh's own note on why it is not a half-edge structure; this is the profile
/// that note said would be the moment to reconsider, and the answer was to keep the working
/// copy local to the one algorithm that needs it rather than to change the mesh everything else
/// depends on.
/// </summary>
sealed class Work
{
readonly Options _options;
// Triangles, three vertex indices each. Dead triangles keep their slot and are skipped;
// compacting mid-run would invalidate every index in every adjacency list.
readonly List<int> _tri = new();
readonly List<int> _triMaterial = new();
readonly List<bool> _triAlive = new();
// Per corner, the UV of the source face's corner. Parallel to _tri, so corner 3*t+i.
readonly List<Vec2> _triUV = new();
readonly List<Vec3> _pos = new();
readonly List<bool> _alive = new();
readonly List<Quadric> _quadric = new();
readonly List<List<int>> _vertTris = new();
/// <summary>A vertex on an open border or a material seam. Its position is held: see
/// <see cref="Options.PreserveBoundary"/>.</summary>
readonly List<bool> _locked = new();
Vec4[] _colors;
SkinWeights _skin;
PaintCanvas _paint;
public int LiveTriangles;
public int Welded;
public float WorstError;
// Allocation-free "set" membership over vertices: _mark[v] records the stamp of the most
// recent pass that touched v, and _stamp is bumped per logical set. The collapse loop asks
// "is v in this link / this opposite set / already seen this round" several million times,
// and the HashSet version of the same questions was the dominant cost on a dense import —
// tens of millions of small allocations for the GC to chew through.
readonly int[] _mark;
int _stamp;
// Bumped whenever a vertex absorbs another, so a heap candidate can tell "my price is still
// the one on offer" from "the surviving vertex moved on". See Candidate.VersionA.
readonly int[] _version;
// The (at most two) vertices opposite a shared edge, collected during the link condition.
readonly List<int> _opposite = new();
public Work( PolyMesh mesh, Options options )
{
_options = options;
Unpack( mesh );
BuildQuadrics();
_mark = new int[_pos.Count];
_version = new int[_pos.Count];
}
int NextStamp()
{
_stamp++;
// Practically unreachable — one run bumps this a handful of times per collapse — but a
// wrapped stamp would silently collide with a still-marked vertex and fail the link
// condition. Reset the whole board rather than let that happen.
if ( _stamp == int.MaxValue )
{
Array.Clear( _mark, 0, _mark.Length );
_stamp = 1;
}
return _stamp;
}
// --- unpacking ------------------------------------------------------------------------
void Unpack( PolyMesh mesh )
{
var remap = Weld( mesh );
_colors = mesh.HasVertexColors ? new Vec4[_pos.Count] : null;
_skin = mesh.IsRigged ? new SkinWeights( _pos.Count ) : null;
_paint = mesh.Paint;
// The first source vertex to land on a welded position is the one whose colour and
// weights the merged vertex takes. Averaging them would be defensible and is not
// obviously better: coincident vertices in a real file are a seam, and the two sides of
// a seam usually agree about everything except the UV, which is per corner and survives.
var claimed = new bool[_pos.Count];
for ( var i = 0; i < mesh.Positions.Count; i++ )
{
var to = remap[i];
if ( claimed[to] )
continue;
claimed[to] = true;
if ( _colors is not null )
_colors[to] = mesh.VertexColors[i];
if ( _skin is not null )
_skin[to] = mesh.Skin[i];
}
for ( var i = 0; i < _pos.Count; i++ )
{
_alive.Add( true );
_locked.Add( false );
_quadric.Add( default );
_vertTris.Add( new List<int>() );
}
foreach ( var face in mesh.Faces )
{
if ( face.Count < 3 )
continue;
var corners = new Vec3[face.Count];
for ( var i = 0; i < face.Count; i++ )
corners[i] = mesh.Positions[face.Indices[i]];
foreach ( var (ia, ib, ic) in Triangulate.Face( corners ) )
{
var a = remap[face.Indices[ia]];
var b = remap[face.Indices[ib]];
var c = remap[face.Indices[ic]];
// Welding can make a sliver face degenerate. It contributed no area before
// either; carrying it would only give the link condition something to trip on.
if ( a == b || b == c || a == c )
continue;
AddTriangle( a, b, c, face.Material, face.UVs[ia], face.UVs[ib], face.UVs[ic] );
}
}
}
/// <summary>
/// Source vertex index to working vertex index, filling <see cref="_pos"/> as it goes.
/// Identity when welding is off.
/// </summary>
int[] Weld( PolyMesh mesh )
{
var remap = new int[mesh.Positions.Count];
if ( !_options.Weld )
{
for ( var i = 0; i < remap.Length; i++ )
{
remap[i] = i;
_pos.Add( mesh.Positions[i] );
}
return remap;
}
// Quantised to a grid rather than compared pairwise — an O(n^2) weld on 450k vertices
// is not a slow weld, it is a weld that never finishes. The grid means two points
// either side of a cell boundary are not welded, which is the standard and acceptable
// failure: the tolerance is a hundred-thousandth of the model and the vertices this is
// aimed at are bit-identical duplicates, not near misses.
var tolerance = MathF.Max( mesh.BoundsDiagonal * _options.WeldTolerance, 1e-9f );
var inverse = 1f / tolerance;
var seen = new Dictionary<(long, long, long), int>( mesh.Positions.Count );
for ( var i = 0; i < mesh.Positions.Count; i++ )
{
var p = mesh.Positions[i];
var key = (
(long)MathF.Round( p.x * inverse ),
(long)MathF.Round( p.y * inverse ),
(long)MathF.Round( p.z * inverse ));
if ( seen.TryGetValue( key, out var existing ) )
{
remap[i] = existing;
Welded++;
continue;
}
remap[i] = _pos.Count;
seen[key] = _pos.Count;
_pos.Add( p );
}
return remap;
}
void AddTriangle( int a, int b, int c, int material, Vec2 ua, Vec2 ub, Vec2 uc )
{
var t = _triAlive.Count;
_tri.Add( a ); _tri.Add( b ); _tri.Add( c );
_triUV.Add( ua ); _triUV.Add( ub ); _triUV.Add( uc );
_triMaterial.Add( material );
_triAlive.Add( true );
_vertTris[a].Add( t );
_vertTris[b].Add( t );
_vertTris[c].Add( t );
LiveTriangles++;
}
// --- quadrics -------------------------------------------------------------------------
void BuildQuadrics()
{
var quadrics = new Quadric[_pos.Count];
for ( var t = 0; t < _triAlive.Count; t++ )
{
var a = _tri[3 * t];
var b = _tri[3 * t + 1];
var c = _tri[3 * t + 2];
var normal = Vec3.Cross( _pos[b] - _pos[a], _pos[c] - _pos[a] );
var length = normal.Length;
if ( length < 1e-20f )
continue;
var n = normal / length;
var d = -Vec3.Dot( n, _pos[a] );
// Area weighting — half the cross product's length — so a big flat face counts for
// more than a sliver, exactly as it does in ComputeVertexNormals and for the same
// reason: a fan of slivers around a vertex should not outvote the surface it sits on.
var q = Quadric.Plane( n.x, n.y, n.z, d, length * 0.5f );
quadrics[a].Add( q );
quadrics[b].Add( q );
quadrics[c].Add( q );
}
if ( _options.PreserveBoundary )
AddConstraintPlanes( quadrics );
for ( var i = 0; i < quadrics.Length; i++ )
_quadric[i] = quadrics[i];
}
/// <summary>
/// A wall standing on every border, perpendicular to the surface.
///
/// A boundary vertex is held by the triangles on ONE side of it and by nothing on the other,
/// so its quadric says the cheapest thing it can do is slide off the edge — and it does,
/// which is how a decimated shell ends up with a ragged opening and a decimated two-material
/// part ends up with the seam wandering. The fix is Garland and Heckbert's own: for each
/// border edge, add the plane containing that edge and perpendicular to its triangle,
/// weighted heavily. Moving along the border stays free; leaving it becomes expensive.
///
/// A MATERIAL CHANGE IS A BORDER for this purpose, and so is a non-manifold edge. The first
/// because the line between two materials is a thing somebody chose and the mesh is the only
/// place it is recorded; the second because there is no sensible collapse across it and it is
/// cheaper to freeze it than to reason about it.
/// </summary>
void AddConstraintPlanes( Quadric[] quadrics )
{
var edges = new Dictionary<EdgeKey, (int Count, int Triangle, int Material, bool Mixed)>();
for ( var t = 0; t < _triAlive.Count; t++ )
{
for ( var i = 0; i < 3; i++ )
{
var key = new EdgeKey( _tri[3 * t + i], _tri[3 * t + (i + 1) % 3] );
if ( edges.TryGetValue( key, out var e ) )
{
edges[key] = (e.Count + 1, e.Triangle, e.Material,
e.Mixed || e.Material != _triMaterial[t]);
}
else
{
edges[key] = (1, t, _triMaterial[t], false);
}
}
}
foreach ( var (key, e) in edges )
{
if ( e.Count == 2 && !e.Mixed )
continue;
var a = _pos[key.A];
var b = _pos[key.B];
var along = b - a;
if ( along.LengthSquared < 1e-20f )
continue;
var t = e.Triangle;
var face = Vec3.Cross(
_pos[_tri[3 * t + 1]] - _pos[_tri[3 * t]],
_pos[_tri[3 * t + 2]] - _pos[_tri[3 * t]] );
if ( face.LengthSquared < 1e-20f )
continue;
// Perpendicular to the surface AND containing the edge: cross the edge direction
// with the face normal.
var n = Vec3.Cross( along, face ).Normal;
var d = -Vec3.Dot( n, a );
// Heavy enough that the border wins any argument with the surface, and finite so a
// border vertex can still slide ALONG the border, which is the whole point.
var weight = along.Length * 1000f;
var wall = Quadric.Plane( n.x, n.y, n.z, d, weight );
quadrics[key.A].Add( wall );
quadrics[key.B].Add( wall );
_locked[key.A] = true;
_locked[key.B] = true;
}
}
// --- the collapse loop ------------------------------------------------------------------
/// <summary>Runs until the target is met or nothing legal is left. True if it got there.</summary>
public bool Collapse( int target )
{
// Upper bound on the number of edges in a closed triangle mesh: three corners a triangle
// shared between two — so 1.5x the triangle count. Pre-sizing keeps the heap's backing
// array from doubling and copying itself several times over a long run.
var heap = new Heap( LiveTriangles + LiveTriangles / 2 );
foreach ( var key in LiveEdges() )
{
if ( TryCost( key.A, key.B, out var cost, out _ ) )
heap.Push( new Candidate( cost, key.A, key.B, _version[key.A], _version[key.B] ) );
}
while ( LiveTriangles > target && heap.Count > 0 )
{
var top = heap.Pop();
if ( !_alive[top.A] || !_alive[top.B] )
continue;
// A stale entry: one of its endpoints has absorbed a vertex since this price was
// taken. Its fresh price is already in the heap — the collapse that bumped the
// version re-priced every edge touching the survivor — so the stale one is dropped
// rather than recomputed and re-pushed.
if ( _version[top.A] != top.VersionA || _version[top.B] != top.VersionB )
continue;
if ( !TryCost( top.A, top.B, out var cost, out var to ) )
continue;
if ( cost > _options.MaxError )
break;
if ( !Apply( top.A, top.B, to ) )
continue;
if ( cost > WorstError )
WorstError = (float)cost;
// Every edge now touching the surviving vertex has a fresh cost. Pushed without an
// allocation: a stamp marks which neighbours have already been pushed this round.
var stamp = NextStamp();
var versionA = _version[top.A];
foreach ( var t in _vertTris[top.A] )
{
if ( !_triAlive[t] )
continue;
for ( var i = 0; i < 3; i++ )
{
var n = _tri[3 * t + i];
if ( n == top.A || _mark[n] == stamp )
continue;
_mark[n] = stamp;
if ( TryCost( top.A, n, out var updated, out _ ) )
heap.Push( new Candidate( updated, top.A, n, versionA, _version[n] ) );
}
}
}
return LiveTriangles <= target;
}
IEnumerable<EdgeKey> LiveEdges()
{
var seen = new HashSet<EdgeKey>();
for ( var t = 0; t < _triAlive.Count; t++ )
{
if ( !_triAlive[t] )
continue;
for ( var i = 0; i < 3; i++ )
{
var key = new EdgeKey( _tri[3 * t + i], _tri[3 * t + (i + 1) % 3] );
if ( seen.Add( key ) )
yield return key;
}
}
}
/// <summary>
/// Where the merged vertex would go and what it would cost, or false if this edge must not
/// collapse at all.
///
/// The position is not always the quadric's minimum. A vertex on a border is pinned to one
/// of the two endpoints, because the optimum of a sum of quadrics can sit anywhere and
/// "anywhere" is how an opening drifts. Interior edges get the true minimum, and fall back
/// to the better endpoint or the midpoint where the system is singular — which, on anything
/// flat, it always is.
/// </summary>
bool TryCost( int a, int b, out double cost, out Vec3 to )
{
cost = 0;
to = Vec3.Zero;
if ( a == b || !_alive[a] || !_alive[b] )
return false;
var lockedA = _locked[a];
var lockedB = _locked[b];
// Both ends on a border, but the edge between them is not one: collapsing it pulls two
// separate stretches of border together through the middle of the surface. Refused
// outright rather than priced, because there is no position that makes it acceptable.
if ( lockedA && lockedB && !IsConstrainedEdge( a, b ) )
return false;
var q = _quadric[a];
q.Add( _quadric[b] );
if ( lockedA != lockedB )
{
// One end is held. The merged vertex goes there; the free one comes to it.
to = lockedA ? _pos[a] : _pos[b];
}
else if ( !q.Solve( out var x, out var y, out var z ) )
{
to = Cheapest( q, _pos[a], _pos[b], Vec3.Lerp( _pos[a], _pos[b], 0.5f ) );
}
else
{
to = new Vec3( (float)x, (float)y, (float)z );
}
cost = q.Error( to.x, to.y, to.z );
return true;
}
static Vec3 Cheapest( in Quadric q, Vec3 a, Vec3 b, Vec3 mid )
{
var ea = q.Error( a.x, a.y, a.z );
var eb = q.Error( b.x, b.y, b.z );
var em = q.Error( mid.x, mid.y, mid.z );
if ( ea <= eb && ea <= em ) return a;
return eb <= em ? b : mid;
}
/// <summary>Whether the edge itself is a border — one live triangle, or two with different
/// materials. Asked only of edges whose ends are both locked, which is rare.</summary>
bool IsConstrainedEdge( int a, int b )
{
var count = 0;
var material = -1;
foreach ( var t in _vertTris[a] )
{
if ( !_triAlive[t] || !Uses( t, b ) )
continue;
count++;
if ( material >= 0 && material != _triMaterial[t] )
return true;
material = _triMaterial[t];
}
return count != 2;
}
bool Uses( int t, int v ) =>
_tri[3 * t] == v || _tri[3 * t + 1] == v || _tri[3 * t + 2] == v;
/// <summary>
/// The link condition: an edge is safe to collapse when the only vertices adjacent to BOTH
/// its endpoints are the ones opposite it in the triangles that share it.
///
/// This is the whole of topological safety in one test, and it is not optional. Without it a
/// collapse can weld two sheets of the mesh that merely passed close to each other, or pinch
/// a tube into a figure of eight — a mesh that still has the right triangle count, still
/// renders, and is non-manifold, so the exporter, the boolean and the physics hull all fail
/// on it later with nothing pointing back here. Dey, Edelsbrunner, Guha and Nekhayev proved
/// it necessary and sufficient for a simplicial complex; the cost is one small set
/// intersection per candidate.
///
/// The sets here are stamp arrays, not HashSets: this runs once per collapse and the HashSet
/// version of it — three allocations a call, tens of millions over a dense import — was the
/// single largest cost in the whole decimation.
/// </summary>
bool LinkConditionHolds( int a, int b )
{
// The vertices opposite the edge in the triangles that share it — at most two on a
// manifold, so a small reused list rather than a set.
_opposite.Clear();
foreach ( var t in _vertTris[a] )
{
if ( !_triAlive[t] || !Uses( t, b ) )
continue;
for ( var i = 0; i < 3; i++ )
{
var v = _tri[3 * t + i];
if ( v != a && v != b )
_opposite.Add( v );
}
}
// The link of a — every neighbour, opposite or not.
var linkA = NextStamp();
foreach ( var t in _vertTris[a] )
{
if ( !_triAlive[t] )
continue;
for ( var i = 0; i < 3; i++ )
{
var v = _tri[3 * t + i];
if ( v != a )
_mark[v] = linkA;
}
}
// A neighbour of b that is in a's link but not opposite the edge closes a triangle
// around the collapse, and the link condition fails.
foreach ( var t in _vertTris[b] )
{
if ( !_triAlive[t] )
continue;
for ( var i = 0; i < 3; i++ )
{
var v = _tri[3 * t + i];
if ( v == a || v == b || _mark[v] != linkA )
continue;
if ( !_opposite.Contains( v ) )
return false;
}
}
return true;
}
/// <summary>Would any triangle that survives this collapse be turned inside out by it?</summary>
bool WouldFlip( int a, int b, Vec3 to ) =>
Flips( a, b, to ) || Flips( b, a, to );
bool Flips( int v, int other, Vec3 to )
{
foreach ( var t in _vertTris[v] )
{
if ( !_triAlive[t] )
continue;
// A triangle using both ends is about to disappear, so its normal is nobody's
// business.
if ( Uses( t, v ) && Uses( t, other ) )
continue;
var p0 = _tri[3 * t] == v ? to : _pos[_tri[3 * t]];
var p1 = _tri[3 * t + 1] == v ? to : _pos[_tri[3 * t + 1]];
var p2 = _tri[3 * t + 2] == v ? to : _pos[_tri[3 * t + 2]];
var before = Vec3.Cross(
_pos[_tri[3 * t + 1]] - _pos[_tri[3 * t]],
_pos[_tri[3 * t + 2]] - _pos[_tri[3 * t]] );
var after = Vec3.Cross( p1 - p0, p2 - p0 );
// Collapsed to nothing. Not a flip, but not a triangle either, and letting it
// through leaves a zero-area face for the next pass to divide by.
if ( after.LengthSquared < 1e-20f )
return true;
if ( before.LengthSquared < 1e-20f )
continue;
if ( Vec3.Dot( before.Normal, after.Normal ) < _options.FlipThreshold )
return true;
}
return false;
}
/// <summary>
/// Would this collapse remove surface rather than simplify it?
///
/// A collapse is supposed to be a trade: two triangles die and the rest of the ring closes
/// up around the merged vertex. Where there is no rest of the ring, the same operation is
/// just a delete — the triangles vanish and leave a hole, and the triangle count goes down,
/// so nothing upstream notices that the answer is a mesh with a bite out of it.
///
/// FOUND BY THE UNWELDED CASE. A triangle soup is nothing but isolated triangles, and every
/// one of them satisfies the link condition perfectly: collapse any edge and the third
/// vertex is the only shared neighbour, exactly as the condition requires. It is a valid
/// collapse of a valid complex, and it eats the model one triangle at a time. Welding is the
/// real fix and is on by default; this is what makes turning it off honest rather than
/// destructive.
/// </summary>
bool WouldDelete( int a, int b ) =>
Deletes( a, b ) && Deletes( b, a );
bool Deletes( int v, int other )
{
foreach ( var t in _vertTris[v] )
{
if ( _triAlive[t] && !(Uses( t, v ) && Uses( t, other )) )
return false;
}
return true;
}
/// <summary>Do it, if the checks allow. B is merged into A, which keeps A's slot and
/// therefore A's adjacency list — the cheaper of the two directions.</summary>
bool Apply( int a, int b, Vec3 to )
{
if ( WouldDelete( a, b ) || !LinkConditionHolds( a, b ) || WouldFlip( a, b, to ) )
return false;
var from = _pos[a];
var t = Along( from, _pos[b], to );
// Kill the triangles that used the whole edge — they have collapsed to a line.
foreach ( var tri in _vertTris[a] )
{
if ( !_triAlive[tri] || !Uses( tri, b ) )
continue;
_triAlive[tri] = false;
LiveTriangles--;
}
// Everything else that touched B now touches A.
foreach ( var tri in _vertTris[b] )
{
if ( !_triAlive[tri] )
continue;
for ( var i = 0; i < 3; i++ )
{
if ( _tri[3 * tri + i] == b )
_tri[3 * tri + i] = a;
}
_vertTris[a].Add( tri );
}
_pos[a] = to;
_alive[b] = false;
_locked[a] = _locked[a] || _locked[b];
_version[a]++;
var q = _quadric[a];
q.Add( _quadric[b] );
_quadric[a] = q;
if ( _colors is not null )
_colors[a] = Vec4.Lerp( _colors[a], _colors[b], t );
if ( _skin is not null )
{
_skin[a] = SkinWeights.Blend( new[]
{
(_skin[a], 1f - t),
(_skin[b], t),
} );
}
_vertTris[b] = null;
// The dead entries left behind by the loop above would otherwise accumulate until a
// vertex that has absorbed a thousand neighbours carries a thousand dead triangles and
// every neighbourhood walk over it costs a thousand steps. Compacted when the list is
// mostly rubbish rather than every time, so the amortised cost stays flat.
//
// The count pass and the write pass are separate on purpose: the write compacts in place,
// so running it and then NOT trimming (because the list was less than half dead) leaves
// the live entries duplicated at the front of a list that kept its old tail. That was
// inflating the survivor's list by up to 2x and making every later walk over it pay for
// the phantom triangles.
var live = _vertTris[a];
if ( live.Count > 16 )
{
var kept = 0;
for ( var i = 0; i < live.Count; i++ )
{
if ( _triAlive[live[i]] )
kept++;
}
if ( kept * 2 < live.Count )
{
var w = 0;
for ( var i = 0; i < live.Count; i++ )
{
if ( _triAlive[live[i]] )
live[w++] = live[i];
}
live.RemoveRange( w, live.Count - w );
}
}
return true;
}
/// <summary>Where the merged position sits along the original edge, 0 at A and 1 at B.
/// The blend factor for everything carried per vertex.</summary>
static float Along( Vec3 a, Vec3 b, Vec3 to )
{
var edge = b - a;
var lengthSquared = edge.LengthSquared;
if ( lengthSquared < 1e-20f )
return 0.5f;
return Math.Clamp( Vec3.Dot( to - a, edge ) / lengthSquared, 0f, 1f );
}
// --- packing back up --------------------------------------------------------------------
public PolyMesh Build()
{
var mesh = new PolyMesh();
var remap = new int[_pos.Count];
for ( var i = 0; i < remap.Length; i++ )
remap[i] = -1;
var colors = _colors is null ? null : new List<Vec4>();
var skin = _skin is null ? null : new SkinWeights();
for ( var t = 0; t < _triAlive.Count; t++ )
{
if ( !_triAlive[t] )
continue;
var indices = new int[3];
var uvs = new Vec2[3];
for ( var i = 0; i < 3; i++ )
{
var v = _tri[3 * t + i];
if ( remap[v] < 0 )
{
remap[v] = mesh.AddVertex( _pos[v] );
colors?.Add( _colors[v] );
skin?.Vertices.Add( _skin[v] );
}
indices[i] = remap[v];
uvs[i] = _triUV[3 * t + i];
}
mesh.AddFace( indices, uvs, _triMaterial[t] );
}
if ( colors is not null )
mesh.VertexColors = colors.ToArray();
mesh.Skin = skin;
mesh.Paint = _paint;
return mesh;
}
}
}