diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 848fc1f..c59e065 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -10,8 +10,9 @@ using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
///
-/// Plans whole parts and their emitted contour prefixes along a part order. A preserved order is
-/// followed with full backtracking. Otherwise the order comes from ;
+/// Plans whole parts and their emitted contour prefixes along a part order. Multi-part holed
+/// requests first try one ranked hole chain per endpoint, then retain full backtracking.
+/// A preserved order stays fixed. Otherwise the order comes from ;
/// when a part on it cannot be reached without crossing parts already cut, the search learns
/// "cut this part before those", keeps the parts cut before them and re-plans the rest. Once
/// nothing new can be learned, the remaining budget goes to a full search over every ready part.
@@ -32,24 +33,39 @@ internal static class JointCuttingPlanSearch
{
var walk = new Walk(snapshot, token);
var count = snapshot.Placements.Count;
+ var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
+ var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
+ var preferEndpoints = count > 1 && maxContours > 1;
try
{
if (snapshot.PreservePartOrder)
{
- var kept = walk.Follow(Enumerable.Range(0, count).ToArray(), null, null);
+ var keptSequence = Enumerable.Range(0, count).ToArray();
+ if (preferEndpoints)
+ {
+ var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true);
+ if (preferred.Order != null)
+ return walk.Ready(preferred.Order);
+ }
+ var kept = walk.Follow(keptSequence, null, null);
return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
}
var centres = snapshot.Placements.Select(Centre).ToArray();
var prerequisites = Enumerable.Range(0, count)
.Select(i => new HashSet(snapshot.Dependencies.PrerequisitesOf(i))).ToArray();
- var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
- var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
+
var sequence = CuttingPartOrder.Plan(Enumerable.Range(0, count).ToArray(), centres,
snapshot.StartPoint, prerequisites, token);
Node resume = null;
while (true)
{
+ if (preferEndpoints)
+ {
+ var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true);
+ if (preferred.Order != null)
+ return walk.Ready(preferred.Order);
+ }
var attempt = walk.Follow(sequence, stall, resume);
if (attempt.Order != null)
return walk.Ready(attempt.Order);
@@ -173,7 +189,7 @@ internal static class JointCuttingPlanSearch
/// With a stall limit the attempt ends once that many expansions pass without getting
/// further along the order; it never backtracks behind its starting node.
///
- internal Attempt Follow(int[] sequence, int? stall, Node resume)
+ internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false)
{
var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null);
var attempt = new Attempt(sequence);
@@ -194,7 +210,7 @@ internal static class JointCuttingPlanSearch
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
return attempt;
- frame.Children ??= OrderedChildren(node, sequence, attempt);
+ frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly);
if (frame.Next == frame.Children.Length)
{
stack.Pop();
@@ -243,9 +259,9 @@ internal static class JointCuttingPlanSearch
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
///
- private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt)
+ private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
{
- var edges = Expand(node, sequence, attempt).ToList();
+ var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
if (edges.Count <= 1)
return edges.ToArray();
// Stable source order: the minimum incremental rapid per source, ties ordinal.
@@ -263,7 +279,7 @@ internal static class JointCuttingPlanSearch
.ToArray();
}
- private IEnumerable Expand(Node node, int[] sequence, Attempt attempt)
+ private IEnumerable Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
{
IEnumerable sources;
if (node.Active is { } active)
@@ -315,6 +331,11 @@ internal static class JointCuttingPlanSearch
var boundary = node.Active?.Boundary ?? node;
var contours = choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
: Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c));
+ // First try one ranked hole chain per endpoint. A later-part failure then
+ // changes the endpoint before replaying all earlier hole combinations.
+ // The retained pass below still searches every entry and hole order.
+ if (preferredOnly && preference != null)
+ contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1);
foreach (var contour in contours)
{
token.ThrowIfCancellationRequested();
@@ -346,7 +367,8 @@ internal static class JointCuttingPlanSearch
}
if (entries.Count == 0)
continue;
- for (var entry = 0; entry < entries.Count; entry++)
+ var entryCount = preferredOnly && node.Active?.Perimeter != null ? System.Math.Min(1, entries.Count) : entries.Count;
+ for (var entry = 0; entry < entryCount; entry++)
{
CountExpansion(source); // Before emission/native queries, including rejected candidates.
var prefix = choices.Append(entries[entry]).ToArray();
diff --git a/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs b/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs
new file mode 100644
index 0000000..b188e2c
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs
@@ -0,0 +1,85 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Diagnostics;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class HoleEndpointBudgetTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void LaterFixedApproachTriesOtherEndpointsBeforeHoleCombinations(bool preserveOrder)
+ {
+ var parameters = HoleLookAheadTests.Parameters();
+ var clean = HoleLookAheadTests.MixedHoles();
+ foreach (var centre in new[] { new Vector(4, 8), new Vector(8, 8) })
+ {
+ clean.MoveTo(centre.X + 0.75, centre.Y);
+ clean.ArcTo(centre.X + 0.75, centre.Y, centre.X, centre.Y, RotationType.CCW);
+ }
+ var first = new Part(new Drawing("four holes", clean));
+ var nextClean = LeadPathValidationTests.Rectangle(0, 0, 2, 2);
+ nextClean.MoveTo(-22, 10);
+ nextClean.Codes.Add(new LinearMove(-21, 10) { Layer = LayerType.Scribe });
+ nextClean.MoveTo(0, 10);
+ nextClean.Codes.Add(new LinearMove(1, 10) { Layer = LayerType.Scribe });
+ var next = new Part(new Drawing("fixed marked part", nextClean), new Vector(20, 2));
+ var nextPrepared = PreparedContours.Capture(nextClean, parameters);
+ Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit(
+ [nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true));
+ var before = new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray();
+ var start = new Vector(-2, 4);
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], start,
+ confirmedParameters: parameters, preservePartOrder: preserveOrder));
+ var source = snapshot.Placements[0];
+ var prepared = source.Prepared;
+ var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material,
+ snapshot.Placements.Select(p => p.Material).ToArray());
+ var target = new Vector(21, 3);
+ var outside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(4, target)
+ .RankTowardNextCut(target, start), c => adapter.Check(c.Choice)).Choices[0];
+ var centres = prepared.HoleCentres().Select(c => c ?? Vector.Zero).ToArray();
+ var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2, 3 }, centres, start,
+ PreferredContourEntries.Pierce(prepared, outside, default));
+ var preferred = PreferredContourEntries.TryPlan(prepared, outside, route, centres, start,
+ c => adapter.Check(c.Choice));
+ Assert.True(preferred.IsPreferred, preferred.Reason);
+ var execution = ExecutionMotionReader.Read(prepared.Emit(preferred.HoleChoices.Append(outside).ToArray()),
+ Vector.Zero, start, default);
+ var checker = new ReleasedContourState();
+ Assert.Empty(checker.Check(execution, start, 1));
+ Assert.Contains(checker.Check(snapshot.Placements[1].Execution, execution.DeparturePoint, 2),
+ f => f.Kind == PostVerificationKind.RapidCrossing);
+
+ var result = CuttingPlanService.Plan(snapshot);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready,
+ $"{result.Status}, {result.Expansions} expansions: " + string.Join("; ", result.Findings.Select(f => f.Message)));
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
+ Assert.Equal(5, result.ProposedOrder[0].ContourChoices.Count);
+ Assert.Equal(4, result.ProposedOrder[0].ContourChoices[^1].ContourOrdinal);
+ Assert.NotEqual(outside.Point, result.ProposedOrder[0].ContourChoices[^1].Point);
+ Assert.InRange(result.Expansions, 1, 20000);
+ var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default);
+ Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
+ Assert.True(replay.IndependentlyReplayed);
+ Assert.Equal(before, new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
+ Assert.Equal(before[1], ExplicitContourTests.Fingerprint(result.ProposedOrder[1].CopyProgram()));
+
+ var bounded = CuttingPlanService.Plan(new CuttingPlanRequest([first, next], start,
+ expansionBudget: 1, confirmedParameters: parameters, preservePartOrder: preserveOrder));
+ Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status);
+ Assert.Equal(1, bounded.Expansions);
+ Assert.Empty(bounded.ProposedOrder);
+ Assert.False(bounded.IndependentlyReplayed);
+ using var cancelled = new CancellationTokenSource();
+ cancelled.Cancel();
+ Assert.Equal(CuttingPlanStatus.Cancelled, CuttingPlanService.Plan(snapshot, cancelled.Token).Status);
+ Assert.Equal(before, new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
+ }
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index c5b1ee7..cdaeb4f 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -77,7 +77,14 @@ the final replay enforce them:
With regeneration, the bounded deterministic search plans internal contour order
and native entry candidates part by part along a whole-part order. Internal
contours precede their own perimeter; parts remain contiguous. Backtracking can
-revisit an earlier entry when a later part cannot be reached safely.
+revisit an earlier entry when a later part cannot be reached safely. For multi-part
+requests containing regenerated holes, it first tries one ranked hole chain for
+each outside endpoint. This lets a later blocked approach change the previous
+part's departure without first exhausting combinations of its earlier holes.
+This preferred pass uses the same emitted-motion checks, shared expansion budget
+and stall limit as the retained search; on failure the full entry/hole-order
+backtracking pass remains available. Single-part and no-hole requests keep their
+existing search order.
A preserved order is followed as given. Otherwise the order is an open
travelling-salesman path over part centres from the start point: nearest neighbour,