Unit tests for the constraint-tool UI and solver integration. Exercises which constraint offers are produced for various selections, applies offers, runs the solver, and asserts geometry, markers, duplicates, refusal behaviour and special cases (circle radius, unreachable rules, touching, markers).
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy.Tests;
/// <summary>
/// Turning a selection into a constraint.
///
/// The solver has been able to satisfy eleven kinds of rule for several sessions and there has been
/// no way to add one in the editor, so it only ever ran on what the drawing inference happened to
/// put there. ConstraintTools is the missing half, and it is all rules and no drawing — which is
/// where the mistakes are, and why it can be tested without a viewport.
///
/// These check three things in order: that the right things are offered for a given selection, that
/// applying one and solving actually moves the geometry the way the label promised, and that the
/// same rule cannot be added twice however it is phrased.
/// </summary>
public static class ConstraintToolTests
{
public static void Run()
{
Report.Section( "constraint tools: what a selection allows" );
TestOffers();
Report.Section( "constraint tools: applying one does what the label says" );
TestApplied();
Report.Section( "constraint tools: dimensions open on the truth" );
TestMeasured();
Report.Section( "constraint tools: the same rule is not offered twice" );
TestDuplicates();
Report.Section( "constraint tools: what a circle's radius really is" );
TestCircleRadius();
Report.Section( "constraint tools: the six kinds that had no way in" );
TestTheUnreachableSix();
Report.Section( "constraint tools: a rule that cannot hold is taken back out" );
TestRefused();
Report.Section( "constraint tools: marks to draw on the sketch" );
TestMarkers();
Report.Section( "constraint tools: finding what holds a point" );
TestTouching();
}
static void TestOffers()
{
var sketch = new Sketch();
var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 1 ) );
var one = Labels( sketch, new SketchSelection( null, new[] { line.Id } ) );
Report.Check( "one line offers horizontal, vertical and a length",
one.SequenceEqual( new[] { "Horizontal", "Length", "Vertical" } ), string.Join( ", ", one ) );
var second = sketch.AddLine( new Vec2( 0, 3 ), new Vec2( 4, 5 ) );
var two = Labels( sketch, new SketchSelection( null, new[] { line.Id, second.Id } ) );
Report.Check( "two lines offer the pair rules",
two.SequenceEqual( new[] { "Angle", "Equal length", "Parallel", "Perpendicular" } ),
string.Join( ", ", two ) );
var points = new SketchSelection( new[] { line.Start, second.Start } );
var pair = Labels( sketch, points );
Report.Check( "two points offer coincident, distance and the two alignments",
pair.SequenceEqual( new[] { "Coincident", "Distance", "Horizontal", "Vertical" } ),
string.Join( ", ", pair ) );
// A POINT AND A LINE, not three loose points. Three points also describe "one lies on the
// other two", and then which one is the point is a guess dressed up as a convention.
//
// Midpoint takes the same selection, being point-on-line said more exactly.
var onLine = Labels( sketch, new SketchSelection( new[] { second.Start }, new[] { line.Id } ) );
Report.Check( "a point and a line offer point-on-line and midpoint",
onLine.SequenceEqual( new[] { "Midpoint", "Point on line" } ), string.Join( ", ", onLine ) );
var mirror = Labels( sketch,
new SketchSelection( new[] { second.Start, second.End }, new[] { line.Id } ) );
Report.Check( "two points and a line offer symmetric",
mirror.SequenceEqual( new[] { "Symmetric" } ), string.Join( ", ", mirror ) );
Report.Check( "nothing selected offers nothing",
ConstraintTools.Offers( sketch, new SketchSelection() ).Count == 0 );
Report.Check( "a selection that means nothing offers nothing",
ConstraintTools.Offers( sketch,
new SketchSelection( new[] { line.Start, line.End, second.Start } ) ).Count == 0 );
}
/// <summary>
/// The part that matters: apply the offer, solve, and check the geometry obeys.
///
/// A test that only checked which constraint object came out would pass just as well if the
/// point indices in it were wrong, and wrong indices are the single most likely mistake in the
/// whole file.
/// </summary>
static void TestApplied()
{
// HORIZONTAL on a line that is not.
var sketch = new Sketch();
var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 2 ) );
Apply( sketch, new SketchSelection( null, new[] { line.Id } ), "Horizontal" );
var solved = SketchSolver.Solve( sketch );
Report.Check( "horizontal converges", solved.Converged, $"residual {solved.Residual:0.000000}" );
Report.Check( "and the line comes out level",
MathF.Abs( sketch.Points[line.Start].y - sketch.Points[line.End].y ) < 1e-3f,
$"ends at y {sketch.Points[line.Start].y:0.####} and {sketch.Points[line.End].y:0.####}" );
// PERPENDICULAR between two lines that are not.
var corner = new Sketch();
var a = corner.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
var b = corner.AddLine( new Vec2( 0, 0 ), new Vec2( 3, 1 ) );
Apply( corner, new SketchSelection( null, new[] { a.Id, b.Id } ), "Perpendicular" );
SketchSolver.Solve( corner );
var u = corner.Points[a.End] - corner.Points[a.Start];
var v = corner.Points[b.End] - corner.Points[b.Start];
Report.Check( "perpendicular gives a right angle",
MathF.Abs( Vec2.Dot( u.Normal, v.Normal ) ) < 1e-3f,
$"dot {Vec2.Dot( u.Normal, v.Normal ):0.#####}" );
// DISTANCE, driven to a number the sketch was not.
var span = new Sketch();
var line2 = span.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
var offer = ConstraintTools.Offers( span, new SketchSelection( null, new[] { line2.Id } ) )
.Single( o => o.Label == "Length" );
offer.Value = 10f;
ConstraintTools.Apply( span, offer );
SketchSolver.Solve( span );
Report.Check( "a driven length reaches the number it was given",
MathF.Abs( (span.Points[line2.End] - span.Points[line2.Start]).Length - 10f ) < 1e-3f,
$"came out {(span.Points[line2.End] - span.Points[line2.Start]).Length:0.####}" );
// POINT ON LINE, with the right point of the three.
var onto = new Sketch();
var rail = onto.AddLine( new Vec2( 0, 0 ), new Vec2( 10, 0 ) );
var loose = onto.AddPoint( new Vec2( 5, 4 ) );
Apply( onto, new SketchSelection( new[] { loose }, new[] { rail.Id } ), "Point on line" );
var landed = SketchSolver.Solve( onto );
Report.Check( "point on line converges", landed.Converged, $"residual {landed.Residual:0.000000}" );
// AGAINST THE LINE WHERE IT ENDED UP, not against where it started. The first version of this
// checked that the point's y reached zero and the rail's y stayed there, and both failed: an
// under-constrained sketch is free to meet a new rule by moving EITHER side of it, and least
// squares moves both. That is correct — the rule says the point is on the line, and says
// nothing about which of them has to give — so the oracle is the relation, not the fixture's
// assumption about who moves.
Report.Check( "the point ends up on the line",
DistanceToLine( onto, loose, rail.Start, rail.End ) < 1e-3f,
$"{DistanceToLine( onto, loose, rail.Start, rail.End ):0.######} off it" );
// It did have to move: a test that passed on an untouched sketch would prove nothing.
Report.Check( "and something actually moved to get there",
(onto.Points[loose] - new Vec2( 5, 4 )).Length > 0.1f
|| (onto.Points[rail.End] - new Vec2( 10, 0 )).Length > 0.1f );
// SYMMETRIC about a line.
var mirror = new Sketch();
var axis = mirror.AddLine( new Vec2( 0, -5 ), new Vec2( 0, 5 ) );
var left = mirror.AddPoint( new Vec2( -3, 1 ) );
var right = mirror.AddPoint( new Vec2( 4, 2 ) );
Apply( mirror, new SketchSelection( new[] { left, right }, new[] { axis.Id } ), "Symmetric" );
var mirrored = SketchSolver.Solve( mirror );
Report.Check( "symmetric converges", mirrored.Converged, $"residual {mirrored.Residual:0.000000}" );
// Same lesson as above, and it bit harder here: the AXIS is free to rotate too, so checking
// that the two x values cancel assumes an axis that stayed vertical. What symmetry actually
// means is two things — their midpoint lies on the axis, and the line between them crosses it
// square — and both are true wherever the axis ended up.
var midpoint = (mirror.Points[left] + mirror.Points[right]) * 0.5f;
var toAxis = DistanceToLine( midpoint, mirror.Points[axis.Start], mirror.Points[axis.End] );
Report.Check( "the pair's midpoint sits on the axis", toAxis < 1e-3f, $"{toAxis:0.######} off it" );
var across = (mirror.Points[right] - mirror.Points[left]).Normal;
var along = (mirror.Points[axis.End] - mirror.Points[axis.Start]).Normal;
Report.Check( "and the line between them crosses it square",
MathF.Abs( Vec2.Dot( across, along ) ) < 1e-3f,
$"dot {Vec2.Dot( across, along ):0.#####}" );
// EQUAL RADIUS on two arcs.
var arcs = new Sketch();
var c1 = arcs.AddPoint( new Vec2( 0, 0 ) );
var s1 = arcs.AddPoint( new Vec2( 2, 0 ) );
var e1 = arcs.AddPoint( new Vec2( 0, 2 ) );
var c2 = arcs.AddPoint( new Vec2( 10, 0 ) );
var s2 = arcs.AddPoint( new Vec2( 15, 0 ) );
var e2 = arcs.AddPoint( new Vec2( 10, 5 ) );
var arcA = arcs.Add( new SketchArc( c1, s1, e1 ) );
var arcB = arcs.Add( new SketchArc( c2, s2, e2 ) );
Apply( arcs, new SketchSelection( null, new[] { arcA.Id, arcB.Id } ), "Equal radius" );
SketchSolver.Solve( arcs );
var r1 = (arcs.Points[s1] - arcs.Points[c1]).Length;
var r2 = (arcs.Points[s2] - arcs.Points[c2]).Length;
Report.Check( "equal radius makes two arcs the same size",
MathF.Abs( r1 - r2 ) < 1e-3f, $"{r1:0.####} and {r2:0.####}" );
}
static void TestMeasured()
{
var sketch = new Sketch();
var line = sketch.AddLine( new Vec2( 1, 1 ), new Vec2( 4, 5 ) );
var length = ConstraintTools.Offers( sketch, new SketchSelection( null, new[] { line.Id } ) )
.Single( o => o.Label == "Length" );
// A DIMENSION OPENS ON THE TRUTH. Showing zero and making the user type what the sketch
// already is turns "lock this where it is" — which is most dimensions — into measuring by
// hand, and any rounding they do silently moves the geometry.
Report.Check( "a length dimension is pre-filled with the current length",
MathF.Abs( length.Value - 5f ) < 1e-4f, $"{length.Value:0.#####}" );
Report.Check( "and is marked as taking a number", length.NeedsValue );
Report.Check( "with no unit, since it is a length", length.Unit == "" );
var second = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 0, 3 ) );
var angle = ConstraintTools.Offers( sketch, new SketchSelection( null, new[] { line.Id, second.Id } ) )
.Single( o => o.Label == "Angle" );
// (3,4) against (0,1) is 90 - 53.13 = 36.87 degrees.
Report.Check( "an angle dimension is pre-filled with the current angle",
MathF.Abs( angle.Value - 36.8699f ) < 1e-2f, $"{angle.Value:0.####}" );
Report.Check( "and says it is in degrees", angle.Unit == "deg" );
Report.Check( "a plain rule carries no value",
ConstraintTools.Offers( sketch, new SketchSelection( null, new[] { line.Id, second.Id } ) )
.Single( o => o.Label == "Parallel" ).NeedsValue == false );
// Applying without touching the value locks what is there — the sketch must not move.
var before = sketch.Points[line.End];
ConstraintTools.Apply( sketch, length );
SketchSolver.Solve( sketch );
Report.Check( "applying a measured dimension unchanged leaves the sketch where it was",
(sketch.Points[line.End] - before).Length < 1e-3f );
}
static void TestDuplicates()
{
var sketch = new Sketch();
var a = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
var b = sketch.AddLine( new Vec2( 0, 2 ), new Vec2( 4, 3 ) );
var selection = new SketchSelection( null, new[] { a.Id, b.Id } );
Apply( sketch, selection, "Parallel" );
Report.Check( "the rule is on the sketch", sketch.Constraints.Count == 1 );
Report.Check( "and is no longer offered",
!Labels( sketch, selection ).Contains( "Parallel" ),
string.Join( ", ", Labels( sketch, selection ) ) );
// ORDER-INSENSITIVE. "A parallel to B" and "B parallel to A" are one rule, and offering the
// second is how a sketch quietly acquires the redundancy that makes the NEXT dimension
// appear to do nothing.
var swapped = new SketchSelection( null, new[] { b.Id, a.Id } );
Report.Check( "nor is it offered with the two lines the other way round",
!Labels( sketch, swapped ).Contains( "Parallel" ),
string.Join( ", ", Labels( sketch, swapped ) ) );
Report.Check( "applying it again is refused",
!ConstraintTools.Apply( sketch, new ConstraintOffer
{
Kind = SketchConstraintKind.Parallel,
Constraint = new SketchConstraint( SketchConstraintKind.Parallel, b.Start, b.End, a.Start, a.End ),
} ) );
Report.Check( "so the sketch still holds one", sketch.Constraints.Count == 1 );
// Reversing one line's own endpoints is the same segment too.
Report.Check( "nor with one line's ends reversed",
ConstraintTools.Has( sketch,
new SketchConstraint( SketchConstraintKind.Parallel, a.End, a.Start, b.Start, b.End ) ) );
// SYMMETRIC IS THE EXCEPTION, and has to be. Its first pair is what gets mirrored and its
// second is the mirror; swapping them is a different rule, not the same one phrased twice.
var mirror = new Sketch();
var axis = mirror.AddLine( new Vec2( 0, -5 ), new Vec2( 0, 5 ) );
var p = mirror.AddPoint( new Vec2( -3, 0 ) );
var q = mirror.AddPoint( new Vec2( 3, 0 ) );
mirror.Constraints.Add( new SketchConstraint( SketchConstraintKind.Symmetric, p, q, axis.Start, axis.End ) );
Report.Check( "swapping a symmetric constraint's pairs is a different rule",
!ConstraintTools.Has( mirror,
new SketchConstraint( SketchConstraintKind.Symmetric, axis.Start, axis.End, p, q ) ) );
Report.Check( "but the same rule is still recognised",
ConstraintTools.Has( mirror,
new SketchConstraint( SketchConstraintKind.Symmetric, q, p, axis.End, axis.Start ) ) );
}
/// <summary>
/// A circle's radius is a stored float, not two points, so the solver has nothing to act on and
/// no radius constraint is offered for one. That is a real limitation and the honest thing is to
/// say so here rather than to offer a control that silently does nothing.
/// </summary>
static void TestCircleRadius()
{
var sketch = new Sketch();
var circle = sketch.AddCircle( new Vec2( 0, 0 ), 3f );
Report.Check( "a circle offers no radius constraint",
ConstraintTools.Offers( sketch, new SketchSelection( null, new[] { circle.Id } ) ).Count == 0 );
// An ARC does, because its radius is the distance between two of its points.
var arcSketch = new Sketch();
var centre = arcSketch.AddPoint( new Vec2( 0, 0 ) );
var start = arcSketch.AddPoint( new Vec2( 3, 0 ) );
var end = arcSketch.AddPoint( new Vec2( 0, 3 ) );
var arc = arcSketch.Add( new SketchArc( centre, start, end ) );
var offers = ConstraintTools.Offers( arcSketch, new SketchSelection( null, new[] { arc.Id } ) );
Report.Check( "an arc does", offers.Count == 2 && offers[0].Label == "Radius",
string.Join( ", ", offers.Select( o => o.Label ) ) );
Report.Check( "pre-filled with the radius it has", MathF.Abs( offers[0].Value - 3f ) < 1e-4f );
// RADIUS AND DIAMETER ARE ONE RULE WRITTEN TWO WAYS. Both are offered while the arc is
// undimensioned, and the number each opens on is its own.
Report.Check( "and a diameter alongside it", offers[1].Label == "Diameter" );
Report.Check( "opening on twice the radius", MathF.Abs( offers[1].Value - 6f ) < 1e-4f,
$"{offers[1].Value:0.###}" );
offers[0].Value = 5f;
ConstraintTools.Apply( arcSketch, offers[0] );
SketchSolver.Solve( arcSketch );
Report.Check( "and driving it moves the arc",
MathF.Abs( (arcSketch.Points[start] - arcSketch.Points[centre]).Length - 5f ) < 1e-3f,
$"{(arcSketch.Points[start] - arcSketch.Points[centre]).Length:0.####}" );
// The implicit arc invariant keeps the far end with it — an arc whose ends sit at different
// distances from its centre is not an arc.
Report.Check( "taking the other end with it",
MathF.Abs( (arcSketch.Points[end] - arcSketch.Points[centre]).Length - 5f ) < 1e-3f,
$"{(arcSketch.Points[end] - arcSketch.Points[centre]).Length:0.####}" );
// AND NOW NEITHER IS OFFERED. A sketch carrying both would solve and then report
// redundancy, which is a puzzling way to be told the arc was already dimensioned.
var after = Labels( arcSketch, new SketchSelection( null, new[] { arc.Id } ) );
Report.Check( "a dimensioned arc is offered neither of them again",
after.Count == 0, string.Join( ", ", after ) );
}
/// <summary>
/// The six kinds that solved, marked up and round-tripped through the file with no selection
/// anywhere that produced one.
///
/// OFFERED AND THEN APPLIED, in that order and never only the first. What a selection allows is
/// a table, and a table reads as correct while the point indices inside it are wrong; only the
/// geometry moving proves each constraint's slots were filled in the order it reads them.
/// </summary>
static void TestTheUnreachableSix()
{
// --- fix ------------------------------------------------------------------------------
var fixSketch = new Sketch();
var bar = fixSketch.AddLine( new Vec2( 2, 3 ), new Vec2( 6, 3 ) );
var single = Labels( fixSketch, new SketchSelection( new[] { bar.Start } ) );
Report.Check( "one point on its own offers a fix",
single.SequenceEqual( new[] { "Fix" } ), string.Join( ", ", single ) );
Apply( fixSketch, new SketchSelection( new[] { bar.Start } ), "Fix" );
// A FIX IS THE ONE RULE WHOSE VALUE IS A POSITION. Carrying only the x through the offer
// would nail the point to the right column of a sketch and to y = 0.
Report.Check( "a fix remembers both coordinates",
MathF.Abs( fixSketch.Constraints[0].Value - 2f ) < 1e-4f
&& MathF.Abs( fixSketch.Constraints[0].ValueY - 3f ) < 1e-4f,
$"{fixSketch.Constraints[0].Value:0.##}, {fixSketch.Constraints[0].ValueY:0.##}" );
fixSketch.Points[bar.Start] = new Vec2( 9, 9 );
SketchSolver.Solve( fixSketch, bar.End );
Report.Check( "and drags the point back where it was fixed",
(fixSketch.Points[bar.Start] - new Vec2( 2, 3 )).Length < 1e-3f,
$"({fixSketch.Points[bar.Start].x:0.##},{fixSketch.Points[bar.Start].y:0.##})" );
// --- midpoint -------------------------------------------------------------------------
var mid = new Sketch();
var rail = mid.AddLine( new Vec2( 0, 0 ), new Vec2( 10, 0 ) );
var free = mid.AddPoint( new Vec2( 1, 4 ) );
Apply( mid, new SketchSelection( new[] { free }, new[] { rail.Id } ), "Midpoint" );
SketchSolver.Solve( mid, rail.Start );
// Against the line WHEREVER IT ENDED UP, not against (5,0): the solver is free to move the
// line as well as the point, and the rule is about the relation rather than the place.
Report.Check( "midpoint puts the point half way along the line",
(mid.Points[free] - (mid.Points[rail.Start] + mid.Points[rail.End]) * 0.5f).Length < 1e-3f,
$"({mid.Points[free].x:0.##},{mid.Points[free].y:0.##})" );
// --- concentric -----------------------------------------------------------------------
//
// The one new rule a CIRCLE can take part in: it contributes its centre to the solve and
// nothing else, which is all a concentricity needs.
var conc = new Sketch();
var outer = conc.AddCircle( new Vec2( 0, 0 ), 3f );
var inner = conc.AddCircle( new Vec2( 4, 1 ), 1.5f );
var both = Labels( conc, new SketchSelection( null, new[] { outer.Id, inner.Id } ) );
Report.Check( "two circles offer concentric and nothing else",
both.SequenceEqual( new[] { "Concentric" } ), string.Join( ", ", both ) );
Apply( conc, new SketchSelection( null, new[] { outer.Id, inner.Id } ), "Concentric" );
SketchSolver.Solve( conc, outer.Center );
Report.Check( "and applying it brings their centres together",
(conc.Points[inner.Center] - conc.Points[outer.Center]).Length < 1e-3f,
$"{(conc.Points[inner.Center] - conc.Points[outer.Center]).Length:0.####}" );
// --- a line tangent to an arc -----------------------------------------------------------
var tangent = new Sketch();
var hub = tangent.AddPoint( new Vec2( 0, 0 ) );
var rim = tangent.AddPoint( new Vec2( 2, 0 ) );
var tail = tangent.AddPoint( new Vec2( 0, 2 ) );
var curve = tangent.Add( new SketchArc( hub, rim, tail ) );
var edge = tangent.AddLine( new Vec2( -4, 3 ), new Vec2( 4, 3 ) );
var lineAndArc = Labels( tangent, new SketchSelection( null, new[] { edge.Id, curve.Id } ) );
Report.Check( "a line and an arc offer a tangency",
lineAndArc.SequenceEqual( new[] { "Tangent" } ), string.Join( ", ", lineAndArc ) );
Apply( tangent, new SketchSelection( null, new[] { edge.Id, curve.Id } ), "Tangent" );
SketchSolver.Solve( tangent, hub );
var reach = (tangent.Points[rim] - tangent.Points[hub]).Length;
var standoff = DistanceToLine( tangent, hub, edge.Start, edge.End );
Report.Check( "and the line ends up exactly a radius from the centre",
MathF.Abs( standoff - reach ) < 1e-3f, $"{standoff:0.####} against a radius of {reach:0.####}" );
// --- two arcs tangent to each other -----------------------------------------------------
var pairSketch = new Sketch();
var leftHub = pairSketch.AddPoint( new Vec2( 0, 0 ) );
var leftRim = pairSketch.AddPoint( new Vec2( 2, 0 ) );
var leftTail = pairSketch.AddPoint( new Vec2( 0, 2 ) );
var left = pairSketch.Add( new SketchArc( leftHub, leftRim, leftTail ) );
var rightHub = pairSketch.AddPoint( new Vec2( 6, 0 ) );
var rightRim = pairSketch.AddPoint( new Vec2( 7, 0 ) );
var rightTail = pairSketch.AddPoint( new Vec2( 6, 1 ) );
var right = pairSketch.Add( new SketchArc( rightHub, rightRim, rightTail ) );
var twoArcs = Labels( pairSketch, new SketchSelection( null, new[] { left.Id, right.Id } ) );
Report.Check( "two arcs offer equal radius, concentric and a tangency",
twoArcs.SequenceEqual( new[] { "Concentric", "Equal radius", "Tangent" } ),
string.Join( ", ", twoArcs ) );
Apply( pairSketch, new SketchSelection( null, new[] { left.Id, right.Id } ), "Tangent" );
SketchSolver.Solve( pairSketch, leftHub );
var apart = (pairSketch.Points[rightHub] - pairSketch.Points[leftHub]).Length;
var leftRadius = (pairSketch.Points[leftRim] - pairSketch.Points[leftHub]).Length;
var rightRadius = (pairSketch.Points[rightRim] - pairSketch.Points[rightHub]).Length;
Report.Check( "two arcs drawn apart are made to touch on the outside",
MathF.Abs( apart - (leftRadius + rightRadius) ) < 1e-3f,
$"{apart:0.###} apart against radii of {leftRadius:0.###} and {rightRadius:0.###}" );
// WHICH TANGENCY IS READ OFF THE SKETCH. A small arc drawn inside a big one is asking for
// the internal kind, and offering the external one would shove it out through the wall.
var nested = new Sketch();
var bigHub = nested.AddPoint( new Vec2( 0, 0 ) );
var bigRim = nested.AddPoint( new Vec2( 5, 0 ) );
var bigTail = nested.AddPoint( new Vec2( 0, 5 ) );
var big = nested.Add( new SketchArc( bigHub, bigRim, bigTail ) );
var smallHub = nested.AddPoint( new Vec2( 1, 0 ) );
var smallRim = nested.AddPoint( new Vec2( 2, 0 ) );
var smallTail = nested.AddPoint( new Vec2( 1, 1 ) );
var small = nested.Add( new SketchArc( smallHub, smallRim, smallTail ) );
var selection = new SketchSelection( null, new[] { big.Id, small.Id } );
var internalOffer = ConstraintTools.Offers( nested, selection ).Single( o => o.Label == "Tangent" );
Report.Check( "an arc drawn inside another is offered the internal tangency",
internalOffer.Value != 0f, $"{internalOffer.Value}" );
ConstraintTools.Apply( nested, internalOffer );
// The flag lives in Value, which Apply writes from the offer - so this is also the check
// that a plain rule's value survives the trip through the offer rather than being zeroed.
Report.Check( "and the rule keeps that flag when it lands on the sketch",
nested.Constraints.Single().Value != 0f );
SketchSolver.Solve( nested, bigHub );
var gap = (nested.Points[smallHub] - nested.Points[bigHub]).Length;
var bigRadius = (nested.Points[bigRim] - nested.Points[bigHub]).Length;
var smallRadius = (nested.Points[smallRim] - nested.Points[smallHub]).Length;
Report.Check( "so it touches from the inside rather than being pushed out",
MathF.Abs( gap - MathF.Abs( bigRadius - smallRadius ) ) < 1e-3f,
$"{gap:0.###} apart against radii of {bigRadius:0.###} and {smallRadius:0.###}" );
}
static void TestTouching()
{
var sketch = new Sketch();
var a = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
var b = sketch.AddLine( new Vec2( 4, 0 ), new Vec2( 4, 3 ) );
Apply( sketch, new SketchSelection( null, new[] { a.Id } ), "Horizontal" );
Apply( sketch, new SketchSelection( null, new[] { b.Id } ), "Vertical" );
Apply( sketch, new SketchSelection( new[] { a.End, b.Start } ), "Coincident" );
Report.Check( "three rules on the sketch", sketch.Constraints.Count == 3 );
var atCorner = ConstraintTools.Touching( sketch, a.End );
Report.Check( "the corner point is held by the horizontal and the coincidence",
atCorner.Count == 2, $"{atCorner.Count}" );
var onFirst = ConstraintTools.Touching( sketch, a );
Report.Check( "the first line is held by two rules", onFirst.Count == 2, $"{onFirst.Count}" );
Report.Check( "a point nothing holds comes back empty",
ConstraintTools.Touching( sketch, b.End ).Count == 1,
$"{ConstraintTools.Touching( sketch, b.End ).Count}" );
// Removing one is how a UI undoes a rule, and the sketch has to keep solving after it.
sketch.Constraints.Remove( atCorner[0] );
Report.Check( "the sketch still solves with one taken away",
SketchSolver.Solve( sketch ).Converged );
}
/// <summary>
/// A contradictory constraint must leave NOTHING behind — not the rule, and not the positions
/// the failed solve dragged the sketch through on its way to giving up.
///
/// The second half is the part that is easy to get wrong. Removing the rule and solving again
/// converges to *a* valid answer, which need not be the one that was on screen a moment ago, so
/// the user watches their geometry shift in response to an operation that reported failure.
/// </summary>
static void TestRefused()
{
var sketch = new Sketch();
var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
var length = ConstraintTools.Offers( sketch, new SketchSelection( null, new[] { line.Id } ) )
.Single( o => o.Label == "Length" );
length.Value = 6f;
Report.Check( "a first dimension applies", ConstraintTools.ApplyAndSolve( sketch, length ).Applied );
var settled = sketch.Points.Select( p => p ).ToList();
// A second length on the same line, saying something else. Both cannot be true.
var contradiction = new ConstraintOffer
{
Kind = SketchConstraintKind.Distance,
Label = "Length",
NeedsValue = true,
Value = 9f,
Constraint = new SketchConstraint( SketchConstraintKind.Distance, line.Start, line.End ),
};
var refused = ConstraintTools.ApplyAndSolve( sketch, contradiction );
Report.Check( "a contradictory dimension is refused", !refused.Applied );
Report.Check( "and says why", refused.Message is not null, refused.Message ?? "no message" );
Report.Check( "the rule is not left on the sketch", sketch.Constraints.Count == 1,
$"{sketch.Constraints.Count} constraints" );
Report.Check( "and the geometry is exactly where it was",
sketch.Points.Zip( settled, ( a, b ) => (a - b).Length ).All( d => d < 1e-6f ),
$"line is now {(sketch.Points[line.End] - sketch.Points[line.Start]).Length:0.####} long" );
Report.Check( "so the first dimension still holds",
MathF.Abs( (sketch.Points[line.End] - sketch.Points[line.Start]).Length - 6f ) < 1e-3f );
// A duplicate is refused too, and differently — it is not a contradiction, it is already true.
var duplicate = new ConstraintOffer
{
Kind = SketchConstraintKind.Distance,
Label = "Length",
Value = 6f,
Constraint = new SketchConstraint( SketchConstraintKind.Distance, line.End, line.Start ),
};
var already = ConstraintTools.ApplyAndSolve( sketch, duplicate );
Report.Check( "a duplicate is refused", !already.Applied );
Report.Check( "and says the sketch already has it",
already.Message is not null && already.Message.Contains( "already" ), already.Message ?? "" );
Report.Check( "without adding a second copy", sketch.Constraints.Count == 1 );
// A successful apply reports the freedom left, which is what a UI shows next.
var fresh = new Sketch();
var only = fresh.AddLine( new Vec2( 0, 0 ), new Vec2( 3, 1 ) );
var horizontal = ConstraintTools.Offers( fresh, new SketchSelection( null, new[] { only.Id } ) )
.Single( o => o.Label == "Horizontal" );
var applied = ConstraintTools.ApplyAndSolve( fresh, horizontal );
Report.Check( "a successful apply carries the solve with it",
applied.Applied && applied.Solve is not null );
Report.Check( "with the degrees of freedom left to report",
applied.Solve.DegreesOfFreedom > 0, $"{applied.Solve.DegreesOfFreedom}" );
}
/// <summary>
/// Where each rule's glyph goes.
///
/// This is geometry, so it is testable, and it is worth testing because getting it slightly
/// wrong produces a sketch covered in marks that are all in nearly the right place — which is
/// the kind of wrong nobody reports as a bug and everybody works around.
/// </summary>
static void TestMarkers()
{
var sketch = new Sketch();
var line = sketch.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
Apply( sketch, new SketchSelection( null, new[] { line.Id } ), "Horizontal" );
var marks = ConstraintTools.Markers( sketch );
Report.Check( "one rule on one line makes one mark", marks.Count == 1 );
Report.Check( "at the middle of the line it holds",
(marks[0].Anchor - new Vec2( 2, 0 )).Length < 1e-4f,
$"({marks[0].Anchor.x:0.##},{marks[0].Anchor.y:0.##})" );
Report.Check( "labelled for what it is", marks[0].Label == "H", marks[0].Label );
Report.Check( "and it points off the line rather than along it",
MathF.Abs( Vec2.Dot( marks[0].Away, new Vec2( 1, 0 ) ) ) < 1e-4f
&& MathF.Abs( marks[0].Away.Length - 1f ) < 1e-4f,
$"({marks[0].Away.x:0.##},{marks[0].Away.y:0.##})" );
Report.Check( "and carries the rule itself, so clicking it can delete it",
ReferenceEquals( marks[0].Constraint, sketch.Constraints[0] ) );
Report.Check( "a plain rule is not a dimension", !marks[0].IsDimension );
// A RULE ABOUT TWO SEGMENTS MARKS BOTH. One glyph between them would leave you guessing
// which pair it meant on a sketch with six lines in it.
var second = sketch.AddLine( new Vec2( 0, 3 ), new Vec2( 4, 3 ) );
Apply( sketch, new SketchSelection( null, new[] { line.Id, second.Id } ), "Equal length" );
var equals = ConstraintTools.Markers( sketch ).Where( m => m.Label == "=" ).ToList();
Report.Check( "equal length marks both lines", equals.Count == 2 );
Report.Check( "one on each, at their middles",
equals.Any( m => (m.Anchor - new Vec2( 2, 0 )).Length < 1e-4f )
&& equals.Any( m => (m.Anchor - new Vec2( 2, 3 )).Length < 1e-4f ) );
// A DIMENSION READS ITS VALUE.
var dim = new Sketch();
var bar = dim.AddLine( new Vec2( 0, 0 ), new Vec2( 7.5f, 0 ) );
Apply( dim, new SketchSelection( null, new[] { bar.Id } ), "Length" );
var length = ConstraintTools.Markers( dim ).Single();
Report.Check( "a dimension is labelled with its number",
length.Label == "7.500", length.Label );
Report.Check( "and says it is a dimension", length.IsDimension );
// AN ANGLE IS MARKED WHERE ITS LINES CROSS, which is where a person looks for it.
var corner = new Sketch();
var across = corner.AddLine( new Vec2( -4, 0 ), new Vec2( 4, 0 ) );
var up = corner.AddLine( new Vec2( 0, -3 ), new Vec2( 0, 5 ) );
Apply( corner, new SketchSelection( null, new[] { across.Id, up.Id } ), "Angle" );
var angle = ConstraintTools.Markers( corner ).Single();
Report.Check( "an angle sits at the crossing, not between the midpoints",
angle.Anchor.Length < 1e-4f, $"({angle.Anchor.x:0.###},{angle.Anchor.y:0.###})" );
Report.Check( "labelled in degrees", angle.Label.EndsWith( "\u00b0" ), angle.Label );
// Two lines that never touch still have an angle, out where the extended lines would meet —
// which is what the angle between them means.
var apart = new Sketch();
var flat = apart.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
var slanted = apart.AddLine( new Vec2( 6, 2 ), new Vec2( 10, 6 ) );
Apply( apart, new SketchSelection( null, new[] { flat.Id, slanted.Id } ), "Angle" );
var extended = ConstraintTools.Markers( apart ).Single();
Report.Check( "two lines that do not touch are marked where they would meet",
(extended.Anchor - new Vec2( 4, 0 )).Length < 1e-3f,
$"({extended.Anchor.x:0.###},{extended.Anchor.y:0.###})" );
// Parallel lines have no crossing at all, and must not produce a NaN.
var parallel = new Sketch();
var one = parallel.AddLine( new Vec2( 0, 0 ), new Vec2( 4, 0 ) );
var two = parallel.AddLine( new Vec2( 0, 2 ), new Vec2( 4, 2 ) );
parallel.Constraints.Add( new SketchConstraint( SketchConstraintKind.Angle,
one.Start, one.End, two.Start, two.End ) { Value = 0f } );
var noCrossing = ConstraintTools.Markers( parallel ).Single();
Report.Check( "parallel lines fall back to a real position rather than a NaN",
float.IsFinite( noCrossing.Anchor.x ) && float.IsFinite( noCrossing.Anchor.y ),
$"({noCrossing.Anchor.x},{noCrossing.Anchor.y})" );
// A RULE LEFT BEHIND BY A DELETED CURVE MARKS NOTHING, and must not throw. This is ordinary
// rather than exceptional — deleting a curve leaves its rules pointing at points that went
// with it, which is why the solver drops them too.
var stale = new Sketch();
stale.AddPoint( new Vec2( 0, 0 ) );
stale.Constraints.Add( new SketchConstraint( SketchConstraintKind.Distance, 0, 99, 5f ) );
Report.Check( "a rule pointing at a point that is gone marks nothing",
ConstraintTools.Markers( stale ).Count == 0 );
Report.Check( "an empty sketch marks nothing",
ConstraintTools.Markers( new Sketch() ).Count == 0 );
Report.Check( "and a null one does not throw",
ConstraintTools.Markers( null ).Count == 0 );
}
// --- helpers ------------------------------------------------------------------------------
/// <summary>Perpendicular distance from a point to the infinite line through two others — the
/// relation "on the line" actually asserts, wherever the line has ended up.</summary>
static float DistanceToLine( Sketch sketch, int point, int a, int b ) =>
DistanceToLine( sketch.Points[point], sketch.Points[a], sketch.Points[b] );
static float DistanceToLine( Vec2 p, Vec2 a, Vec2 b )
{
var along = b - a;
if ( along.Length < 1e-9f )
return (p - a).Length;
return MathF.Abs( Vec2.Cross( along, p - a ) ) / along.Length;
}
static List<string> Labels( Sketch sketch, SketchSelection selection ) =>
ConstraintTools.Offers( sketch, selection ).Select( o => o.Label ).OrderBy( s => s ).ToList();
static void Apply( Sketch sketch, SketchSelection selection, string label )
{
var offer = ConstraintTools.Offers( sketch, selection ).SingleOrDefault( o => o.Label == label );
if ( offer is null )
throw new InvalidOperationException( $"no offer labelled '{label}' for that selection" );
ConstraintTools.Apply( sketch, offer );
}
}