From a791b749451e2209f242d1e29612945ee61dce98 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 7 Oct 2026 02:51:06 -0400 Subject: [PATCH] feat(cutting): rank contour entries toward the next cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure deterministic ordering of the automatic catalogue: classify each point by its nearest bounding-rectangle side(s) with the preparation tolerance (a corner belongs to two sides), choose the facing side pair from the look-ahead target against the centre (right/left and top/bottom per the source plan), then order by matched facing sides descending, rank tier ascending (corner, midpoint/tangent peer tier, fallbacks), then arrival->entry + entry->target travel, then the stable geometric key. Without a target (last part) the tier leads and distance to the arrival breaks ties — never the plate origin. The input list is never mutated, no candidates are added, no cap is applied and entity order is never the tie-break; reversed and cyclically reindexed drawings rank to the identical geometric order. --- .../CuttingPlanning/ContourEntryRanking.cs | 111 ++++++++++ .../ContourEntryRankingTests.cs | 207 ++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs create mode 100644 OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs new file mode 100644 index 0000000..37ae8e6 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingPlanning; + +/// +/// Pure deterministic ordering of the automatic entry catalogue toward the next cut: +/// facing sides first, then tier, then travel, then a stable geometric key. It adds no +/// candidates, mutates nothing, runs no lead checks and applies no cap — feasibility +/// filtering and the bounded selection belong to S07/S08, the wiring to S09. +/// +internal static class ContourEntryRanking +{ + /// + /// Orders for one contour in local coordinates. With a + /// (the next cut's look-ahead point): the number of matched + /// facing sides of the candidate bounding rectangle descending (a corner on both facing + /// sides is ideal), then rank tier ascending, then arrival->entry + entry->target + /// ascending, then the stable geometric key. With no target (the last part): tier first, + /// then distance to — never toward the plate origin. The + /// input list is returned untouched as a new list; entity order is never meaningful. + /// + internal static IReadOnlyList RankTowardNextCut( + this IReadOnlyList candidates, + Vector? target = null, + Vector? arrival = null) + { + if (target.HasValue) PostVerificationGeometry.Validate(target.Value); + if (arrival.HasValue) PostVerificationGeometry.Validate(arrival.Value); + if (candidates.Count == 0) + return new List(); + + // Candidate bounding rectangle in the contour's local coordinates; every candidate + // lies on the contour, so distances to the four side lines order side proximity. + var minX = double.PositiveInfinity; + var minY = double.PositiveInfinity; + var maxX = double.NegativeInfinity; + var maxY = double.NegativeInfinity; + foreach (var candidate in candidates) + { + var p = candidate.Choice.Point; + if (p.X < minX) minX = p.X; + if (p.X > maxX) maxX = p.X; + if (p.Y < minY) minY = p.Y; + if (p.Y > maxY) maxY = p.Y; + } + + // Facing sides from the target relative to the centre, matching the source plan: + // horizontal right when the target is right of centre else left; vertical likewise. + var centreX = minX + (maxX - minX) * 0.5; + var centreY = minY + (maxY - minY) * 0.5; + var facingRight = target != null && target.Value.X > centreX; + var facingTop = target != null && target.Value.Y > centreY; + + return candidates + .Select(c => (Candidate: c, Score: Score(c, minX, minY, maxX, maxY, facingRight, facingTop, target, arrival))) + .OrderByDescending(x => x.Score.Facing) + .ThenBy(x => x.Score.Tier) + .ThenBy(x => x.Score.Travel) + .ThenBy(x => x.Candidate.GeometryKey.X) + .ThenBy(x => x.Candidate.GeometryKey.Y) + .Select(x => x.Candidate) + .ToList(); + } + + /// + /// The ranking tier — coarser than the preference kind: outside corners first, then + /// straight midpoints and tangent joints as peers, then every fallback kind. + /// + internal static int RankTier(this AutomaticEntryKind kind) => kind switch + { + AutomaticEntryKind.ConvexCorner => 0, + AutomaticEntryKind.StraightMidpoint or AutomaticEntryKind.TangentJoint => 1, + _ => 2, + }; + + private static (int Facing, int Tier, double Travel) Score( + ContourEntryCandidate candidate, + double minX, + double minY, + double maxX, + double maxY, + bool facingRight, + bool facingTop, + Vector? target, + Vector? arrival) + { + var p = candidate.Choice.Point; + var facing = 0; + if (target != null) + { + // Nearest side(s) of the candidate bounding rectangle (a corner belongs to two + // sides within tolerance); count how many of them are facing sides. + var left = p.X - minX; + var right = maxX - p.X; + var bottom = p.Y - minY; + var top = maxY - p.Y; + var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top)); + if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++; + if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++; + if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++; + if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++; + } + var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0) + + (target != null ? p.DistanceTo(target.Value) : 0.0); + return (facing, candidate.Kind.RankTier(), travel); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs new file mode 100644 index 0000000..b91440e --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs @@ -0,0 +1,207 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// The pure facing/tier/travel ranking of the entry catalogue: deterministic lexicographic +/// order toward a look-ahead target (or toward arrival alone for the last part), with the +/// catalogue itself untouched and entity order never meaningful. +/// +public class ContourEntryRankingTests +{ + private static IReadOnlyList Catalogue(Program program) => + PreparedContours.Capture(program, ExplicitContourTests.Parameters()) + .AutomaticEntryCandidatesWithFallbacks(0); + + // --- fixtures --------------------------------------------------------------- + + /// Square 0..10 in travel order, optionally cyclically reindexed or reversed. + private static Program Square(IEnumerable vertices) + { + var p = new Program(); + p.MoveTo(vertices.First().X, vertices.First().Y); + foreach (var v in vertices.Skip(1).Append(vertices.First())) + p.LineTo(v.X, v.Y); + return p; + } + + 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 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 = Catalogue(Square(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 = Catalogue(Square(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 = Catalogue(Square(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); + } + + [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 = Catalogue(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(Catalogue(Square(SquareVertices)).RankTowardNextCut(target, new Vector(-1, -1))), + Shape(Catalogue(Square(vertices)).RankTowardNextCut(target, new Vector(-1, -1)))); + } + + [Fact] + public void InputCatalogue_IsNotMutated_AndNothingIsIntroduced() + { + var catalogue = Catalogue(BumpSquare()); + var before = Shape(catalogue); + var snapshot = catalogue.ToList(); + + var ranked = catalogue.RankTowardNextCut(new Vector(14, -2), new Vector(-1, -1)); + + Assert.Equal(before, Shape(snapshot)); // input untouched + Assert.Equal(catalogue.Count, ranked.Count); // permutation only + Assert.True(ranked.All(catalogue.Contains)); + } + + [Fact] + public void Ranking_NeverIntroducesReflexCandidates() + { + // L-outline: (5,5) is reflex for its own travel and absent from the catalogue. + var p = Square(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 = Catalogue(p).RankTowardNextCut(target); + Assert.DoesNotContain(ranked, c => At(c, 5, 5)); + } + } + + [Fact] + public void NonFiniteTarget_IsRejected() + { + var catalogue = Catalogue(Square(SquareVertices)); + Assert.Throws(() => + catalogue.RankTowardNextCut(new Vector(double.NaN, 5))); + } + + // --- no target (last part) -------------------------------------------------------- + + [Fact] + public void NoTarget_TierFirstThenDistanceToArrival() + { + var ranked = Catalogue(Square(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 = Catalogue(Square(SquareVertices)) + .RankTowardNextCut(arrival: new Vector(arrivalX, arrivalY)); + + Assert.True(At(ranked[0], expectedX, expectedY)); + } + + [Fact] + public void EmptyCatalogue_RanksToEmpty() + { + var empty = new List(); + Assert.Empty(empty.RankTowardNextCut(new Vector(3, 4), new Vector(-1, -1))); + } +}