diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index a8f3086..1ee9e2b 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -13,7 +13,8 @@ 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 ;
/// 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.
+/// "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.
///
internal static class JointCuttingPlanSearch
{
@@ -52,7 +53,12 @@ internal static class JointCuttingPlanSearch
if (attempt.Order != null)
return walk.Ready(attempt.Order);
if (!Learn(attempt, prerequisites))
- return walk.Exhausted();
+ {
+ // "Cut before" rules are a heuristic: a part blocked straight after another can
+ // still be reachable via a third. Search every order with what budget remains.
+ var full = walk.Follow(null, null, null);
+ return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted();
+ }
// Back up to just before the earliest part the blocked approach crossed, keep the
// parts cut before it, and re-plan the rest from where the tool is at that point.
var back = attempt.Crossed.Min(part => Array.IndexOf(sequence, part));
@@ -154,7 +160,8 @@ internal static class JointCuttingPlanSearch
}
///
- /// Follows from (or the start point).
+ /// Follows from (or the start point);
+ /// a null sequence tries every dependency-ready part, nearest first.
/// 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.
///
@@ -199,8 +206,11 @@ internal static class JointCuttingPlanSearch
else
{
var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
- var next = snapshot.Placements[sequence[node.Order.Length]];
- sources = snapshot.Dependencies.IsReady(next.SourceOrdinal, finished) ? [next] : [];
+ sources = sequence == null
+ ? snapshot.Placements.Where(p => !finished.Contains(p.SourceOrdinal)
+ && snapshot.Dependencies.IsReady(p.SourceOrdinal, finished))
+ : snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished)
+ ? [snapshot.Placements[sequence[node.Order.Length]]] : [];
}
foreach (var source in sources)
{
@@ -296,7 +306,7 @@ internal static class JointCuttingPlanSearch
private Node Deepest { get; set; }
/// The ordinal of the part the attempt could not get past, or null.
- internal int? Blocked => Deepest == null || Deepest.Order.Length >= sequence.Length ? null
+ internal int? Blocked => sequence == null || Deepest == null || Deepest.Order.Length >= sequence.Length ? null
: sequence[Deepest.Order.Length];
/// Parts whose completed contours the blocked part's motions crossed (it can be among them).
diff --git a/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs b/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs
index ea65636..634ee08 100644
--- a/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs
@@ -76,6 +76,24 @@ public class ReorderSearchTests
Assert.True(result.IndependentlyReplayed);
}
+ [Fact]
+ public void FreeOrder_LearnedOrderContradicts_FallsBackToTheFullSearch()
+ {
+ // Left leads on its left, right on its right: right straight after left crosses left, and
+ // left straight after right crosses right, so "cut before" rules contradict each other.
+ // Going via the part above, which leaves downward, reaches right safely.
+ var left = LeftLeadRectangle("left", 0, 0, 2, 2);
+ var right = LeftLeadRectangle("right", 4, 0, 2, 2, mirror: true);
+ var via = LeftLeadRectangle("via", -1, 5, 2, 2, departure: new Vector(-0.25, -0.25));
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([left, right, via],
+ confirmedParameters: ExplicitContourTests.Parameters()));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(new[] { left, via, right }, result.ProposedOrder.Select(p => p.SourcePart));
+ }
+
[Fact]
public void FreeOrder_NoSafeOrder_RefusesWithTheCrossing()
{
@@ -107,9 +125,9 @@ public class ReorderSearchTests
private static (double, double) Key(Part part) => (part.Location.X, part.Location.Y);
// A locked rectangle whose lead-in and lead-out sit 0.25 outside its left edge (its right
- // edge when mirrored), so the tool departs on that side.
+ // edge when mirrored), so the tool departs on that side unless a final rapid moves it on.
private static Part LeftLeadRectangle(string name, double x, double y, double width, double height,
- bool mirror = false)
+ bool mirror = false, Vector? departure = null)
{
var clean = LeadPathValidationTests.Rectangle(0, 0, width, height);
var part = new Part(new Drawing(name, clean), new Vector(x, y));
@@ -131,6 +149,8 @@ public class ReorderSearchTests
}
placed.LineTo(edge, height / 2);
placed.Codes.Add(new LinearMove(outside, height / 2) { Layer = LayerType.Leadout });
+ if (departure is { } end)
+ placed.MoveTo(end.X, end.Y);
Assert.True(part.RestoreLeadInProgram(placed, true));
return part;
}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index d59c281..08abdf6 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -88,7 +88,11 @@ before those", backs up to just before the earliest of them and re-plans the res
from the tool position there; parts cut before that point are kept. An attempt
stops backtracking after a stall of 8 x entries x contours expansions without
getting further, so it learns instead of retrying every entry combination of the
-parts before it. When nothing new can be learned the result is a refusal.
+parts before it. A rule that would contradict an order already required is skipped.
+"Cut before" rules are a heuristic (a part blocked straight after another may be
+reachable via a third), so once nothing new can be learned the remaining budget
+goes to a full search that tries every ready part, nearest first; only when that
+also fails is the result a refusal.
Candidates use native closest points, vertices, midpoints and circle angles in
stable order, capped by `maxEntries`. Circle rounding, clamping, corner resolution