Effigy.Tests/SketchEditTests.cs
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;
	}
}