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)));
+ }
+}