Effigy.Tests/EdgeBlendTests.cs
using System;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;
namespace Effigy.Tests;
/// <summary>
/// Checks on EdgeBlend — the same "looks right, is wrong" trap as Catmull-Clark, since a corner cut
/// with the winding backwards still renders as a plausible chamfer right up until the lighting (or
/// the enclosed volume) says otherwise.
/// </summary>
public static class EdgeBlendTests
{
public static void Run()
{
Section( "bevel keeps a solid closed and manifold" );
TestStaysClosed();
Section( "bevel winds outward" );
TestWindingStaysOutward();
Section( "bevel shrinks enclosed volume, monotonically with width" );
TestVolumeShrinks();
Section( "angle threshold selects only the edges it should" );
TestAngleThreshold();
Section( "a single edge only cuts its own two corners" );
TestPartialChamfer();
Section( "an unreachable angle threshold is a no-op" );
TestUnreachableThresholdIsANoOp();
Section( "zero width is a no-op" );
TestZeroWidth();
Section( "bevel carries skin weights" );
TestRigSurvives();
Section( "fillet: one segment is the chamfer, exactly" );
TestOneSegmentIsTheChamfer();
Section( "fillet: the strip is an arc, not a chord" );
TestArcIsRound();
Section( "fillet: the solid survives being rounded" );
TestFilletStaysClosed();
Section( "fillet: radius is a radius, not a setback" );
TestRadiusIsARadius();
Section( "fillet carries skin weights" );
TestFilletRigSurvives();
Section( "bevel stays local on a collinear corner" );
TestCollinearCornersStayLocal();
Section( "an explicit edge list cuts only those edges" );
TestExplicitEdgeList();
}
/// <summary>
/// A bevelled corner must not fly off into space.
///
/// THE BUG THIS EXISTS FOR, because every other check in this file passed while it was live:
/// a corner lands at roughly width/sin(turn) from its vertex, so a corner that is nearly
/// straight throws it arbitrarily far. Ear clipping a thin annulus — which is what a sketch
/// with a hole extrudes into — produces collinear corners (turn 180°, sin 1.5e-5), and those
/// put vertices 15000 units away on a model 20 across.
///
/// It stayed invisible because the result is still finite, still closed, still manifold and
/// still has the right Euler characteristic. Only a render showed it, as the whole model
/// collapsing to a speck while the view stretched to fit one stray vertex. So the assertion
/// here is the one nothing else was making: the geometry has to stay near where it started.
/// </summary>
static void TestCollinearCornersStayLocal()
{
// THE DEFECT IS BUILT BY HAND NOW, and that is the point of this arrangement.
//
// The corner came free once: ear clipping a holed cap left corners turning a full 180, and
// the annulus below was full of them. Holed caps are no longer clipped - they come back as
// n-gons, however many holes they have - so the shape that used to carry the defect stopped
// carrying it, and a guard that quietly stops guarding is worth less than no guard at all.
// Splitting an edge of a box gives the same 180 turn, exactly, and cannot drift with a
// tessellation or a cap pass ever again.
//
// The annulus stays as well. It is the shape the bug was found on, it is still worth
// chamfering, and a thin ring is where a chamfer has least room to work.
var tube = Extruded( s =>
{
s.AddCircle( new Vec2( 0, 0 ), 10f );
s.AddCircle( new Vec2( 0, 0 ), 8.4f );
} );
var split = WithSplitEdge( Primitives.Box( 8f, 5f, 3f ) );
Check( "the hand-built fixture really does have a collinear corner",
StraightestCorner( split ) < 1e-6f, $"straightest |sin| = {StraightestCorner( split ):E3}" );
Check( "and it is still a closed, valid mesh to start from",
MeshValidator.Validate( split ) is { IsValid: true, IsClosed: true } );
foreach ( var (name, mesh) in new[] { ("annulus", tube), ("split-edge box", split) } )
{
var before = mesh.Positions.Max( p => p.Length );
foreach ( var width in new[] { 0.05f, 0.22f, 0.5f } )
{
var chamfered = EdgeBlend.Chamfer( mesh, width, 20f );
var after = chamfered.Positions.Max( p => p.Length );
// Generous: a chamfer can only move a corner outward by a small multiple of its own
// width, so anything past the original radius plus a few widths is the runaway case.
var limit = before + width * 20f;
Check( $"{name} width {width}: no corner escapes the model", after <= limit,
$"furthest vertex {after:0.##}, allowed {limit:0.##} (was {before:0.##} before)" );
var validation = MeshValidator.Validate( chamfered );
Check( $"{name} width {width}: still closed and manifold",
validation is { IsValid: true, IsClosed: true }, validation.ToString() );
}
}
}
/// <summary>
/// Split one edge of a mesh by putting a vertex at its midpoint, giving both faces that share
/// it a corner that turns exactly 180 degrees.
///
/// Inserted into BOTH faces, which is the whole difficulty: adding it to one leaves the other
/// with an edge running past a vertex it does not mention, which is a T-junction rather than a
/// collinear corner, and the mesh stops being manifold.
/// </summary>
static PolyMesh WithSplitEdge( PolyMesh mesh )
{
var face = mesh.Faces[0];
var a = face.Indices[0];
var b = face.Indices[1];
var index = mesh.Positions.Count;
mesh.Positions.Add( (mesh.Positions[a] + mesh.Positions[b]) * 0.5f );
foreach ( var f in mesh.Faces )
{
for ( var i = 0; i < f.Indices.Length; i++ )
{
var p = f.Indices[i];
var q = f.Indices[(i + 1) % f.Indices.Length];
// Either way round: the two faces sharing an edge walk it in opposite directions.
if ( (p != a || q != b) && (p != b || q != a) )
continue;
var indices = f.Indices.ToList();
var uvs = f.UVs.ToList();
indices.Insert( i + 1, index );
uvs.Insert( i + 1, (f.UVs[i] + f.UVs[(i + 1) % f.UVs.Length]) * 0.5f );
f.Indices = indices.ToArray();
f.UVs = uvs.ToArray();
break;
}
}
return mesh;
}
/// <summary>One solid from one drawn profile, 2.6 deep.</summary>
static PolyMesh Extruded( Action<Sketch> draw )
{
var studio = new PartStudio();
var sketch = studio.Add( new SketchFeature() );
draw( sketch.Sketch );
studio.Add( new ExtrudeFeature() ).Distance.Value = 2.6f;
studio.Rebuild();
return studio.Bodies.Single().Mesh;
}
/// <summary>The smallest |sin(turn)| over every corner of every face — the quantity
/// EdgeBlend.IntersectCoplanarLines divides by.</summary>
static float StraightestCorner( PolyMesh mesh )
{
var smallest = float.MaxValue;
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
var f = mesh.Faces[fi];
var n = mesh.FaceNormal( f );
for ( var i = 0; i < f.Count; i++ )
{
var prev = mesh.Positions[f.Indices[(i - 1 + f.Count) % f.Count]];
var v = mesh.Positions[f.Indices[i]];
var next = mesh.Positions[f.Indices[(i + 1) % f.Count]];
var d = MathF.Abs( Vec3.Dot( Vec3.Cross( (v - prev).Normal, (next - v).Normal ), n ) );
if ( d < smallest )
smallest = d;
}
}
return smallest;
}
static float Volume( PolyMesh m ) => m.SignedVolume();
static void TestStaysClosed()
{
var cube = Primitives.Box( 2, 2, 2 );
var chamfered = EdgeBlend.Chamfer( cube, 0.2f, 15f );
var validation = MeshValidator.Validate( chamfered );
Check( "valid mesh", validation.IsValid, validation.ToString() );
Check( "still closed (no boundary edges)", validation.IsClosed, validation.ToString() );
Check( "Euler characteristic still 2", MeshValidator.EulerCharacteristic( chamfered ) == 2,
$"got {MeshValidator.EulerCharacteristic( chamfered )}" );
// 6 shrunk faces + 12 edge bridges + 8 vertex caps, for a fully-chamfered cube.
Check( "face count matches the hand count", chamfered.FaceCount == 6 + 12 + 8, $"{chamfered.FaceCount} faces" );
Check( "no vertex is left unused", chamfered.Positions.Count == chamfered.Faces.SelectMany( f => f.Indices ).Distinct().Count() );
}
static void TestWindingStaysOutward()
{
var cube = Primitives.Box( 2, 2, 2 );
var chamfered = EdgeBlend.Chamfer( cube, 0.2f, 15f );
Check( "enclosed volume is positive", Volume( chamfered ) > 0f, $"volume {Volume( chamfered )}" );
}
static void TestVolumeShrinks()
{
var cube = Primitives.Box( 2, 2, 2 );
var original = Volume( cube );
var small = Volume( EdgeBlend.Chamfer( cube, 0.05f, 15f ) );
var big = Volume( EdgeBlend.Chamfer( cube, 0.3f, 15f ) );
Check( "a small bevel loses only a little volume", small < original && small > original * 0.9f,
$"original {original}, small-bevel {small}" );
Check( "a bigger bevel loses more volume than a smaller one", big < small, $"small {small}, big {big}" );
Check( "volume stays positive even for a generous bevel", big > 0f, $"big-bevel volume {big}" );
}
static void TestAngleThreshold()
{
// A cylinder's cap-to-side edges are genuinely sharp (90°); the seams between adjacent side
// quads are flat (0°, coplanar within each ring segment's tessellation — actually adjacent
// side quads meet at a real angle too, so use the boundary between top-cap and side wall,
// which is unambiguously 90°, versus a threshold set above that).
var cylinder = Primitives.Cylinder( 0.5f, 1f, 16 );
var untouched = EdgeBlend.Chamfer( cylinder, 0.05f, 179f );
Check( "an unreachable threshold changes nothing",
untouched.FaceCount == cylinder.FaceCount && untouched.VertexCount == cylinder.VertexCount );
var chamfered = EdgeBlend.Chamfer( cylinder, 0.05f, 45f );
Check( "a reachable threshold does cut something", chamfered.FaceCount > cylinder.FaceCount );
var validation = MeshValidator.Validate( chamfered );
Check( "still valid and closed", validation.IsValid && validation.IsClosed, validation.ToString() );
}
static void TestPartialChamfer()
{
// The wedge's two "ridge" edges — slope-to-base and slope-to-back — meet at 135°; every
// other edge on it is a plain 90° box corner. A 110° threshold selects only those two, so
// this is a genuine mixed selection: some corners of some faces cut, their neighbours on
// the very same vertex left alone. That is the case CutCorner's "only one side moved" path
// exists for, and the cube tests above never exercise it because every cube edge is 90°.
var wedge = Primitives.Wedge( 1, 1, 1 );
var chamfered = EdgeBlend.Chamfer( wedge, 0.1f, 110f );
var validation = MeshValidator.Validate( chamfered );
Check( "still valid and closed", validation.IsValid && validation.IsClosed, validation.ToString() );
// 5 shrunk originals + 2 ridge bridge quads + 6 degenerate-or-not bridges for the other six
// unselected edges each triangle end touches (0-1, 1-4, 4-0 and their +Y-end counterparts,
// each disagreeing with its shrunk neighbour on at least one side) + 4 vertex caps (one per
// ridge endpoint) — hand-counted from the geometry once the bridging pass covers every edge,
// not just the selected ones.
Check( "face count matches the hand count", chamfered.FaceCount == 5 + 2 + 6 + 4, $"{chamfered.FaceCount} faces" );
// The end-cap triangles' vertex POSITIONS survive untouched (neither of their own edges is
// ever selected) — but their INDEX labels do not: RemoveUnusedVertices renumbers by
// first-use order across the whole mesh, so checking against the original [0,1,4]/[2,3,5]
// index triples (as opposed to positions) would be checking a renumbering artefact.
bool TriangleAt( Vec3 a, Vec3 b, Vec3 c ) =>
chamfered.Faces.Any( f => f.Count == 3
&& new[] { a, b, c }.All( p => f.Indices.Any( i => chamfered.Positions[i].AlmostEquals( p ) ) ) );
Check( "the -Y end cap survives completely untouched",
TriangleAt( new Vec3( -0.5f, -0.5f, -0.5f ), new Vec3( 0.5f, -0.5f, -0.5f ), new Vec3( -0.5f, -0.5f, 0.5f ) ) );
Check( "the +Y end cap survives completely untouched",
TriangleAt( new Vec3( 0.5f, 0.5f, -0.5f ), new Vec3( -0.5f, 0.5f, -0.5f ), new Vec3( -0.5f, 0.5f, 0.5f ) ) );
Check( "volume shrinks but stays positive", Volume( chamfered ) > 0f && Volume( chamfered ) < Volume( wedge ),
$"original {Volume( wedge )}, chamfered {Volume( chamfered )}" );
}
static void TestExplicitEdgeList()
{
var cube = Primitives.Box( 2, 2, 2 );
EdgeKey top = default;
var found = false;
foreach ( var (key, _) in cube.BuildEdgeFaces() )
{
var a = cube.Positions[key.A];
var b = cube.Positions[key.B];
if ( MathF.Abs( a.z - 1f ) > 1e-4f || MathF.Abs( b.z - 1f ) > 1e-4f )
continue;
top = key;
found = true;
break;
}
Check( "the cube has a top edge to pick", found );
var one = EdgeBlend.ChamferReport( cube, 0.2f, new[] { top } );
var all = EdgeBlend.Chamfer( cube, 0.2f, 15f );
Check( "an explicit list actually cuts", one.Failure is null, one.Failure?.Problem ?? "" );
Check( "and names exactly one edge", one.SelectedEdges == 1, $"{one.SelectedEdges}" );
var validation = MeshValidator.Validate( one.Mesh );
Check( "the one-edge cut stays closed and manifold", validation.IsValid && validation.IsClosed,
validation.ToString() );
Check( "it adds fewer faces than blending every sharp edge",
one.Mesh.FaceCount < all.FaceCount,
$"one {one.Mesh.FaceCount}, all {all.FaceCount}" );
Check( "volume shrinks, but less than a full chamfer",
Volume( one.Mesh ) < Volume( cube ) && Volume( one.Mesh ) > Volume( all ),
$"original {Volume( cube )}, one {Volume( one.Mesh )}, all {Volume( all )}" );
}
static void TestUnreachableThresholdIsANoOp()
{
var cube = Primitives.Box( 2, 2, 2 );
var untouched = EdgeBlend.Chamfer( cube, 0.2f, 179f );
Check( "an edge nothing selects leaves the cube alone",
untouched.FaceCount == cube.FaceCount && untouched.VertexCount == cube.VertexCount );
var validation = MeshValidator.Validate( untouched );
Check( "still valid and closed", validation.IsValid && validation.IsClosed, validation.ToString() );
}
static void TestZeroWidth()
{
var cube = Primitives.Box( 2, 2, 2 );
var chamfered = EdgeBlend.Chamfer( cube, 0f, 15f );
Check( "zero width returns the mesh unchanged", chamfered.FaceCount == cube.FaceCount && chamfered.VertexCount == cube.VertexCount );
}
/// <summary>
/// EdgeBlend rebuilds the vertex list, so it has to rebuild the weights beside it — the same
/// contract Shell and the old uniform bevel were both fixed to honour. Every new corner point is
/// a single cut of one original vertex (see the class comment on EdgeBlend), so unlike a smoothing
/// operation it inherits that vertex's weights outright rather than blending several.
/// </summary>
static void TestRigSurvives()
{
var skeleton = new Skeleton();
skeleton.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, -1 ), new Vec3( 0, 0, 0 ) );
skeleton.AddBoneFromPoints( "tip", 0, new Vec3( 0, 0, 0 ), new Vec3( 0, 0, 1 ) );
var mesh = Primitives.Box( 2, 2, 2 );
mesh.Skin = SkinBinder.BindSmooth( mesh, skeleton );
var chamfered = EdgeBlend.Chamfer( mesh, 0.2f, 15f );
Check( "a rigged body survives bevelling", chamfered.IsRigged );
Check( "with one weight set per new vertex", chamfered.Skin.Count == chamfered.VertexCount,
$"{chamfered.Skin.Count} vs {chamfered.VertexCount}" );
Check( "and every set still valid", chamfered.Skin.Validate( chamfered.VertexCount, skeleton.Count ).Count == 0 );
// An untouched-face corner keeps the source vertex's own index too, so its weights should
// match exactly - not an average, and not bone 0.
var sourceWeights = mesh.Skin[mesh.Faces[0].Indices[0]];
Check( "corners inherit their source vertex's weights",
chamfered.Skin[0].Length == sourceWeights.Length && chamfered.Skin[0][0].Bone == sourceWeights[0].Bone );
}
// --- fillets ---------------------------------------------------------------------------------
/// <summary>
/// A one-segment arc IS the chord, so a one-segment fillet has to be the chamfer — same faces,
/// same vertices, same volume, not merely similar.
///
/// This is the test that keeps the two operations one implementation. The moment the fillet path
/// drifts into computing its corners differently, this fails, and it fails on the cheapest case
/// rather than on some rounded solid where the difference is a fraction of a percent nobody
/// notices.
/// </summary>
static void TestOneSegmentIsTheChamfer()
{
var cube = Primitives.Box( 2, 2, 2 );
var chamfered = EdgeBlend.Chamfer( cube, 0.2f, 15f );
var filleted = EdgeBlend.Fillet( cube, 0.2f, 15f, 1 );
Check( "same face count", chamfered.FaceCount == filleted.FaceCount,
$"{chamfered.FaceCount} vs {filleted.FaceCount}" );
Check( "same vertex count", chamfered.VertexCount == filleted.VertexCount,
$"{chamfered.VertexCount} vs {filleted.VertexCount}" );
Check( "same volume", Close( Volume( chamfered ), Volume( filleted ) ),
$"{Volume( chamfered )} vs {Volume( filleted )}" );
}
/// <summary>
/// THE ONE THAT ACTUALLY CHECKS IT IS ROUND. Every other property here — closed, manifold,
/// Euler, a plausible face count — is satisfied just as well by a chamfer cut into n flat
/// strips, which is what a slerp that quietly degraded to a lerp would produce.
///
/// A cube's rounded edge is a quarter cylinder, so every point on that strip sits exactly one
/// radius from the edge's own axis. Measured against the axis rather than against neighbouring
/// points, because evenly spaced is not the same as circular.
/// </summary>
static void TestArcIsRound()
{
const float radius = 0.3f;
var cube = Primitives.Box( 2, 2, 2 );
var filleted = EdgeBlend.Fillet( cube, radius, 15f, 6 );
// The +x/+z edge of a 2x2x2 box runs along y at (1, ., 1); its fillet axis is the parallel
// line one radius in from both faces.
var axis = new Vec3( 1f - radius, 0f, 1f - radius );
var worst = 0f;
var found = 0;
foreach ( var p in filleted.Positions )
{
// Only the points that belong to this edge's strip. The strip is a quad strip between
// two rails, so every one of its vertices sits ON a rail — at |y| = 1 - setback, which
// on a cube is 1 - radius. That plus being out past the axis on both faces picks this
// edge's arc and nothing else: a neighbouring edge's arc points share a rail plane but
// swing away in the axis they round about.
if ( MathF.Abs( MathF.Abs( p.y ) - (1f - radius) ) > 1e-3f )
continue;
if ( p.x < axis.x - 1e-3f || p.z < axis.z - 1e-3f )
continue;
found++;
var offset = new Vec3( p.x - axis.x, 0f, p.z - axis.z );
worst = MathF.Max( worst, MathF.Abs( offset.Length - radius ) );
}
Check( "the strip has the points a 6-segment arc needs", found >= 7, $"{found} points" );
Check( "and every one is exactly one radius from the axis", worst < 1e-4f,
$"worst deviation {worst}" );
// A chord cuts the corner; an arc bulges out to meet it. So the same setback must remove
// less material rounded than flat — and still remove some.
var chamfered = EdgeBlend.Chamfer( cube, radius, 15f );
Check( "a fillet keeps more material than the chamfer it replaces",
Volume( filleted ) > Volume( chamfered ), $"{Volume( filleted )} vs {Volume( chamfered )}" );
Check( "and still less than the sharp solid", Volume( filleted ) < Volume( cube ),
$"{Volume( filleted )} vs {Volume( cube )}" );
}
static void TestFilletStaysClosed()
{
const int segments = 4;
var cube = Primitives.Box( 2, 2, 2 );
var filleted = EdgeBlend.Fillet( cube, 0.25f, 15f, segments );
var validation = MeshValidator.Validate( filleted );
Check( "valid mesh", validation.IsValid, validation.ToString() );
Check( "still closed (no boundary edges)", validation.IsClosed, validation.ToString() );
Check( "Euler characteristic still 2", MeshValidator.EulerCharacteristic( filleted ) == 2,
$"got {MeshValidator.EulerCharacteristic( filleted )}" );
// 6 shrunk faces + 12 edges each cut into `segments` strips + 8 vertex caps. The caps do not
// multiply: a corner is still one face, it just has more corners now that the arcs run into
// it — which is the whole T-junction question, answered by counting.
Check( "face count matches the hand count",
filleted.FaceCount == 6 + 12 * segments + 8, $"{filleted.FaceCount} faces" );
Check( "no vertex is left unused",
filleted.Positions.Count == filleted.Faces.SelectMany( f => f.Indices ).Distinct().Count() );
// Each cap sits where three rounded edges meet, so it has one corner per face plus the
// segments-1 arc points each of the three edges brings into it.
var caps = filleted.Faces.Count( f => f.Count == 3 * segments );
Check( "and the eight caps carry the arcs that run into them", caps == 8, $"{caps} such faces" );
}
/// <summary>
/// The radius is the arc's radius, and the setback follows from the angle — r/tan(φ/2). On a
/// cube's 90° edges those are the same number, which is exactly why this needs a shape whose
/// edges are not 90°: a wedge opens at 45° along its slope, where a setback equal to the radius
/// would be wrong by a factor of 2.4.
/// </summary>
static void TestRadiusIsARadius()
{
var wedge = Primitives.Wedge( 2, 2, 2 );
var filleted = EdgeBlend.Fillet( wedge, 0.15f, 15f, 4 );
var validation = MeshValidator.Validate( filleted );
Check( "a wedge fillets into a valid solid", validation.IsValid, validation.ToString() );
Check( "and stays closed", validation.IsClosed, validation.ToString() );
Check( "Euler characteristic still 2", MeshValidator.EulerCharacteristic( filleted ) == 2,
$"got {MeshValidator.EulerCharacteristic( filleted )}" );
// The shallow edge has to set back FURTHER than the radius to hold the same arc. Measuring
// it directly: the chamfer that produces the same tangent points is the one whose distance
// is that setback, and it is not 0.15.
var sameSetback = EdgeBlend.Chamfer( wedge, 0.15f, 15f );
Check( "so a fillet is not a chamfer of the same number",
!Close( Volume( filleted ), Volume( sameSetback ), 1e-3f ),
$"{Volume( filleted )} vs {Volume( sameSetback )}" );
}
static void TestFilletRigSurvives()
{
var skeleton = new Skeleton();
skeleton.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, -1 ), new Vec3( 0, 0, 0 ) );
skeleton.AddBoneFromPoints( "tip", 0, new Vec3( 0, 0, 0 ), new Vec3( 0, 0, 1 ) );
var mesh = Primitives.Box( 2, 2, 2 );
mesh.Skin = SkinBinder.BindSmooth( mesh, skeleton );
var filleted = EdgeBlend.Fillet( mesh, 0.2f, 15f, 4 );
// The arc points are new vertices invented after the corner pass, which is exactly where a
// parallel weight list gets forgotten — the count is what catches it.
Check( "a rigged body survives filleting", filleted.IsRigged );
Check( "with one weight set per new vertex", filleted.Skin.Count == filleted.VertexCount,
$"{filleted.Skin.Count} vs {filleted.VertexCount}" );
Check( "and every set still valid",
filleted.Skin.Validate( filleted.VertexCount, skeleton.Count ).Count == 0 );
}
static bool Close( float a, float b, float eps = 1e-4f ) => MathF.Abs( a - b ) <= eps;
static void Section( string title ) => Report.Section( title );
static void Check( string what, bool ok, string detail = null ) => Report.Check( what, ok, detail );
}