Unit test suite for sketch editing operations. It constructs sketches and asserts behavior of fillet, trim, extend, offset, cut and mirror tools by checking geometric outcomes and error messages.
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy.Tests;
/// <summary>
/// Trim, extend, fillet and offset — the edits that make a sketcher usable rather than
/// write-only. Before these, a line drawn wrong could only be deleted and drawn again.
///
/// Each one is checked by the GEOMETRY IT PROMISES rather than by the curve count it happens to
/// leave behind. A fillet's arc has to be genuinely tangent to both lines, not merely present; an
/// offset has to sit the requested distance away along its whole length, not just at its ends. A
/// count-only test passes for an operation that produces the right number of wrong curves, which is
/// the most likely way for any of this to be broken.
/// </summary>
public static class SketchEditTests
{
public static void Run()
{
Report.Section( "sketch fillet" );
TestFillet();
Report.Section( "sketch trim" );
TestTrim();
Report.Section( "sketch extend" );
TestExtend();
Report.Section( "sketch offset" );
TestOffset();
Report.Section( "sketch cut stroke" );
TestCut();
Report.Section( "sketch mirror" );
TestMirror();
}
static void TestFillet()
{
// A right angle at the origin, arms four units long.
var sketch = new Sketch();
var corner = sketch.AddPoint( 0f, 0f );
var right = sketch.AddPoint( 4f, 0f );
var up = sketch.AddPoint( 0f, 4f );
var armA = sketch.Add( new SketchLine( corner, right ) );
var armB = sketch.Add( new SketchLine( corner, up ) );
var ok = SketchEdit.Fillet( sketch, corner, 1f, out var error );
Report.Check( "a right angle rounds", ok, error );
var arc = sketch.Curves.OfType<SketchArc>().FirstOrDefault();
Report.Check( "and leaves an arc behind", arc is not null );
if ( arc is null )
return;
var centre = sketch.Points[arc.Center];
var radius = arc.Radius( sketch );
Report.Check( "of the radius that was asked for", MathF.Abs( radius - 1f ) < 1e-4f,
$"{radius:0.#####}" );
// A right angle rounded by 1 puts the centre at (1,1) and the tangent points at (1,0) and
// (0,1). Those are worth asserting outright, because they are the closed-form answer.
Report.Check( "centred where the closed form says", (centre - new Vec2( 1f, 1f )).Length < 1e-4f,
centre.ToString() );
// TANGENCY IS THE ACTUAL CLAIM. The centre must be exactly a radius from each arm's line,
// which is what makes the join smooth rather than merely connected.
var gapA = DistanceToSegment( centre, sketch.Points[armA.Start], sketch.Points[armA.End] );
var gapB = DistanceToSegment( centre, sketch.Points[armB.Start], sketch.Points[armB.End] );
Report.Check( "and genuinely tangent to both arms, not just touching them",
MathF.Abs( gapA - radius ) < 1e-3f && MathF.Abs( gapB - radius ) < 1e-3f,
$"arm gaps {gapA:0.#####} and {gapB:0.#####} against radius {radius:0.#####}" );
// The arms must still reach the arc: its ends are their new ends.
var arcStart = sketch.Points[arc.Start];
var arcEnd = sketch.Points[arc.End];
var armEnds = new[]
{
sketch.Points[armA.Start], sketch.Points[armA.End],
sketch.Points[armB.Start], sketch.Points[armB.End]
};
Report.Check( "and the arms were shortened onto its ends, leaving no gap",
armEnds.Any( p => (p - arcStart).Length < 1e-4f ) && armEnds.Any( p => (p - arcEnd).Length < 1e-4f ) );
Report.Check( "the far ends of the arms did not move",
armEnds.Any( p => (p - new Vec2( 4f, 0f )).Length < 1e-5f ) &&
armEnds.Any( p => (p - new Vec2( 0f, 4f )).Length < 1e-5f ) );
// A fillet bigger than the lines can carry has to be refused, not clamped: clamping would
// silently give a different radius than the number typed.
var tight = new Sketch();
var tightCorner = tight.AddPoint( 0f, 0f );
tight.Add( new SketchLine( tightCorner, tight.AddPoint( 1f, 0f ) ) );
tight.Add( new SketchLine( tightCorner, tight.AddPoint( 0f, 1f ) ) );
Report.Check( "a radius too big for its corner is refused rather than clamped",
!SketchEdit.Fillet( tight, tightCorner, 5f, out var tightError ) &&
tightError.Contains( "available" ), tightError );
var straight = new Sketch();
var mid = straight.AddPoint( 0f, 0f );
straight.Add( new SketchLine( mid, straight.AddPoint( 1f, 0f ) ) );
straight.Add( new SketchLine( mid, straight.AddPoint( -1f, 0f ) ) );
Report.Check( "two collinear lines have no corner to round",
!SketchEdit.Fillet( straight, mid, 0.2f, out var straightError ), straightError );
var crowded = new Sketch();
var hub = crowded.AddPoint( 0f, 0f );
crowded.Add( new SketchLine( hub, crowded.AddPoint( 1f, 0f ) ) );
crowded.Add( new SketchLine( hub, crowded.AddPoint( 0f, 1f ) ) );
crowded.Add( new SketchLine( hub, crowded.AddPoint( -1f, 0f ) ) );
Report.Check( "three lines at a corner is ambiguous and says so",
!SketchEdit.Fillet( crowded, hub, 0.2f, out var crowdedError ) &&
crowdedError.Contains( "ambiguous" ), crowdedError );
}
static void TestTrim()
{
// A horizontal line crossed by a vertical one at x = 1. Picking left of the crossing must
// remove the left piece and leave the right.
var sketch = new Sketch();
var line = sketch.Add( new SketchLine( sketch.AddPoint( -2f, 0f ), sketch.AddPoint( 4f, 0f ) ) );
sketch.Add( new SketchLine( sketch.AddPoint( 1f, -2f ), sketch.AddPoint( 1f, 2f ) ) );
Report.Check( "a crossed line trims", SketchEdit.Trim( sketch, line, new Vec2( -1f, 0f ), out var error ), error );
Report.Check( "the piece under the pick is the piece that goes",
MathF.Abs( sketch.Points[line.Start].x - 1f ) < 1e-4f &&
MathF.Abs( sketch.Points[line.End].x - 4f ) < 1e-4f,
$"{sketch.Points[line.Start]} to {sketch.Points[line.End]}" );
// Picking the other side of the same crossing takes the other piece.
var mirror = new Sketch();
var across = mirror.Add( new SketchLine( mirror.AddPoint( -2f, 0f ), mirror.AddPoint( 4f, 0f ) ) );
mirror.Add( new SketchLine( mirror.AddPoint( 1f, -2f ), mirror.AddPoint( 1f, 2f ) ) );
SketchEdit.Trim( mirror, across, new Vec2( 3f, 0f ), out _ );
Report.Check( "and picking the other side takes the other piece",
MathF.Abs( mirror.Points[across.Start].x + 2f ) < 1e-4f &&
MathF.Abs( mirror.Points[across.End].x - 1f ) < 1e-4f,
$"{mirror.Points[across.Start]} to {mirror.Points[across.End]}" );
// Crossed twice, picked in the middle: the curve splits rather than losing an end.
var twice = new Sketch();
var spine = twice.Add( new SketchLine( twice.AddPoint( -4f, 0f ), twice.AddPoint( 4f, 0f ) ) );
twice.Add( new SketchLine( twice.AddPoint( -1f, -2f ), twice.AddPoint( -1f, 2f ) ) );
twice.Add( new SketchLine( twice.AddPoint( 1f, -2f ), twice.AddPoint( 1f, 2f ) ) );
SketchEdit.Trim( twice, spine, new Vec2( 0f, 0f ), out _ );
var pieces = twice.Curves.OfType<SketchLine>()
.Where( l => MathF.Abs( twice.Points[l.Start].y ) < 1e-6f && MathF.Abs( twice.Points[l.End].y ) < 1e-6f )
.ToList();
Report.Check( "a bite out of the middle leaves two pieces, not one", pieces.Count == 2,
$"{pieces.Count} pieces" );
Report.Check( "and neither piece covers the bite",
pieces.All( p => MathF.Min( twice.Points[p.Start].x, twice.Points[p.End].x ) >= 1f - 1e-3f ||
MathF.Max( twice.Points[p.Start].x, twice.Points[p.End].x ) <= -1f + 1e-3f ),
string.Join( "; ", pieces.Select( p => $"{twice.Points[p.Start]}->{twice.Points[p.End]}" ) ) );
// A curve crossing nothing has no piece to cut, so trimming it removes it outright.
var lonely = new Sketch();
var only = lonely.Add( new SketchLine( lonely.AddPoint( 0f, 0f ), lonely.AddPoint( 1f, 1f ) ) );
SketchEdit.Trim( lonely, only, new Vec2( 0.5f, 0.5f ), out _ );
Report.Check( "a line crossing nothing is removed outright", lonely.Curves.Count == 0,
$"{lonely.Curves.Count} curves left" );
// A circle has no ends, so a trim turns it into an arc rather than shortening it.
var round = new Sketch();
var circle = round.AddCircle( new Vec2( 0f, 0f ), 2f );
round.Add( new SketchLine( round.AddPoint( -3f, 1f ), round.AddPoint( 3f, 1f ) ) );
var trimmedCircle = SketchEdit.Trim( round, circle, new Vec2( 0f, 2f ), out var circleError );
Report.Check( "a circle crossed twice trims", trimmedCircle, circleError );
Report.Check( "and becomes an arc rather than staying a circle",
!round.Curves.OfType<SketchCircle>().Any() && round.Curves.OfType<SketchArc>().Count() == 1,
$"{round.Curves.OfType<SketchCircle>().Count()} circles, {round.Curves.OfType<SketchArc>().Count()} arcs" );
var survivor = round.Curves.OfType<SketchArc>().First();
Report.Check( "the arc kept is the one away from the pick",
round.Points[survivor.Start].y < 1f + 1e-3f && round.Points[survivor.End].y < 1f + 1e-3f,
$"{round.Points[survivor.Start]} to {round.Points[survivor.End]}" );
var spline = new Sketch();
var wiggle = spline.AddSpline( false, new Vec2( 0f, 0f ), new Vec2( 1f, 1f ), new Vec2( 2f, 0f ) );
Report.Check( "trimming a spline is refused with a reason rather than a wrong answer",
!SketchEdit.Trim( spline, wiggle, new Vec2( 1f, 1f ), out var splineError ) &&
splineError.Contains( "not supported" ), splineError );
}
static void TestExtend()
{
// A stub pointing at a wall it does not reach.
var sketch = new Sketch();
var stub = sketch.Add( new SketchLine( sketch.AddPoint( 0f, 0f ), sketch.AddPoint( 1f, 0f ) ) );
sketch.Add( new SketchLine( sketch.AddPoint( 3f, -2f ), sketch.AddPoint( 3f, 2f ) ) );
Report.Check( "a line extends to what it points at",
SketchEdit.Extend( sketch, stub, atStart: false, out var error ), error );
Report.Check( "landing exactly on the crossing",
(sketch.Points[stub.End] - new Vec2( 3f, 0f )).Length < 1e-4f,
sketch.Points[stub.End].ToString() );
Report.Check( "and leaving the other end alone",
(sketch.Points[stub.Start] - new Vec2( 0f, 0f )).Length < 1e-6f );
// TWO WALLS: the extend must stop at the NEARER one, or repeated extends jump the sketch.
var far = new Sketch();
var probe = far.Add( new SketchLine( far.AddPoint( 0f, 0f ), far.AddPoint( 1f, 0f ) ) );
far.Add( new SketchLine( far.AddPoint( 6f, -2f ), far.AddPoint( 6f, 2f ) ) );
far.Add( new SketchLine( far.AddPoint( 3f, -2f ), far.AddPoint( 3f, 2f ) ) );
SketchEdit.Extend( far, probe, atStart: false, out _ );
Report.Check( "stopping at the nearer of two walls, not the further",
MathF.Abs( far.Points[probe.End].x - 3f ) < 1e-4f, far.Points[probe.End].ToString() );
// Extending the START goes the other way.
var backwards = new Sketch();
var tail = backwards.Add( new SketchLine( backwards.AddPoint( 0f, 0f ), backwards.AddPoint( 1f, 0f ) ) );
backwards.Add( new SketchLine( backwards.AddPoint( -2f, -2f ), backwards.AddPoint( -2f, 2f ) ) );
SketchEdit.Extend( backwards, tail, atStart: true, out _ );
Report.Check( "extending the start goes the other way",
MathF.Abs( backwards.Points[tail.Start].x + 2f ) < 1e-4f,
backwards.Points[tail.Start].ToString() );
var nowhere = new Sketch();
var alone = nowhere.Add( new SketchLine( nowhere.AddPoint( 0f, 0f ), nowhere.AddPoint( 1f, 0f ) ) );
Report.Check( "a line pointing at nothing says so rather than flying off",
!SketchEdit.Extend( nowhere, alone, atStart: false, out var nowhereError ), nowhereError );
}
static void TestOffset()
{
// A single line offset to its left. The sign convention is the thing being pinned here.
var one = new Sketch();
var flat = one.Add( new SketchLine( one.AddPoint( 0f, 0f ), one.AddPoint( 4f, 0f ) ) );
Report.Check( "a single line offsets",
SketchEdit.Offset( one, new[] { (SketchCurve)flat }, 1f, out var made, out var error ), error );
var copy = (SketchLine)made[0];
Report.Check( "to the left of its direction of travel, by the distance asked for",
MathF.Abs( one.Points[copy.Start].y - 1f ) < 1e-4f &&
MathF.Abs( one.Points[copy.End].y - 1f ) < 1e-4f,
$"{one.Points[copy.Start]} to {one.Points[copy.End]}" );
Report.Check( "and a negative distance goes the other way",
SketchEdit.Offset( one, new[] { (SketchCurve)flat }, -1f, out var below, out _ ) &&
MathF.Abs( one.Points[((SketchLine)below[0]).Start].y + 1f ) < 1e-4f );
// AN OUTSIDE CORNER IS THE INTERESTING CASE: the two offset lines fall short of each other
// and the joint has to be closed by extending them to their crossing.
var elbow = new Sketch();
var a = elbow.AddPoint( 0f, 0f );
var b = elbow.AddPoint( 4f, 0f );
var c = elbow.AddPoint( 4f, 4f );
var first = elbow.Add( new SketchLine( a, b ) );
var second = elbow.Add( new SketchLine( b, c ) );
Report.Check( "a corner offsets",
SketchEdit.Offset( elbow, new SketchCurve[] { first, second }, 1f, out var elbowMade, out var elbowError ),
elbowError );
var outA = (SketchLine)elbowMade[0];
var outB = (SketchLine)elbowMade[1];
Report.Check( "and its corner is closed rather than left as two loose ends",
(elbow.Points[outA.End] - elbow.Points[outB.Start]).Length < 1e-4f,
$"{elbow.Points[outA.End]} against {elbow.Points[outB.Start]}" );
// Offsetting this corner one unit to the left puts the new corner at (3, 1), which is the
// closed-form answer and catches a joint stitched to the wrong crossing.
Report.Check( "at the point the closed form says",
(elbow.Points[outA.End] - new Vec2( 3f, 1f )).Length < 1e-3f,
elbow.Points[outA.End].ToString() );
// An arc offsets by changing radius, and which way depends on its sweep.
var curved = new Sketch();
var centre = curved.AddPoint( 0f, 0f );
var arcStart = curved.AddPoint( 2f, 0f );
var arcEnd = curved.AddPoint( 0f, 2f );
var quarter = curved.Add( new SketchArc( centre, arcStart, arcEnd ) );
Report.Check( "an arc offsets",
SketchEdit.Offset( curved, new SketchCurve[] { quarter }, 0.5f, out var arcMade, out var arcError ),
arcError );
var offsetArc = (SketchArc)arcMade[0];
Report.Check( "by changing its radius, toward the centre when it turns counter-clockwise",
MathF.Abs( offsetArc.Radius( curved ) - 1.5f ) < 1e-4f,
$"{offsetArc.Radius( curved ):0.#####}, wanted 1.5" );
Report.Check( "and an offset that would collapse an arc is refused",
!SketchEdit.Offset( curved, new SketchCurve[] { quarter }, 5f, out _, out var collapseError ) &&
collapseError.Contains( "collapses" ), collapseError );
var unsupported = new Sketch();
var wiggle = unsupported.AddSpline( false, new Vec2( 0f, 0f ), new Vec2( 1f, 1f ), new Vec2( 2f, 0f ) );
Report.Check( "offsetting a spline is refused with a reason",
!SketchEdit.Offset( unsupported, new SketchCurve[] { wiggle }, 1f, out _, out var wiggleError ) &&
wiggleError.Contains( "not supported" ), wiggleError );
}
/// <summary>
/// The cut stroke — one segment of a drag, and what it takes with it.
///
/// EVERY CASE HERE IS "WHAT DID THE STROKE LEAVE BEHIND", not "did the call return true". The
/// whole risk in a tool driven by a drag is that it takes slightly more or slightly less than
/// the thing you swiped through, and a call that succeeded says nothing about which.
/// </summary>
static void TestCut()
{
// A line crossing nothing has no piece smaller than itself, so a swipe through it takes it.
var lonely = new Sketch();
lonely.AddLine( new Vec2( 0f, 0f ), new Vec2( 4f, 0f ) );
Report.Check( "a stroke through a lone line takes it",
SketchCut.Cut( lonely, new Vec2( 2f, -1f ), new Vec2( 2f, 1f ) ) == 1 && lonely.Curves.Count == 0,
$"{lonely.Curves.Count} curves left" );
// And a stroke that goes nowhere near it takes nothing. An eraser that fires on a miss is
// worse than one that misses, because the drag is continuous and every frame of it is
// another chance.
var missed = new Sketch();
missed.AddLine( new Vec2( 0f, 0f ), new Vec2( 4f, 0f ) );
Report.Check( "a stroke that misses cuts nothing",
SketchCut.Cut( missed, new Vec2( 2f, 1f ), new Vec2( 2f, 3f ) ) == 0 && missed.Curves.Count == 1,
$"{missed.Curves.Count} curves left" );
// DRAWN ALONG A LINE RATHER THAN ACROSS IT. Collinear is not a crossing, and the whole
// difference between the two is one degenerate case in SegmentCross - so it is worth its own
// check: without it, dragging down an edge to reach the thing past it eats the edge.
var alongside = new Sketch();
alongside.AddLine( new Vec2( 0f, 0f ), new Vec2( 4f, 0f ) );
Report.Check( "a stroke drawn along a line does not eat it",
SketchCut.Cut( alongside, new Vec2( 1f, 0f ), new Vec2( 3f, 0f ) ) == 0 && alongside.Curves.Count == 1,
$"{alongside.Curves.Count} curves left" );
// THE CUT STOPS AT THE NEIGHBOURS, which is the whole difference between this and deleting
// whatever is under the cursor. A rectangle's edge ends at two corners, so swiping it takes
// that edge and leaves the other three - the shape opens up rather than vanishing.
var box = new Sketch();
box.AddRectangle( new Vec2( 0f, 0f ), new Vec2( 4f, 3f ) );
Report.Check( "a stroke across one edge of a rectangle takes that edge",
SketchCut.Cut( box, new Vec2( 2f, -1f ), new Vec2( 2f, 1f ) ) == 1 && box.Curves.Count == 3,
$"{box.Curves.Count} edges left" );
Report.Check( "and it is the edge that was crossed",
!box.Curves.OfType<SketchLine>().Any( l =>
MathF.Abs( box.Points[l.Start].y ) < 1e-4f && MathF.Abs( box.Points[l.End].y ) < 1e-4f ),
string.Join( "; ", box.Curves.OfType<SketchLine>()
.Select( l => $"{box.Points[l.Start]}->{box.Points[l.End]}" ) ) );
// A cut through the middle of a crossed line leaves the outer pieces standing - the same
// bite the Trim tool's click takes, because it is the same call underneath.
var cross = new Sketch();
cross.AddLine( new Vec2( -4f, 0f ), new Vec2( 4f, 0f ) );
cross.AddLine( new Vec2( -1f, -2f ), new Vec2( -1f, 2f ) );
cross.AddLine( new Vec2( 1f, -2f ), new Vec2( 1f, 2f ) );
Report.Check( "a stroke through a crossed line bites out the middle",
SketchCut.Cut( cross, new Vec2( 0f, -0.5f ), new Vec2( 0f, 0.5f ) ) == 1 );
var spans = cross.Curves.OfType<SketchLine>()
.Where( l => MathF.Abs( cross.Points[l.Start].y ) < 1e-6f && MathF.Abs( cross.Points[l.End].y ) < 1e-6f )
.ToList();
Report.Check( "leaving the two outer pieces rather than nothing", spans.Count == 2,
$"{spans.Count} pieces" );
Report.Check( "and neither piece covers the bite",
spans.All( p => MathF.Min( cross.Points[p.Start].x, cross.Points[p.End].x ) >= 1f - 1e-3f ||
MathF.Max( cross.Points[p.Start].x, cross.Points[p.End].x ) <= -1f + 1e-3f ),
string.Join( "; ", spans.Select( p => $"{cross.Points[p.Start]}->{cross.Points[p.End]}" ) ) );
// ONE SEGMENT, SEVERAL CURVES. A drag covers ground between frames, and the piece of path
// handed down can easily span three lines - so a segment has to cut all of what it went
// through rather than the first thing it found.
var fence = new Sketch();
for ( var i = 0; i < 3; i++ )
fence.AddLine( new Vec2( i, -1f ), new Vec2( i, 1f ) );
var crossings = SketchCut.Crossings( fence, new Vec2( -0.5f, 0f ), new Vec2( 2.5f, 0f ) );
Report.Check( "one segment finds every curve it went through", crossings.Count == 3,
$"{crossings.Count} crossings" );
Report.Check( "in the order the stroke reached them",
crossings.Count == 3 && crossings[0].At.x < crossings[1].At.x && crossings[1].At.x < crossings[2].At.x,
string.Join( "; ", crossings.Select( c => c.At.ToString() ) ) );
Report.Check( "and cuts all of them", SketchCut.Apply( fence, crossings ) == 3 && fence.Curves.Count == 0,
$"{fence.Curves.Count} curves left" );
// A closed curve crossed twice by ONE segment is reported once. Cutting at both is not a
// thing that can happen in one pass - the first cut replaces the circle with an arc, and the
// second crossing then names a curve the sketch no longer has.
var hoop = new Sketch();
hoop.AddCircle( new Vec2( 0f, 0f ), 2f );
Report.Check( "a segment through both sides of a circle reports it once",
SketchCut.Crossings( hoop, new Vec2( -3f, 1f ), new Vec2( 3f, 1f ) ).Count == 1 );
Report.Check( "and a circle crossing nothing else goes whole",
SketchCut.Cut( hoop, new Vec2( -3f, 1f ), new Vec2( 3f, 1f ) ) == 1 && hoop.Curves.Count == 0,
$"{hoop.Curves.Count} curves left" );
// The same circle with something to cut against keeps the part away from the stroke, which
// is Trim's rule and not a second one.
var capped = new Sketch();
capped.AddCircle( new Vec2( 0f, 0f ), 2f );
capped.AddLine( new Vec2( -3f, 1f ), new Vec2( 3f, 1f ) );
Report.Check( "a circle crossed by a line loses only the piece swiped through",
SketchCut.Cut( capped, new Vec2( 0f, 1.5f ), new Vec2( 0f, 2.5f ) ) == 1 &&
capped.Curves.OfType<SketchArc>().Count() == 1 && !capped.Curves.OfType<SketchCircle>().Any(),
$"{capped.Curves.OfType<SketchCircle>().Count()} circles, {capped.Curves.OfType<SketchArc>().Count()} arcs" );
var kept = capped.Curves.OfType<SketchArc>().First();
Report.Check( "and what is kept is the part away from the stroke",
capped.Points[kept.Start].y < 1f + 1e-3f && capped.Points[kept.End].y < 1f + 1e-3f,
$"{capped.Points[kept.Start]} to {capped.Points[kept.End]}" );
// Trim refuses splines, so the cut tool removes them rather than doing nothing - see
// SketchCut's header for why silence is the worse of the two answers.
var wiggly = new Sketch();
wiggly.AddSpline( false, new Vec2( 0f, 0f ), new Vec2( 1f, 2f ), new Vec2( 2f, 0f ) );
Report.Check( "a spline, which trim will not cut, goes whole",
SketchCut.Cut( wiggly, new Vec2( 1f, 0f ), new Vec2( 1f, 3f ) ) == 1 && wiggly.Curves.Count == 0,
$"{wiggly.Curves.Count} curves left" );
}
/// <summary>
/// Mirror, which is the one edit here that ADDS geometry rather than reshaping what is there —
/// and the only one whose result has to survive being dragged afterwards.
///
/// So the checks come in two halves. The first is the reflection itself: the copies land where
/// the closed form says, an arc's sweep flips, and a point already on the axis is SHARED rather
/// than duplicated. The second is the part that makes it a mirror instead of a paste — the
/// Symmetric rules it leaves behind hold, and moving an original drags its reflection after it.
/// A test that only checked coordinates would pass for a mirror that falls apart on the first
/// drag, which is the most likely way for this to be broken.
/// </summary>
static void TestMirror()
{
// A vertical axis on x = 0, and an L drawn entirely to the right of it.
var sketch = new Sketch();
var axis = sketch.Add( new SketchLine( sketch.AddPoint( 0f, -5f ), sketch.AddPoint( 0f, 5f ) ) );
axis.Construction = true;
var foot = sketch.AddPoint( 1f, 0f );
var knee = sketch.AddPoint( 3f, 0f );
var top = sketch.AddPoint( 3f, 2f );
var shin = sketch.Add( new SketchLine( foot, knee ) );
var thigh = sketch.Add( new SketchLine( knee, top ) );
var ok = SketchEdit.Mirror( sketch, new[] { shin, thigh }, null, axis, out var created, out var error );
Report.Check( "two lines mirror across a vertical axis", ok, error );
Report.Check( "and produce one copy each", created.Count == 2, $"{created.Count} created" );
if ( !ok || created.Count != 2 )
return;
var copyShin = (SketchLine)created[0];
var copyThigh = (SketchLine)created[1];
Report.Check( "the copies land where the closed form says",
(sketch.Points[copyShin.Start] - new Vec2( -1f, 0f )).Length < 1e-5f &&
(sketch.Points[copyShin.End] - new Vec2( -3f, 0f )).Length < 1e-5f &&
(sketch.Points[copyThigh.End] - new Vec2( -3f, 2f )).Length < 1e-5f,
$"{sketch.Points[copyShin.Start]} {sketch.Points[copyShin.End]} {sketch.Points[copyThigh.End]}" );
// The copies share their corner with each other exactly the way the originals do. Mirroring
// each curve into its own fresh points would leave a corner that looks joined and comes
// apart the moment either half is dragged.
Report.Check( "and share their own corner by index, not by coordinate",
copyShin.End == copyThigh.Start, $"{copyShin.End} against {copyThigh.Start}" );
Report.Check( "the originals were not moved",
(sketch.Points[foot] - new Vec2( 1f, 0f )).Length < 1e-6f &&
(sketch.Points[top] - new Vec2( 3f, 2f )).Length < 1e-6f );
Report.Check( "the axis itself was not copied",
sketch.Curves.OfType<SketchLine>().Count( l => MathF.Abs( sketch.Points[l.Start].x ) < 1e-5f
&& MathF.Abs( sketch.Points[l.End].x ) < 1e-5f ) == 1 );
// THE PART THAT MAKES IT A MIRROR. Every mirrored point carries a Symmetric rule against
// the axis, so the solver knows the two halves belong to each other.
var symmetric = sketch.Constraints.Count( c => c.Kind == SketchConstraintKind.Symmetric );
Report.Check( "each mirrored point is held symmetric to its original",
symmetric == 3, $"{symmetric} symmetric constraints for 3 mirrored points" );
// Solving a sketch that is ALREADY symmetric must not move anything. If it does, the
// constraints disagree with the geometry the mirror just wrote, and every mirror in the
// editor would jump the moment anything else was constrained.
sketch.Solve();
Report.Check( "and the sketch as built already satisfies them, so solving moves nothing",
(sketch.Points[copyThigh.End] - new Vec2( -3f, 2f )).Length < 1e-3f,
sketch.Points[copyThigh.End].ToString() );
// Move the original's top corner and re-solve, pinned on the point that moved - which is
// what a drag does; see the Solve call in EffigyViewport.Sketching.cs.
//
// WHAT IS CHECKED IS THAT THE SYMMETRY STILL HOLDS, not that the copy landed on a
// coordinate worked out by hand. Nothing in this sketch is fixed, so the solver is free to
// meet the new position by moving the axis a little as well as the copy - which is
// ordinary parametric behaviour, and would be the answer Onshape gives for an
// unconstrained mirror line too. Asserting a hand-computed position would be asserting
// that the axis never moves, which nothing here promises.
sketch.Points[top] = new Vec2( 5f, 3f );
var solved = SketchSolver.Solve( sketch, top );
var axisA = sketch.Points[axis.Start];
var axisB = sketch.Points[axis.End];
var wanted = SketchEdit.Reflect( sketch.Points[top], axisA, axisB );
Report.Check( "moving an original drags its reflection after it",
solved.Converged && (sketch.Points[copyThigh.End] - wanted).Length < 1e-2f,
$"{sketch.Points[copyThigh.End]} against the reflection {wanted}" );
Report.Check( "and the point that was dragged stayed where it was put",
(sketch.Points[top] - new Vec2( 5f, 3f )).Length < 1e-4f, sketch.Points[top].ToString() );
TestMirrorOnAxis();
TestMirrorArcAndCircle();
TestMirrorRefusals();
}
/// <summary>A half profile drawn against the axis has to come back as ONE closed shape, which
/// only happens if the points sitting on the axis are shared rather than doubled.</summary>
static void TestMirrorOnAxis()
{
var sketch = new Sketch();
var axis = sketch.Add( new SketchLine( sketch.AddPoint( 0f, -5f ), sketch.AddPoint( 0f, 5f ) ) );
// Half a triangle: out from the axis, up, and back to the axis.
var bottom = sketch.AddPoint( 0f, 0f );
var side = sketch.AddPoint( 2f, 1f );
var apex = sketch.AddPoint( 0f, 2f );
var lower = sketch.Add( new SketchLine( bottom, side ) );
var upper = sketch.Add( new SketchLine( side, apex ) );
var before = sketch.Points.Count;
Report.Check( "a half profile drawn against the axis mirrors",
SketchEdit.Mirror( sketch, new[] { lower, upper }, null, axis, out var created, out var error ), error );
Report.Check( "and only the one point off the axis is duplicated",
sketch.Points.Count == before + 1, $"{sketch.Points.Count - before} points added" );
var copyLower = (SketchLine)created[0];
var copyUpper = (SketchLine)created[1];
Report.Check( "so the two halves meet at the same indices and the loop closes",
copyLower.Start == bottom && copyUpper.End == apex,
$"{copyLower.Start}/{bottom} and {copyUpper.End}/{apex}" );
Report.Check( "and a point on the axis gets no symmetry rule of its own",
sketch.Constraints.Count( c => c.Kind == SketchConstraintKind.Symmetric ) == 1 );
}
static void TestMirrorArcAndCircle()
{
var sketch = new Sketch();
var axis = sketch.Add( new SketchLine( sketch.AddPoint( 0f, -5f ), sketch.AddPoint( 0f, 5f ) ) );
var arc = sketch.Add( new SketchArc(
sketch.AddPoint( 2f, 0f ), sketch.AddPoint( 3f, 0f ), sketch.AddPoint( 2f, 1f ) ) );
var circle = sketch.Add( new SketchCircle( sketch.AddPoint( 4f, 1f ), 0.75f ) );
circle.Construction = true;
Report.Check( "an arc and a circle mirror",
SketchEdit.Mirror( sketch, new SketchCurve[] { arc, circle }, null, axis, out var created, out var error ),
error );
var copyArc = created.OfType<SketchArc>().FirstOrDefault();
var copyCircle = created.OfType<SketchCircle>().FirstOrDefault();
Report.Check( "the arc's sweep flips, because a reflection reverses handedness",
copyArc is not null && copyArc.Clockwise != arc.Clockwise );
if ( copyArc is not null )
{
// The reflected arc has to pass through the reflections of the points the original
// passes through, which is a stronger claim than its ends landing in the right place -
// and it is the one that catches a sweep left unflipped.
var original = arc.Tessellate( sketch, 0.001f );
var copy = copyArc.Tessellate( sketch, 0.001f );
var matched = original.Count == copy.Count && original.Count > 0;
for ( var i = 0; matched && i < original.Count; i++ )
matched = (copy[i] - new Vec2( -original[i].x, original[i].y )).Length < 1e-3f;
Report.Check( "and the copy traces the reflection of the original along its whole length", matched );
}
Report.Check( "a circle keeps its radius and reflects its centre",
copyCircle is not null && MathF.Abs( copyCircle.Radius - 0.75f ) < 1e-5f &&
(sketch.Points[copyCircle.Center] - new Vec2( -4f, 1f )).Length < 1e-5f );
Report.Check( "and construction geometry mirrors as construction geometry",
copyCircle is not null && copyCircle.Construction );
}
static void TestMirrorRefusals()
{
var sketch = new Sketch();
var axis = sketch.Add( new SketchLine( sketch.AddPoint( 0f, -1f ), sketch.AddPoint( 0f, 1f ) ) );
var line = sketch.Add( new SketchLine( sketch.AddPoint( 1f, 0f ), sketch.AddPoint( 2f, 0f ) ) );
Report.Check( "mirroring nothing is refused rather than silently doing nothing",
!SketchEdit.Mirror( sketch, null, null, axis, out _, out var emptyError ), emptyError );
Report.Check( "and so is a mirror line with no length",
!SketchEdit.Mirror( sketch, new[] { line }, null,
new SketchLine( axis.Start, axis.Start ), out _, out var shortError ), shortError );
// Selecting the axis along with everything else is the ordinary way to work - a box drawn
// round the whole sketch takes the centreline too - so it has to be dropped rather than
// reflected onto itself.
var count = sketch.Curves.Count;
Report.Check( "the axis in the selection is dropped, not copied onto itself",
SketchEdit.Mirror( sketch, new[] { axis, line }, null, axis, out var created, out var error )
&& created.Count == 1 && sketch.Curves.Count == count + 1,
error ?? $"{created?.Count} created" );
// A lone point - what the Point tool leaves behind - is geometry too, and mirrors with no
// curve attached to it.
var lone = new Sketch();
var loneAxis = lone.Add( new SketchLine( lone.AddPoint( 0f, -1f ), lone.AddPoint( 0f, 1f ) ) );
var dot = lone.AddPoint( 3f, 1f );
Report.Check( "a lone point mirrors on its own",
SketchEdit.Mirror( lone, null, new[] { dot }, loneAxis, out _, out var loneError ) &&
lone.Points.Any( p => (p - new Vec2( -3f, 1f )).Length < 1e-5f ), loneError );
}
static float DistanceToSegment( Vec2 p, Vec2 a, Vec2 b )
{
var d = b - a;
if ( d.LengthSquared < 1e-12f )
return (p - a).Length;
var t = Math.Clamp( Vec2.Dot( p - a, d ) / d.LengthSquared, 0f, 1f );
return (p - (a + d * t)).Length;
}
}