Effigy.Tests/SnapTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// Drawing a closed rectangle with the line tool, at every part size anyone would model at.
///
/// This is the bug that cost a night: closed sketches not registering as closed. The tolerances
/// were fixed sketch-unit constants, so on a small part every existing point sat inside the snap
/// radius of every new click. Corners collapsed onto one another, the line tool built a
/// zero-length SketchLine, ProfileFinder linked that one curve into the adjacency map twice at the
/// same point, and a perfectly good corner reported as joining three curves - "branching sketches
/// are not supported yet", no closed region, nothing to extrude.
///
/// The whole failure is reproducible with no engine in sight, which is why it belongs here.
/// </summary>
public static class SnapTests
{
public static void Run()
{
Report.Section( "snapping: a drawn rectangle closes at any part size" );
TestRectangleClosesAtEveryScale();
Report.Section( "snapping: the fixed-tolerance version this replaced" );
TestFixedTolerancesFailOnSmallParts();
Report.Section( "snapping: tolerance to a sloppy closing click" );
TestClickTolerance();
Report.Section( "snapping: automatic grid step" );
TestAutoGridStep();
Report.Section( "snapping: point reuse and inference" );
TestReuseAndInference();
Report.Section( "snapping: a reported axis lock is true of the point that comes back" );
TestInferenceIsTrue();
Report.Section( "snapping: existing sketch curves and their midpoints" );
TestSnapToExistingCurves();
}
/// <summary>How many sketch units a pixel covers with a part of this size framed in a ~700px
/// viewport. The editor computes this from the camera; here it stands in for "the user framed
/// the part and started drawing".</summary>
static float UnitsPerPixel( float partSize ) => partSize * 1.6f / 700f;
/// <summary>
/// Draw a rectangle the way the line tool does: four corner clicks plus a fifth that lands
/// near the first to close it. Returns the sketch so ProfileFinder can judge it.
/// </summary>
static Sketch DrawRectangle( float size, SketchSnapper snapper, float closingErrorPixels, float unitsPerPixel )
{
var sketch = new Sketch();
var pending = new List<Vec2>();
var error = closingErrorPixels * unitsPerPixel;
var clicks = new[]
{
new Vec2( 0, 0 ),
new Vec2( size, 0 ),
new Vec2( size, size ),
new Vec2( 0, size ),
new Vec2( error, error ), // aiming at the start point and missing by a few pixels
};
foreach ( var raw in clicks )
{
var lineInProgress = pending.Count == 1;
var result = snapper.Snap( sketch, raw, pending, lineInProgress );
pending.Add( result.Point );
if ( pending.Count < 2 )
continue;
var from = SketchSnapper.PointIndex( sketch, pending[0] );
var to = SketchSnapper.PointIndex( sketch, pending[1] );
// The line tool's degenerate guard: a zero-length line is not geometry, and feeding one
// to ProfileFinder is what turned a corner into a "branching" point.
if ( from != to )
sketch.Add( new SketchLine( from, to ) );
var last = pending[1];
pending.Clear();
pending.Add( last );
}
return sketch;
}
static SketchSnapper ScreenSpace( float unitsPerPixel ) => new()
{
PointRadius = 12f * unitsPerPixel,
AlignmentRadius = 7f * unitsPerPixel,
GridStep = SketchSnapper.AutoGridStep( unitsPerPixel ),
};
static void TestRectangleClosesAtEveryScale()
{
foreach ( var size in new[] { 1000f, 100f, 20f, 10f, 5f, 2f, 1f, 0.5f, 0.1f, 0.01f } )
{
var upp = UnitsPerPixel( size );
var sketch = DrawRectangle( size, ScreenSpace( upp ), closingErrorPixels: 4f, upp );
var found = ProfileFinder.Find( sketch );
var closed = found.Profiles.Count == 1
&& found.Warnings.Count == 0
&& sketch.Points.Count == 4
&& sketch.Curves.Count == 4;
Report.Check( $"a {size} unit rectangle closes",
closed,
$"{sketch.Points.Count} pts, {sketch.Curves.Count} curves, "
+ $"{found.Profiles.Count} profiles, {found.Warnings.Count} warnings" );
if ( found.Profiles.Count == 1 )
{
Report.Check( $"a {size} unit rectangle has the right area",
MathF.Abs( found.Profiles[0].Area - size * size ) < size * size * 1e-3f,
$"{found.Profiles[0].Area} vs {size * size}" );
}
}
}
/// <summary>
/// The old behaviour, kept as a test so the regression is a failing check rather than a night
/// of clicking. Fixed 4 / 1 / 0.25 tolerances in sketch units, at every scale.
/// </summary>
static void TestFixedTolerancesFailOnSmallParts()
{
SketchSnapper Fixed() => new() { PointRadius = 4f, AlignmentRadius = 1f, GridStep = 0.25f };
var brokenBelow = new List<float>();
foreach ( var size in new[] { 100f, 20f, 10f, 5f, 2f, 1f, 0.5f } )
{
var upp = UnitsPerPixel( size );
var sketch = DrawRectangle( size, Fixed(), closingErrorPixels: 4f, upp );
var found = ProfileFinder.Find( sketch );
if ( found.Profiles.Count != 1 || found.Warnings.Count > 0 || sketch.Points.Count != 4 )
brokenBelow.Add( size );
}
Report.Check( "fixed tolerances did break on small parts - this is the bug that was fixed",
brokenBelow.Count > 0,
brokenBelow.Count > 0 ? $"broken at sizes: {string.Join( ", ", brokenBelow )}" : "nothing broke" );
// And the screen-space version survives every one of those same sizes.
var survived = new[] { 100f, 20f, 10f, 5f, 2f, 1f, 0.5f }.All( size =>
{
var upp = UnitsPerPixel( size );
var sketch = DrawRectangle( size, ScreenSpace( upp ), 4f, upp );
return ProfileFinder.Find( sketch ).Profiles.Count == 1;
} );
Report.Check( "screen-space tolerances survive all of them", survived );
}
/// <summary>The closing click is a human aiming at a drawn dot. It has to forgive a few pixels
/// and refuse a wild miss, at any zoom.</summary>
static void TestClickTolerance()
{
const float size = 1f;
var upp = UnitsPerPixel( size );
foreach ( var missPixels in new[] { 0f, 2f, 5f, 8f } )
{
var sketch = DrawRectangle( size, ScreenSpace( upp ), missPixels, upp );
Report.Check( $"a {missPixels}px miss still closes the profile",
ProfileFinder.Find( sketch ).Profiles.Count == 1,
$"{sketch.Points.Count} points" );
}
// Well outside the 12px radius, it correctly does NOT close - snapping that far would drag
// clicks the user did not mean.
var wild = DrawRectangle( size, ScreenSpace( upp ), 40f, upp );
Report.Check( "a 40px miss does not silently snap closed",
ProfileFinder.Find( wild ).Profiles.Count == 0,
$"{ProfileFinder.Find( wild ).Profiles.Count} profiles" );
}
static void TestAutoGridStep()
{
// The step must stay in the same ballpark on screen however far in you are zoomed.
foreach ( var upp in new[] { 100f, 1f, 0.01f, 0.0001f } )
{
var step = SketchSnapper.AutoGridStep( upp );
var onScreenPixels = step / upp;
Report.Check( $"at {upp} units/px the grid is a sane size on screen",
onScreenPixels >= 7f && onScreenPixels <= 28f, $"{onScreenPixels}px" );
var mantissa = step / MathF.Pow( 10f, MathF.Floor( MathF.Log10( step ) ) );
Report.Check( $"at {upp} units/px the step is a 1/2/5 number",
MathF.Abs( mantissa - 1f ) < 1e-3f || MathF.Abs( mantissa - 2f ) < 1e-3f
|| MathF.Abs( mantissa - 5f ) < 1e-3f, $"mantissa {mantissa}" );
}
Report.Check( "a nonsense zoom yields no grid rather than a NaN one",
SketchSnapper.AutoGridStep( 0f ) == 0f );
}
static void TestReuseAndInference()
{
var sketch = new Sketch();
var a = SketchSnapper.PointIndex( sketch, new Vec2( 1, 1 ) );
var b = SketchSnapper.PointIndex( sketch, new Vec2( 1, 1 ) );
Report.Check( "the same coordinate reuses its point", a == b && sketch.Points.Count == 1 );
var c = SketchSnapper.PointIndex( sketch, new Vec2( 2, 1 ) );
Report.Check( "a different coordinate makes a new one", c != a && sketch.Points.Count == 2 );
// A second line click level with the first reports a horizontal lock, which is what the
// sketcher turns into a Horizontal constraint.
var snapper = new SketchSnapper { PointRadius = 0.01f, AlignmentRadius = 0.5f, GridStep = 0f };
var start = new Vec2( 0, 0 );
var level = snapper.Snap( new Sketch(), new Vec2( 5f, 0.2f ), new[] { start }, lineInProgress: true );
Report.Check( "a near-level second click locks to horizontal",
(level.InferenceAxis & 2) != 0 && MathF.Abs( level.Point.y ) < 1e-6f,
$"axis {level.InferenceAxis}, y {level.Point.y}" );
var upright = snapper.Snap( new Sketch(), new Vec2( 0.2f, 5f ), new[] { start }, lineInProgress: true );
Report.Check( "a near-upright second click locks to vertical",
(upright.InferenceAxis & 1) != 0 && MathF.Abs( upright.Point.x ) < 1e-6f,
$"axis {upright.InferenceAxis}, x {upright.Point.x}" );
// Landing on a committed point reports its index, which is what draws the snap ring.
var withPoint = new Sketch();
withPoint.AddPoint( new Vec2( 3, 3 ) );
var onto = snapper.Snap( withPoint, new Vec2( 3.005f, 3.005f ), Array.Empty<Vec2>(), false );
Report.Check( "landing on a committed point reports its index",
onto.SnappedPointIndex == 0 && onto.Point.x == 3f && onto.Point.y == 3f,
$"index {onto.SnappedPointIndex}, point {onto.Point}" );
}
/// <summary>
/// InferenceAxis says one thing and one thing only: THIS POINT SHARES A COORDINATE WITH THE
/// START OF THE LINE BEING DRAWN.
///
/// It is not advisory. The line tool reads bit 1 and adds a real Vertical constraint to the line
/// it commits (EffigyViewport.Sketching.cs, the SketchToolKind.Line and LineMidpoint cases), and
/// the sketcher draws its guide through pending[0] on the strength of the same bit. A bit that is
/// true of something ELSE - of a committed point the cursor happened to land on, of some
/// unrelated corner the alignment pass lined up with - is not a weaker version of that claim. It
/// is a false one, and the cost is a rule the geometry breaks: the solver enforces it on the next
/// rebuild and drags the point off wherever it was put.
///
/// Every check here failed before the bits were verified against pending[0].
/// </summary>
static void TestInferenceIsTrue()
{
const float size = 10f;
var upp = UnitsPerPixel( size );
var start = new Vec2( 0f, 0f );
// --- the two false positives -------------------------------------------------------
// A committed point sitting a few pixels OFF the vertical through the line's start. The
// near-vertical aim locks x, and then the point pass overrides it and wins - the click lands
// on the point, which is right, and the line through it is not vertical, which the bit used
// to keep insisting it was.
var offAxis = new Sketch();
offAxis.AddPoint( new Vec2( 4f * upp, 5f ) );
var ontoPoint = ScreenSpace( upp )
.Snap( offAxis, new Vec2( 6f * upp, 5f ), new[] { start }, lineInProgress: true );
Report.Check( "snapping onto an off-axis point drops the vertical lock",
ontoPoint.SnappedPointIndex == 0 && (ontoPoint.InferenceAxis & 1) == 0,
$"index {ontoPoint.SnappedPointIndex}, axis {ontoPoint.InferenceAxis}, x {ontoPoint.Point.x}" );
// Lining up with the x of SOME OTHER point. A useful snap - it is how you get a corner
// directly above another one - and it says nothing whatever about the line from the start
// point to here being vertical, because that other x is not the start point's x.
var elsewhere = new Sketch();
elsewhere.AddPoint( new Vec2( 3f, 7f ) );
var linedUp = ScreenSpace( upp )
.Snap( elsewhere, new Vec2( 3f + 2f * upp, 2f ), new[] { start }, lineInProgress: true );
Report.Check( "lining up with an unrelated point's x is not a vertical line",
(linedUp.InferenceAxis & 1) == 0,
$"axis {linedUp.InferenceAxis}, x {linedUp.Point.x} vs start {start.x}" );
Report.Check( "and the snap itself still happens - only the claim was dropped",
MathF.Abs( linedUp.Point.x - 3f ) < 1e-4f, $"x {linedUp.Point.x}" );
// --- the true positives, which must survive ----------------------------------------
var real = ScreenSpace( upp )
.Snap( new Sketch(), new Vec2( 3f * upp, 5f ), new[] { start }, lineInProgress: true );
Report.Check( "a genuinely near-vertical click still locks and still reports it",
(real.InferenceAxis & 1) != 0 && MathF.Abs( real.Point.x - start.x ) < 1e-6f,
$"axis {real.InferenceAxis}, x {real.Point.x}" );
// THE GRID USED TO CANCEL THE LOCK. The line pass puts the cursor exactly on the start's x;
// rounding then puts it on the nearest grid line, which is the same number only when the
// start happens to sit on the grid. It usually does - it was grid-snapped too - so this hid
// until the start came from somewhere else, a committed corner or a face's vertex.
var offGrid = new Vec2( 0.07f, 0f );
var throughGrid = ScreenSpace( upp )
.Snap( new Sketch(), offGrid + new Vec2( 3f * upp, 5f ), new[] { offGrid }, lineInProgress: true );
Report.Check( "an off-grid start still gives a truly vertical line",
(throughGrid.InferenceAxis & 1) != 0
&& MathF.Abs( throughGrid.Point.x - offGrid.x ) < 1e-6f,
$"axis {throughGrid.InferenceAxis}, x {throughGrid.Point.x} vs start {offGrid.x}" );
// --- the postcondition, swept ------------------------------------------------------
// The property itself rather than three examples of it: wherever the cursor is, whatever it
// lands on, a reported bit is true of the point that came back. Run over a sketch with
// enough in it that every pass gets a chance to fire.
var busy = new Sketch();
busy.AddPoint( new Vec2( 0f, 0f ) );
busy.AddPoint( new Vec2( 3f, 7f ) );
busy.AddPoint( new Vec2( 4f * upp, 5f ) );
busy.AddPoint( new Vec2( -2.5f, 1.25f ) );
var pendingStart = new Vec2( 0.07f, 0.13f );
var lies = 0;
var locks = 0;
for ( var ix = -40; ix <= 40; ix++ )
{
for ( var iy = -40; iy <= 40; iy++ )
{
var raw = new Vec2( ix * 0.19f, iy * 0.17f );
var result = ScreenSpace( upp ).Snap( busy, raw, new[] { pendingStart }, lineInProgress: true );
if ( result.InferenceAxis == 0 )
continue;
locks++;
if ( (result.InferenceAxis & 1) != 0 && MathF.Abs( result.Point.x - pendingStart.x ) > 1e-4f )
lies++;
else if ( (result.InferenceAxis & 2) != 0 && MathF.Abs( result.Point.y - pendingStart.y ) > 1e-4f )
lies++;
}
}
Report.Check( "over 6561 clicks, every reported lock is true of the point returned",
lies == 0, $"{lies} false locks out of {locks} reported" );
// And the sweep is worth something: it has to have found locks to report on.
Report.Check( "the sweep did exercise the inference passes", locks > 100, $"{locks} locks" );
}
/// <summary>
/// Drawing a line to an edge that is already there has to land ON that edge, not next to it.
/// Corners still win, midpoints are a discrete target, and a curve snap is off when the
/// radius is zero so existing tests that never set it keep their old answers.
/// </summary>
static void TestSnapToExistingCurves()
{
var sketch = new Sketch();
sketch.AddRectangle( new Vec2( 0, 0 ), new Vec2( 10, 10 ) );
var snapper = new SketchSnapper
{
PointRadius = 0.5f,
AlignmentRadius = 0.2f,
GridStep = 0f,
CurveRadius = 0.5f,
};
var mid = snapper.Snap( sketch, new Vec2( 5.1f, 0.15f ), Array.Empty<Vec2>(), false );
Report.Check( "a click near the middle of an edge lands on the midpoint",
MathF.Abs( mid.Point.x - 5f ) < 1e-4f && MathF.Abs( mid.Point.y ) < 1e-4f,
$"landed at {mid.Point}" );
Report.Check( "and names the curve, not a corner",
mid.SnappedCurveIndex >= 0 && mid.SnappedPointIndex < 0,
$"curve {mid.SnappedCurveIndex}, point {mid.SnappedPointIndex}" );
var along = snapper.Snap( sketch, new Vec2( 3.2f, 0.2f ), Array.Empty<Vec2>(), false );
Report.Check( "a click along an edge, off the midpoint, still lands on the edge",
MathF.Abs( along.Point.y ) < 1e-4f && along.Point.x > 2.5f && along.Point.x < 4f,
$"landed at {along.Point}" );
Report.Check( "and still names the curve",
along.SnappedCurveIndex >= 0, $"curve {along.SnappedCurveIndex}" );
var corner = snapper.Snap( sketch, new Vec2( 0.1f, 0.1f ), Array.Empty<Vec2>(), false );
Report.Check( "a click near a corner still prefers the corner",
corner.SnappedPointIndex >= 0 && MathF.Abs( corner.Point.x ) < 1e-4f && MathF.Abs( corner.Point.y ) < 1e-4f,
$"index {corner.SnappedPointIndex}, point {corner.Point}" );
var start = new Vec2( 5f, 0f );
var sameEdge = snapper.Snap( sketch, new Vec2( 7f, 0.1f ), new[] { start }, lineInProgress: true );
Report.Check( "the far click of a line does not snap back onto the edge it started on",
sameEdge.SnappedCurveIndex < 0 || MathF.Abs( sameEdge.Point.y ) > 1e-3f,
$"curve {sameEdge.SnappedCurveIndex}, point {sameEdge.Point}" );
var opposite = snapper.Snap( sketch, new Vec2( 5.1f, 9.85f ), new[] { start }, lineInProgress: true );
Report.Check( "but it does snap onto the opposite edge",
opposite.SnappedCurveIndex >= 0 && MathF.Abs( opposite.Point.y - 10f ) < 1e-4f,
$"curve {opposite.SnappedCurveIndex}, point {opposite.Point}" );
// A diagonal, so alignment with the endpoints cannot fake an on-curve landing. An
// axis-aligned edge sits on the same y as its corners, and the alignment pass would
// pull a near-edge click onto that y whether CurveRadius was set or not.
var diagonal = new Sketch();
diagonal.Add( new SketchLine( diagonal.AddPoint( 0, 0 ), diagonal.AddPoint( 10, 5 ) ) );
var off = new Vec2( 5f, 3f );
var blind = new SketchSnapper { PointRadius = 0.4f, AlignmentRadius = 0.2f, GridStep = 0f, CurveRadius = 0f };
var missed = blind.Snap( diagonal, off, Array.Empty<Vec2>(), false );
Report.Check( "with CurveRadius zero, a near-curve click is not pulled onto the curve",
missed.SnappedCurveIndex < 0 && MathF.Abs( missed.Point.y - 3f ) < 1e-4f,
$"curve {missed.SnappedCurveIndex}, point {missed.Point}" );
var seeing = new SketchSnapper { PointRadius = 0.4f, AlignmentRadius = 0.2f, GridStep = 0f, CurveRadius = 0.6f };
var pulled = seeing.Snap( diagonal, off, Array.Empty<Vec2>(), false );
var onLine = SketchSnapper.ClosestOnSegment( new Vec2( 0, 0 ), new Vec2( 10, 5 ), off );
Report.Check( "with CurveRadius set, the same click lands on the diagonal",
pulled.SnappedCurveIndex >= 0 && (pulled.Point - onLine).LengthSquared < 1e-6f,
$"curve {pulled.SnappedCurveIndex}, point {pulled.Point}, expected {onLine}" );
}
}