Files
OpenNest/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs
T
ajandClaude Opus 5.5 f0d84e041c test: share the circle, square, rectangle and contour fixtures
Cutting-planning, diagnostics and fill tests drew the same circles,
squares, clockwise rectangles, closed polygons, rounded rectangles and
right-midpoint rectangle contours in private helpers. They now use
TestHelpers; files whose helper had its own default size keep a
one-line wrapper for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:50:50 -05:00

221 lines
9.6 KiB
C#

using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Geometry;
using static OpenNest.Tests.TestHelpers;
namespace OpenNest.Tests.CuttingPlanning;
/// <summary>
/// The pure facing/tier/travel ranking of the entry catalog: deterministic lexicographic
/// order toward a look-ahead target (or toward arrival alone for the last part), with the
/// catalog itself untouched and entity order never meaningful.
/// </summary>
public class ContourEntryRankingTests
{
private static IReadOnlyList<ContourEntryCandidate> Catalog(Program program) =>
PreparedContours.Capture(program, ExplicitContourTests.Parameters())
.AutomaticEntryCandidatesWithFallbacks(0);
// --- fixtures ---------------------------------------------------------------
private static readonly Vector[] SquareVertices =
{
new(0, 0), new(10, 0), new(10, 10), new(0, 10),
};
private static Program BumpSquare()
{
// Half-circle bump on the top edge; tangent joints at (0,10) and (10,10).
var p = new Program();
p.MoveTo(0, 0);
p.LineTo(10, 0);
p.LineTo(10, 10);
p.ArcTo(new Vector(0, 10), new Vector(5, 10), RotationType.CCW);
p.LineTo(0, 0);
return p;
}
private static bool At(ContourEntryCandidate c, double x, double y) =>
c.Choice.Point.DistanceTo(new Vector(x, y)) < 1e-6;
private static List<(AutomaticEntryKind Kind, double X, double Y)> Shape(IReadOnlyList<ContourEntryCandidate> ranked) =>
ranked.Select(c => (c.Kind, System.Math.Round(c.Choice.Point.X, 6), System.Math.Round(c.Choice.Point.Y, 6))).ToList();
// --- facing target ------------------------------------------------------------
[Fact]
public void LowerRightTarget_PrefersTheBottomRightCorner()
{
var ranked = Catalog(MakeClosedProgram(SquareVertices))
.RankTowardNextCut(new Vector(14, -2));
// Facing sides right and bottom; the shared corner is the ideal start.
var first = Assert.Single(ranked.Take(1));
Assert.Equal(AutomaticEntryKind.ConvexCorner, first.Kind);
Assert.True(At(first, 10, 0));
// It precedes every other corner and midpoint.
Assert.True(ranked.ToList().FindIndex(c => At(c, 10, 0)) < ranked.ToList().FindIndex(c => At(c, 0, 10)));
Assert.True(ranked.ToList().FindIndex(c => At(c, 10, 0)) < ranked.ToList().FindIndex(c => At(c, 5, 0)));
}
[Fact]
public void FacingMidpoint_BeatsANonFacingCorner()
{
// Target due right: only the right side faces it; the left corners face away.
var ranked = Catalog(MakeClosedProgram(SquareVertices))
.RankTowardNextCut(new Vector(14, 5));
var order = ranked.ToList();
var rightMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5));
var farCorner = order.FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 0, 10));
Assert.True(rightMid < farCorner);
}
[Fact]
public void SameFacingClass_CornerBeatsMidpoint()
{
var ranked = Catalog(MakeClosedProgram(SquareVertices))
.RankTowardNextCut(new Vector(14, -2));
var order = ranked.ToList();
var corner = order.FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 10, 0));
var bottomMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 5, 0));
var rightMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5));
// The shared facing corner beats both facing midpoints; tier breaks the facing tie.
Assert.True(corner < bottomMid && corner < rightMid);
// Between the two facing midpoints travel decides: right-mid is nearer the target.
Assert.True(rightMid < bottomMid);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void FilletedCorner_BeatsAFacingMidpointNearerTheRoute(bool clockwise)
{
// Travel favors the right edge's midpoint, but a long edge's middle is a last resort:
// the joints of the facing rounded corner count on both of its sides and lead.
var ranked = Catalog(ContourEntryCandidateTests.RoundedSquare(clockwise))
.RankTowardNextCut(new Vector(14, 6), new Vector(12, 5)).ToList();
Assert.Equal(AutomaticEntryKind.ConvexCorner, ranked[0].Kind);
Assert.True(At(ranked[0], 10, 9.5) || At(ranked[0], 9.5, 10));
var rightMid = ranked.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5));
Assert.True(ranked.FindIndex(c => At(c, 10, 0.5)) < rightMid);
}
[Fact]
public void TangentAndMidpoint_ShareARankTier()
{
Assert.Equal(AutomaticEntryKind.StraightMidpoint.RankTier(), AutomaticEntryKind.TangentJoint.RankTier());
Assert.True(AutomaticEntryKind.ConvexCorner.RankTier() < AutomaticEntryKind.TangentJoint.RankTier());
Assert.True(AutomaticEntryKind.TangentJoint.RankTier() < AutomaticEntryKind.NearCorner.RankTier());
Assert.True(AutomaticEntryKind.NearCorner.RankTier() <= AutomaticEntryKind.TargetFacing.RankTier());
Assert.Equal(AutomaticEntryKind.NearCorner.RankTier(), AutomaticEntryKind.CircleCompass.RankTier());
// Facing still dominates tier: a joint on both facing sides outranks a non-facing corner.
var ranked = Catalog(BumpSquare()).RankTowardNextCut(new Vector(14, 14));
var joint = Assert.Single(ranked.Where(c => c.Kind == AutomaticEntryKind.TangentJoint && At(c, 10, 10)));
Assert.True(ranked.ToList().IndexOf(joint)
< ranked.ToList().FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 0, 0)));
}
// --- stability ------------------------------------------------------------------
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ReversedOrReindexedDrawing_SameGeometricOrdering(bool reversed)
{
// Same square geometry, different entity travel order: entity order must never be
// the meaningful tie-break — the geometric ordering is identical.
var vertices = reversed
? new[] { SquareVertices[3], SquareVertices[2], SquareVertices[1], SquareVertices[0] }
: new[] { SquareVertices[1], SquareVertices[2], SquareVertices[3], SquareVertices[0] };
var target = new Vector(14, -2);
Assert.Equal(
Shape(Catalog(MakeClosedProgram(SquareVertices))
.RankTowardNextCut(target, new Vector(-1, -1))),
Shape(Catalog(MakeClosedProgram(vertices))
.RankTowardNextCut(target, new Vector(-1, -1))));
}
[Fact]
public void InputCatalog_IsNotMutated_AndNothingIsIntroduced()
{
var catalog = Catalog(BumpSquare());
var before = Shape(catalog);
var snapshot = catalog.ToList();
var ranked = catalog.RankTowardNextCut(new Vector(14, -2), new Vector(-1, -1));
Assert.Equal(before, Shape(snapshot)); // input untouched
Assert.Equal(catalog.Count, ranked.Count); // permutation only
Assert.True(ranked.All(catalog.Contains));
}
[Fact]
public void Ranking_NeverIntroducesReflexCandidates()
{
// L-outline: (5,5) is reflex for its own travel and absent from the catalog.
var p = MakeClosedProgram(new[]
{
new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(5, 10),
new Vector(5, 5), new Vector(0, 5),
});
foreach (var target in new[] { new Vector(14, -2), new Vector(-4, 14), new Vector(14, 14) })
{
var ranked = Catalog(p).RankTowardNextCut(target);
Assert.DoesNotContain(ranked, c => At(c, 5, 5));
}
}
[Fact]
public void NonFiniteTarget_IsRejected()
{
var catalog = Catalog(MakeClosedProgram(SquareVertices));
Assert.Throws<ArgumentException>(() =>
catalog.RankTowardNextCut(new Vector(double.NaN, 5)));
}
// --- no target (last part) --------------------------------------------------------
[Fact]
public void NoTarget_TierFirstThenDistanceToArrival()
{
var ranked = Catalog(MakeClosedProgram(SquareVertices))
.RankTowardNextCut(arrival: new Vector(5, 10));
var order = ranked.ToList();
// Tier first: an outside corner outranks the midpoint the arrival sits on.
var firstCorner = order.FindIndex(c => c.Kind == AutomaticEntryKind.ConvexCorner && At(c, 0, 10));
var touchedMid = order.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 5, 10));
Assert.True(firstCorner < touchedMid);
Assert.Equal(AutomaticEntryKind.ConvexCorner, order[0].Kind);
// Within corners, distance to arrival decides (tie broken by stable key): (0,10) and
// (10,10) are equidistant from (5,10), so the lower X key wins.
Assert.True(At(order[0], 0, 10));
}
[Theory]
[InlineData(0.1, 0.1, 0.0, 0.0)]
[InlineData(9.9, 9.9, 10.0, 10.0)]
public void NoTarget_StartsNearTheArrival_NotBackAtTheOrigin(double arrivalX, double arrivalY, double expectedX, double expectedY)
{
// The last part faces where the head already is; the ranking must not drag it to
// the plate origin when the arrival is elsewhere.
var ranked = Catalog(MakeClosedProgram(SquareVertices))
.RankTowardNextCut(arrival: new Vector(arrivalX, arrivalY));
Assert.True(At(ranked[0], expectedX, expectedY));
}
[Fact]
public void EmptyCatalog_RanksToEmpty()
{
var empty = new List<ContourEntryCandidate>();
Assert.Empty(empty.RankTowardNextCut(new Vector(3, 4), new Vector(-1, -1)));
}
}