Unit test file for curve handle behaviour in the Effigy sketching system. It builds sketches (lines, arcs, circles, ellipses, splines), computes their editor handles via SketchHandles, simulates drags and pins, and asserts expected geometry and refusal behaviour using Report.Check.
using System;
using System.Linq;
using Effigy;
namespace Effigy.Tests;
/// <summary>
/// The grips that sit on a curve rather than at its ends — see SketchHandles.
///
/// These are here rather than read in the viewport for the usual reason: the editor assembly does
/// not compile outside s&box, so anything verified by eye up there is verified once, by whoever
/// wrote it. The maths that matters is a circumcentre, an axis projection and a sweep, and all
/// three are wrong in ways that still LOOK like an arc on screen — a bulge that quietly takes the
/// long way round the circle is a plausible drawing of the wrong thing.
/// </summary>
public static class HandleTests
{
public static void Run()
{
Report.Section( "curve handles: where the grips are" );
TestWhereTheGripsAre();
Report.Section( "curve handles: dragging a line moves the whole line" );
TestDraggingALine();
Report.Section( "curve handles: dragging an arc changes its bulge and nothing else" );
TestDraggingAnArc();
Report.Section( "curve handles: circles and ellipses" );
TestCircleAndEllipse();
Report.Section( "curve handles: what a drag refuses" );
TestRefusals();
}
const float Tolerance = 1e-3f;
static bool Near( float a, float b, float tolerance = Tolerance ) => MathF.Abs( a - b ) <= tolerance;
static bool Near( Vec2 a, Vec2 b, float tolerance = Tolerance ) => (a - b).Length <= tolerance;
static float ToSegment( Vec2 p, Vec2 a, Vec2 b )
{
var along = b - a;
var lengthSquared = along.LengthSquared;
if ( lengthSquared < 1e-12f )
return (p - a).Length;
var t = Math.Clamp( Vec2.Dot( p - a, along ) / lengthSquared, 0f, 1f );
return (p - (a + along * t)).Length;
}
static void TestWhereTheGripsAre()
{
var sketch = new Sketch();
var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 2 ) );
var circle = sketch.AddCircle( new Vec2( 10, 0 ), 3f );
var ellipse = sketch.AddEllipse( new Vec2( 0, 10 ), new Vec2( 4, 10 ), 2f );
var spline = sketch.AddSpline( false, new Vec2( -5, 0 ), new Vec2( -6, 2 ), new Vec2( -7, 0 ) );
var handles = SketchHandles.For( sketch );
Report.Check( "one handle per curve that has something to drive", handles.Count == 3,
$"got {handles.Count}" );
Report.Check( "a spline gets none - its shape is its own points, and they already drag",
handles.All( h => h.CurveId != spline.Id ) );
var lineHandle = handles.Single( h => h.CurveId == line.Id );
Report.Check( "the line's grip is at its middle", Near( lineHandle.At, new Vec2( 2, 1 ) ),
$"{lineHandle.At.x}, {lineHandle.At.y}" );
var circleHandle = handles.Single( h => h.CurveId == circle.Id );
Report.Check( "the circle's grip is on its rim", Near( circleHandle.At, new Vec2( 13, 0 ) ),
$"{circleHandle.At.x}, {circleHandle.At.y}" );
var ellipseHandle = handles.Single( h => h.CurveId == ellipse.Id );
Report.Check( "the ellipse's grip is a quarter turn from its major axis",
Near( ellipseHandle.At, new Vec2( 0, 12 ) ), $"{ellipseHandle.At.x}, {ellipseHandle.At.y}" );
// A ROTATED ELLIPSE IS THE CASE THAT CATCHES A HARD-CODED AXIS. Its major point is up and
// to the right, so the minor grip must be up and to the LEFT by the minor radius - not
// straight up, which is what an axis read off the world rather than off the point gives.
var turned = new Sketch();
var diagonal = turned.AddEllipse( Vec2.Zero, new Vec2( 3f, 3f ), 2f );
var turnedHandle = SketchHandles.For( turned ).Single();
var expected = new Vec2( -MathF.Sqrt( 2f ), MathF.Sqrt( 2f ) );
Report.Check( "and follows the major axis when the ellipse is turned",
Near( turnedHandle.At, expected ), $"{turnedHandle.At.x}, {turnedHandle.At.y}" );
Report.Check( "the turned grip is still a minor radius from the centre",
Near( turnedHandle.At.Length, diagonal.MinorRadius ) );
// The arc grip has to be ON the arc, which for a quarter turn from east to north is the
// 45-degree point - not the middle of the chord, and not the far side of the circle.
var arcSketch = new Sketch();
var c = arcSketch.AddPoint( 0, 0 );
var start = arcSketch.AddPoint( 2, 0 );
var end = arcSketch.AddPoint( 0, 2 );
arcSketch.Add( new SketchArc( c, start, end ) );
var arcHandle = SketchHandles.For( arcSketch ).Single();
var diagonalPoint = new Vec2( MathF.Sqrt( 2f ), MathF.Sqrt( 2f ) );
Report.Check( "the arc's grip is the middle of the arc itself",
Near( arcHandle.At, diagonalPoint ), $"{arcHandle.At.x}, {arcHandle.At.y}" );
// Same two endpoints, the other way round the circle. The grip must move with it, or the
// bulge of a clockwise arc is dragged by something sitting on the arc it is not.
var back = new Sketch();
var bc = back.AddPoint( 0, 0 );
var bs = back.AddPoint( 2, 0 );
var be = back.AddPoint( 0, 2 );
back.Add( new SketchArc( bc, bs, be, clockwise: true ) );
var backHandle = SketchHandles.For( back ).Single();
Report.Check( "and follows the sweep when the arc goes the other way",
Near( backHandle.At, diagonalPoint * -1f ), $"{backHandle.At.x}, {backHandle.At.y}" );
}
static void TestDraggingALine()
{
var sketch = new Sketch();
var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
var moved = SketchHandles.Drag( sketch, line.Id, CurveHandleKind.LineMiddle, new Vec2( 2, 3 ) );
Report.Check( "the drag reports a change", moved );
Report.Check( "both ends moved by the same amount",
Near( sketch.Points[line.Start], new Vec2( 0, 3 ) ) && Near( sketch.Points[line.End], new Vec2( 4, 3 ) ),
$"{sketch.Points[line.Start].x},{sketch.Points[line.Start].y} -> {sketch.Points[line.End].x},{sketch.Points[line.End].y}" );
Report.Check( "the middle landed exactly where it was dragged",
Near( SketchHandles.For( sketch ).Single().At, new Vec2( 2, 3 ) ) );
// A WALL OF A CLOSED PROFILE. Its corners are shared with the two walls either side, so
// dragging it must take their ends with it and leave the loop closed - the whole reason
// SketchCurve stores point indices rather than copies.
var square = new Sketch();
var walls = square.AddRectangle( new Vec2( 0, 0 ), new Vec2( 4, 4 ) );
var top = walls.First( w => Near( (square.Points[w.Start] + square.Points[w.End]) / 2f, new Vec2( 2, 4 ) ) );
SketchHandles.Drag( square, top.Id, CurveHandleKind.LineMiddle, new Vec2( 2, 6 ) );
var found = ProfileFinder.Find( square );
Report.Check( "dragging one wall of a rectangle keeps the profile closed",
found.Profiles.Count == 1 && found.OpenChains == 0,
$"{found.Profiles.Count} regions, {found.OpenChains} open chains" );
Report.Check( "and the rectangle got taller rather than coming apart",
square.Points.Count == 4 && Near( square.Points.Max( p => p.y ), 6f ),
$"{square.Points.Count} points, top at {square.Points.Max( p => p.y )}" );
}
static void TestDraggingAnArc()
{
var sketch = new Sketch();
var centre = sketch.AddPoint( 0, 0 );
var start = sketch.AddPoint( 2, 0 );
var end = sketch.AddPoint( -2, 0 );
var arc = sketch.Add( new SketchArc( centre, start, end ) );
var before = (Start: sketch.Points[start], End: sketch.Points[end]);
// Flatten it: a bulge of 1 over a chord of 4 is a much bigger circle than the half-circle
// it starts as.
var target = new Vec2( 0, 1 );
Report.Check( "the drag reports a change",
SketchHandles.Drag( sketch, arc.Id, CurveHandleKind.ArcBulge, target ) );
Report.Check( "the endpoints did not move",
Near( sketch.Points[start], before.Start ) && Near( sketch.Points[end], before.End ) );
var c = sketch.Points[centre];
var radius = (sketch.Points[start] - c).Length;
Report.Check( "the arc now passes through the point it was dragged to",
Near( (target - c).Length, radius ), $"radius {radius}, target at {(target - c).Length}" );
// The solver adds this as an implicit rule on every arc, so an arc that does not already
// satisfy it is one the first constraint anyone applies will visibly kink.
Report.Check( "and both endpoints are still the same distance from the centre",
Near( (sketch.Points[end] - c).Length, radius ) );
Report.Check( "a flatter arc has its centre pushed away from the chord", c.y < -1f,
$"centre at {c.x}, {c.y}" );
// ACROSS THE CHORD. The same circle through the same two endpoints is two arcs, and
// dragging the bulge to the other side must give the near one rather than the long way
// round - which is what happens if the direction flag is left alone.
var flipped = SketchHandles.Drag( sketch, arc.Id, CurveHandleKind.ArcBulge, new Vec2( 0, -1 ) );
Report.Check( "dragging the bulge across the chord flips the arc rather than inverting it",
flipped && arc.Clockwise );
var tessellated = arc.Tessellate( sketch, 0.001f );
// AGAINST THE SEGMENTS, NOT THE SAMPLES. A polyline's vertices are as far apart as the
// tessellation tolerance allows, so the nearest VERTEX to a point exactly on the arc is
// half a segment away and always will be - measuring that way fails a correct arc and
// tightening the tolerance only moves the number.
var nearest = Enumerable.Range( 0, tessellated.Count - 1 )
.Min( i => ToSegment( new Vec2( 0, -1 ), tessellated[i], tessellated[i + 1] ) );
Report.Check( "and the drawn arc really does pass through the cursor", Near( nearest, 0f, 0.01f ),
$"nearest approach {nearest}" );
Report.Check( "the flipped arc still ends where it started",
Near( tessellated[0], before.Start ) && Near( tessellated[^1], before.End ) );
}
static void TestCircleAndEllipse()
{
var sketch = new Sketch();
var circle = sketch.AddCircle( new Vec2( 1, 1 ), 2f );
Report.Check( "dragging the rim sets the radius to the distance from the centre",
SketchHandles.Drag( sketch, circle.Id, CurveHandleKind.CircleRim, new Vec2( 1, 6 ) )
&& Near( circle.Radius, 5f ), $"radius {circle.Radius}" );
Report.Check( "the centre stayed put", Near( sketch.Points[circle.Center], new Vec2( 1, 1 ) ) );
var ellipse = sketch.AddEllipse( new Vec2( 0, 0 ), new Vec2( 4, 0 ), 2f );
var major = sketch.Points[ellipse.MajorPoint];
Report.Check( "dragging the minor grip sets the minor radius",
SketchHandles.Drag( sketch, ellipse.Id, CurveHandleKind.EllipseMinor, new Vec2( 0, 3 ) )
&& Near( ellipse.MinorRadius, 3f ), $"minor {ellipse.MinorRadius}" );
Report.Check( "the major axis is untouched by it",
Near( sketch.Points[ellipse.MajorPoint], major ) );
// ONLY THE COMPONENT ACROSS THE AXIS COUNTS. Taking the raw distance would read this drag -
// a long way along the major axis and a whisker off it - as a huge minor radius.
Report.Check( "sliding the grip along the major axis barely moves the minor radius",
SketchHandles.Drag( sketch, ellipse.Id, CurveHandleKind.EllipseMinor, new Vec2( 20, 3 ) ) == false
|| Near( ellipse.MinorRadius, 3f ), $"minor {ellipse.MinorRadius}" );
}
static void TestRefusals()
{
var sketch = new Sketch();
var centre = sketch.AddPoint( 0, 0 );
var start = sketch.AddPoint( 2, 0 );
var end = sketch.AddPoint( -2, 0 );
var arc = sketch.Add( new SketchArc( centre, start, end ) );
var before = sketch.Points[centre];
Report.Check( "an arc bulged flat onto its own chord is refused",
!SketchHandles.Drag( sketch, arc.Id, CurveHandleKind.ArcBulge, new Vec2( 0, 0.0000001f ) )
&& Near( sketch.Points[centre], before ) );
var circle = sketch.AddCircle( new Vec2( 5, 5 ), 2f );
Report.Check( "a circle dragged to zero radius is refused",
!SketchHandles.Drag( sketch, circle.Id, CurveHandleKind.CircleRim, new Vec2( 5, 5 ) )
&& Near( circle.Radius, 2f ) );
Report.Check( "a drag against a curve id that is gone is refused",
!SketchHandles.Drag( sketch, "nosuchid", CurveHandleKind.LineMiddle, new Vec2( 1, 1 ) ) );
var line = sketch.AddLine( new Vec2( 0, 9 ), new Vec2( 2, 9 ) );
Report.Check( "a drag that asks for no movement is refused",
!SketchHandles.Drag( sketch, line.Id, CurveHandleKind.LineMiddle, new Vec2( 1, 9 ) ) );
// THE PIN IS A POINT THE DRAG IS NOT MOVING. Pinning something the drag moves would fight
// the hand: the solver holds it still while the cursor pulls it, and the sketch shears.
Report.Check( "an arc drag pins one of the endpoints it keeps still",
SketchHandles.Pin( sketch, arc.Id, CurveHandleKind.ArcBulge ) == arc.Start );
Report.Check( "a circle drag pins its centre",
SketchHandles.Pin( sketch, circle.Id, CurveHandleKind.CircleRim ) == circle.Center );
}
}