diff --git a/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs b/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs
new file mode 100644
index 0000000..a2137b2
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs
@@ -0,0 +1,99 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+///
+/// One preferred sequence of owned contour choices for a holed part: every remaining hole
+/// gets exactly one lead-feasible entry, resolved BACKWARD from the already-chosen
+/// perimeter entry — the last hole faces the perimeter's actual emitted pierce, each earlier
+/// hole faces the next hole's actual emitted pierce — so the cut chain flows toward the
+/// outside start. This is a deterministic recommended v1 proposal, not an optimal joint
+/// tour and not an installed program: the search still certifies every rapid, lead and
+/// crossing. A blocked preferred rapid is the search's problem (S12), never ignored here.
+///
+internal static class PreferredContourEntries
+{
+ /// Proposed hole choices in cut order, or an explicit no-preference verdict.
+ internal sealed record Proposal(
+ IReadOnlyList HoleChoices,
+ ContourSelectionShortfall Shortfall,
+ int? BlockedContour,
+ string? Reason)
+ {
+ public bool IsPreferred => Shortfall != ContourSelectionShortfall.Incomplete
+ && HoleChoices.Count > 0 && BlockedContour is null;
+ }
+
+ ///
+ /// Resolves one preferred entry per hole on (cut order,
+ /// from ), ending at .
+ /// Arrival proxies avoid circularity: the actual (local) part arrival for the first
+ /// hole, otherwise the previous hole's centre. Facing target (the downstream actual
+ /// pierce) and tier still rank first. is the S07 adapter
+ /// verdict; candidates it refuses or leaves uncertain are not preferred.
+ ///
+ internal static Proposal TryPlan(
+ PreparedContours prepared,
+ ContourChoice perimeterChoice,
+ IReadOnlyList holeRoute,
+ IReadOnlyList centresByOrdinal,
+ Vector arrival,
+ Func evaluate,
+ CancellationToken token = default)
+ {
+ if (prepared == null)
+ throw new ArgumentException("Prepared contours are required.", nameof(prepared));
+ if (perimeterChoice == null || !ReferenceEquals(perimeterChoice.Owner, prepared))
+ throw new ArgumentException("The perimeter choice must belong to this preparation.", nameof(perimeterChoice));
+ if (holeRoute == null || centresByOrdinal == null || evaluate == null)
+ throw new ArgumentException("Hole route, centres and evaluation are required.");
+ token.ThrowIfCancellationRequested();
+ if (holeRoute.Count == 0)
+ return new(Array.Empty(), ContourSelectionShortfall.Exhausted, null, null);
+
+ // The downstream target is where the tool ACTUALLY arrives next: the emitted
+ // contour's first cut/lead motion start (native rounding/clamping included).
+ var target = Pierce(prepared, perimeterChoice, token);
+ var choices = new ContourChoice[holeRoute.Count];
+ for (var index = holeRoute.Count - 1; index >= 0; index--)
+ {
+ token.ThrowIfCancellationRequested();
+ var contour = holeRoute[index];
+ var arrivalProxy = index == 0 ? arrival : centresByOrdinal[holeRoute[index - 1]];
+ // The S04 merged catalogue: preferred kinds when present, fallback kinds
+ // (compass points, arc midpoints, ...) otherwise — pure circles included.
+ var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(contour, target, token);
+ var ranked = catalogue.RankTowardNextCut(target, arrivalProxy);
+ var selection = ContourEntrySelection.Select(ranked, evaluate, 1, token);
+ if (selection.Choices.Count == 0)
+ return new(Array.Empty(), selection.Shortfall, contour,
+ $"Hole contour {contour} has no preferred lead-feasible entry: {selection.Reason}");
+ choices[index] = selection.Choices[0];
+ target = Pierce(prepared, choices[index], token);
+ }
+
+ if (choices.Any(c => c == null!))
+ throw new InvalidOperationException("Preferred hole resolution left a gap.");
+ return new(choices, ContourSelectionShortfall.None, null, null);
+ }
+
+ ///
+ /// The actual emitted pierce of one owned choice in local coordinates: the start of the
+ /// first non-rapid motion (lead-in when present — that is where the next rapid must
+ /// arrive), which reflects native rounding and clamping of a nominal entry.
+ ///
+ private static Vector Pierce(PreparedContours prepared, ContourChoice choice, CancellationToken token)
+ {
+ var program = prepared.EmitCandidateForValidation(choice);
+ var execution = ExecutionMotionReader.Read(program, Vector.Zero, null, token);
+ var first = execution.Motions.First(m => !m.Rapid);
+ return first.Start ?? first.End;
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/PreferredContourEntriesTests.cs b/OpenNest.Tests/CuttingPlanning/PreferredContourEntriesTests.cs
new file mode 100644
index 0000000..119b875
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/PreferredContourEntriesTests.cs
@@ -0,0 +1,268 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// The preferred hole-entry proposal: walk the proposed hole route BACKWARD from the chosen
+/// perimeter entry, each hole facing the downstream contour's ACTUAL emitted pierce (native
+/// rounding included), corners outranking midpoints, with an explicit no-preference result
+/// when a hole cannot flow. Proposal only — nothing installed, nothing certified here.
+/// (Contour layout: holes take ordinals 0..N-1, the perimeter is the last ordinal.)
+///
+public class PreferredContourEntriesTests
+{
+ private static readonly Vector Arrival = new(0, 5);
+
+ private static Program CirclesProgram(params (double X, double Y)[] centres)
+ {
+ var p = ExplicitContourTests.Square(false);
+ foreach (var (x, y) in centres)
+ {
+ // Unit circle centred at (x, y): two half-arcs, CCW.
+ p.MoveTo(x + 1, y);
+ p.ArcTo(new Vector(x - 1, y), new Vector(x, y), RotationType.CCW);
+ p.ArcTo(new Vector(x + 1, y), new Vector(x, y), RotationType.CCW);
+ }
+ return p;
+ }
+
+ private static Program RectHolesProgram(params (double X1, double Y1, double X2, double Y2)[] boxes)
+ {
+ var p = ExplicitContourTests.Square(false);
+ foreach (var (x1, y1, x2, y2) in boxes)
+ {
+ p.MoveTo(x1, y1);
+ p.LineTo(x2, y1); p.LineTo(x2, y2); p.LineTo(x1, y2); p.LineTo(x1, y1);
+ }
+ return p;
+ }
+
+ private static (PreparedContours Prepared, ContourEntryFeasibility Feasibility) Capture(Program clean,
+ Vector location)
+ {
+ var prepared = PreparedContours.Capture(clean, ExplicitContourTests.Parameters());
+ var material = LeadMaterialSnapshot.Capture(clean, location);
+ var feasibility = new ContourEntryFeasibility(prepared, location, material, []);
+ return (prepared, feasibility);
+ }
+
+ private static bool At(Vector point, double x, double y) =>
+ point.DistanceTo(new Vector(x, y)) < 1e-6;
+
+ private static Vector Pierce(PreparedContours prepared, ContourChoice choice)
+ {
+ var program = prepared.EmitCandidateForValidation(choice);
+ var execution = ExecutionMotionReader.Read(program, Vector.Zero, null, default);
+ var first = execution.Motions.First(m => !m.Rapid);
+ return first.Start ?? first.End;
+ }
+
+ /// The owned compass candidate of a circle contour at an exact point.
+ private static ContourChoice Compass(PreparedContours prepared, int contour, double x, double y,
+ Vector lookAhead)
+ {
+ var match = prepared.AutomaticEntryCandidatesWithFallbacks(contour, lookAhead)
+ .FirstOrDefault(c => At(c.Choice.Point, x, y));
+ Assert.NotNull(match);
+ return match.Choice;
+ }
+
+ [Fact]
+ public void TwoCircularHoles_FlowToTheSelectedOutsideEntry()
+ {
+ var clean = CirclesProgram((3, 3), (7, 3));
+ var (prepared, feasibility) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5)); // outside right edge
+ Assert.True(At(perimeterEntry.Point, 10, 5));
+
+ // Route: arrival (0,5) -> left circle -> right circle -> the outside entry.
+ var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
+ var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival,
+ Pierce(prepared, perimeterEntry));
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route,
+ centres, Arrival, c => feasibility.Check(c.Choice));
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ Assert.Equal(new[] { 0, 1 }, proposal.HoleChoices.Select(c => c.ContourOrdinal));
+
+ // The last hole's ACTUAL pierce flows to the perimeter pierce: strictly closer to
+ // it than the opposite (west) compass pierce would be.
+ var perimeterPierce = Pierce(prepared, perimeterEntry);
+ var last = Pierce(prepared, proposal.HoleChoices[1]);
+ var opposite = Pierce(prepared, Compass(prepared, 1, 6, 3, new Vector(6, 3)));
+ Assert.True(last.DistanceTo(perimeterPierce) < opposite.DistanceTo(perimeterPierce),
+ $"right hole pierce {last} does not flow to {perimeterPierce} (west pierce {opposite})");
+
+ // The first hole faces the SECOND hole's actual pierce — not the perimeter and not
+ // the downstream nominal point: closer to it than the west compass pierce would be.
+ var downstream = Pierce(prepared, proposal.HoleChoices[1]);
+ var first = Pierce(prepared, proposal.HoleChoices[0]);
+ var firstWest = Pierce(prepared, Compass(prepared, 0, 2, 3, new Vector(2, 3)));
+ Assert.True(first.DistanceTo(downstream) < firstWest.DistanceTo(downstream),
+ $"left hole pierce {first} faces away from downstream {downstream} (west {firstWest})");
+ }
+
+ [Fact]
+ public void RectangularHoles_CornersOutrankMidpoints()
+ {
+ var clean = RectHolesProgram((3, 3, 5, 5), (7, 3, 9, 5));
+ var (prepared, _) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 4));
+ var centres = new[] { new Vector(4, 4), new Vector(8, 4), new Vector(5, 5) };
+
+ // All-clear evaluation isolates pure ranking: a corner on a facing side wins over
+ // any midpoint, walking backward from the perimeter entry.
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
+ centres, Arrival, _ => new(ContourFeasibilityStatus.Clear, null));
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ // Right hole faces the perimeter: a RIGHT-edge corner of the (7,3)-(9,5) hole,
+ // never the (9,4) midpoint nor a left-edge corner.
+ var right = proposal.HoleChoices[1].Point;
+ Assert.True(At(right, 9, 3) || At(right, 9, 5),
+ $"right hole entry {right} is not a right-facing corner");
+ // Left hole faces the right hole's actual pierce (eastward): a right-edge corner of
+ // the (3,3)-(5,5) hole, never its (5,4) midpoint.
+ var left = proposal.HoleChoices[0].Point;
+ Assert.True(At(left, 5, 3) || At(left, 5, 5),
+ $"left hole entry {left} is not a right-facing corner");
+ }
+
+ [Fact]
+ public void BlockedIdealLead_TakesTheNextFeasibleCandidate()
+ {
+ var clean = CirclesProgram((3, 3), (7, 3));
+ var (prepared, feasibility) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
+ var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
+
+ // The ideal east compass point of the right circle is refused: fall back to its
+ // next-ranked candidate rather than fail or skip the hole.
+ ContourFeasibilityVerdict Evaluate(ContourEntryCandidate c) =>
+ c.Choice.ContourOrdinal == 1 && At(c.Choice.Point, 8, 3)
+ ? new(ContourFeasibilityStatus.Blocked, "test block")
+ : feasibility.Check(c.Choice);
+ var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, Pierce(prepared, perimeterEntry));
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route, centres, Arrival, Evaluate);
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ Assert.Equal(1, proposal.HoleChoices[1].ContourOrdinal); // never skipped or swapped
+ Assert.False(At(proposal.HoleChoices[1].Point, 8, 3), "the blocked candidate was used anyway");
+ Assert.True(proposal.HoleChoices[1].Point.X >= 7.0 - 1e-9,
+ $"fallback entry {proposal.HoleChoices[1].Point} abandoned the facing side entirely");
+ }
+
+ [Fact]
+ public void BlockedHole_GivesAnExplicitNoPreferenceFinding()
+ {
+ var clean = CirclesProgram((3, 3), (7, 3));
+ var (prepared, _) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
+ var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
+
+ ContourFeasibilityVerdict Evaluate(ContourEntryCandidate c) =>
+ c.Choice.ContourOrdinal == 0
+ ? new(ContourFeasibilityStatus.Blocked, "nothing fits")
+ : new(ContourFeasibilityStatus.Clear, null);
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
+ centres, Arrival, Evaluate);
+
+ Assert.False(proposal.IsPreferred);
+ Assert.Empty(proposal.HoleChoices);
+ Assert.Equal(0, proposal.BlockedContour);
+ Assert.Contains("no preferred lead-feasible entry", proposal.Reason);
+ }
+
+ [Fact]
+ public void EveryHoleExactlyOnceInRouteOrder()
+ {
+ var clean = CirclesProgram((2.5, 3), (5, 3), (7.5, 3));
+ var (prepared, feasibility) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(3, 2, new Vector(10, 5)); // perimeter is last
+ var centres = new[]
+ {
+ new Vector(2.5, 3), new Vector(5, 3), new Vector(7.5, 3), new Vector(5, 5),
+ };
+ var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2 }, centres, Arrival, Pierce(prepared, perimeterEntry));
+
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, route, centres, Arrival,
+ c => feasibility.Check(c.Choice));
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ Assert.Equal(route, proposal.HoleChoices.Select(c => c.ContourOrdinal));
+ Assert.Equal(route.Count, proposal.HoleChoices.Select(c => c.ContourOrdinal).Distinct().Count());
+ Assert.All(proposal.HoleChoices, c => Assert.NotEqual(3, c.ContourOrdinal)); // never the perimeter
+ }
+
+ [Fact]
+ public void ChainingIsDeterministic()
+ {
+ // Whatever native rounding a nominal circle candidate emits, backward chaining must
+ // reproduce the same earlier-hole choice on a second run: the chain is a pure
+ // function of the ACTUAL pierce, not of iteration order or object identity.
+ var clean = CirclesProgram((3, 3), (7.4, 4.6));
+ var (prepared, feasibility) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
+ var centres = new[] { new Vector(3, 3), new Vector(7.4, 4.6), new Vector(5, 5) };
+
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
+ centres, Arrival, c => feasibility.Check(c.Choice));
+ var again = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
+ centres, Arrival, c => feasibility.Check(c.Choice));
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ Assert.Equal(proposal.HoleChoices.Select(c => c.Point), again.HoleChoices.Select(c => c.Point));
+ }
+
+ [Fact]
+ public void ForeignPerimeterChoiceIsRefused()
+ {
+ var (prepared, feasibility) = Capture(CirclesProgram((3, 3), (7, 3)), Vector.Zero);
+ var (other, _) = Capture(ExplicitContourTests.Square(false), Vector.Zero);
+ var foreign = other.Entry(0, 0, new Vector(0, 5));
+
+ Assert.Throws(() => PreferredContourEntries.TryPlan(prepared, foreign,
+ new[] { 0 }, new[] { new Vector(3, 3), new Vector(7, 3) }, Arrival,
+ c => feasibility.Check(c.Choice)));
+ }
+
+ [Fact]
+ public void CancellationPropagates()
+ {
+ var (prepared, feasibility) = Capture(CirclesProgram((3, 3), (7, 3)), Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+
+ Assert.ThrowsAny(() => PreferredContourEntries.TryPlan(
+ prepared, perimeterEntry, new[] { 0, 1 },
+ new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) }, Arrival,
+ c => feasibility.Check(c.Choice), cancelled.Token));
+ }
+
+ [Fact]
+ public void SourceProgramAndChoicesAreUntouched()
+ {
+ var clean = CirclesProgram((3, 3), (7, 3));
+ var before = ExplicitContourTests.Fingerprint(clean);
+ var (prepared, feasibility) = Capture(clean, Vector.Zero);
+ var perimeterEntry = prepared.Entry(2, 2, new Vector(10, 5));
+ var centres = new[] { new Vector(3, 3), new Vector(7, 3), new Vector(5, 5) };
+
+ var proposal = PreferredContourEntries.TryPlan(prepared, perimeterEntry, new[] { 0, 1 },
+ centres, Arrival, c => feasibility.Check(c.Choice));
+
+ Assert.True(proposal.IsPreferred, proposal.Reason);
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(clean)); // caller program untouched
+ Assert.All(proposal.HoleChoices, c => Assert.True(ReferenceEquals(c.Owner, prepared)));
+ Assert.NotSame(prepared.EmitCandidateForValidation(proposal.HoleChoices[0]),
+ prepared.EmitCandidateForValidation(proposal.HoleChoices[0])); // fresh programs
+ }
+}