diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 962ee8c2..77c894c7 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -21,7 +21,8 @@ internal static class JointCuttingPlanSearch
{
///
/// Expansions per entry and contour that a reordering attempt may spend without getting further
- /// along its order before it gives up and learns from the part that blocked it.
+ /// along its order before it gives up and learns from the part that blocked it. Along an order
+ /// the contours are those of the blocked part and of the part cut just before it.
///
internal const int StallExpansionsPerEntry = 8;
private const int MaximumRecoveryHoles = 16;
@@ -276,7 +277,7 @@ internal static class JointCuttingPlanSearch
return attempt;
if (attempt.Advance(node))
progressExpansions = Expansions;
- else if (stall is int limit && Expansions - progressExpansions > limit)
+ else if (stall is int limit && Expansions - progressExpansions > StallLimit(limit, attempt))
{
if (sequence != null || !contourRecovery)
return attempt;
@@ -307,6 +308,19 @@ internal static class JointCuttingPlanSearch
}
}
+ // Along an order, the stall scales with the part the attempt cannot get past and the part
+ // cut just before it, whose departure a retry changes: one many-holed part elsewhere on the
+ // plate must not let every blocked part replay its predecessors' entries for that long.
+ private int StallLimit(int limit, Attempt attempt)
+ {
+ if (attempt.Blocked is not int blocked)
+ return limit;
+ var contours = snapshot.Placements[blocked].Prepared?.Count ?? 1;
+ if (attempt.LastCut is { } last)
+ contours = System.Math.Max(contours, last.Prepared?.Count ?? 1);
+ return (int)System.Math.Min(limit, (long)StallExpansionsPerEntry * snapshot.MaxEntries * contours);
+ }
+
///
/// The NEXT cut's centre that the outside entry should face, or null for the last
/// part. Supplied order: the next not-yet-finished part in that order (the sequence
@@ -918,6 +932,9 @@ internal static class JointCuttingPlanSearch
/// The deepest whole-part boundary reached; its Previous chain leads back to the root.
private Node Deepest { get; set; }
+ /// The part cut last before the deepest boundary, or null at the start point.
+ internal FixedProgramPlacement LastCut => Deepest?.Order is { Length: > 0 } order ? order[^1] : null;
+
/// The ordinal of the part the attempt could not get past, or null.
internal int? Blocked => sequence == null || Deepest == null || Deepest.Order.Length >= sequence.Length ? null
: sequence[Deepest.Order.Length];
diff --git a/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs b/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs
index 78984e19..e02fe8e3 100644
--- a/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs
@@ -10,11 +10,14 @@ public class ContourRecoveryFileTests
// Optional private inputs: never embed customer geometry or names in the repository.
// RecoveryCuttingNestPath: scribed parts with aligned slot pairs, and a bolted frame boxed
// in by parts cut before it, that the free search only finishes through contour recovery.
+ // One of its plates also holds a many-holed part that once set every stall, so the plan
+ // must finish with most of the budget to spare, not just within it.
[SkippableTheory]
- [InlineData("InterlockedCuttingNestPath", false)]
- [InlineData("InterlockedCuttingNestPath", true)]
- [InlineData("RecoveryCuttingNestPath", true)]
- public void PlateFinishesThroughDesktopBatchWithoutChangingInput(string key, bool lineLeads)
+ [InlineData("InterlockedCuttingNestPath", false, 100)]
+ [InlineData("InterlockedCuttingNestPath", true, 100)]
+ [InlineData("RecoveryCuttingNestPath", false, 60)]
+ [InlineData("RecoveryCuttingNestPath", true, 60)]
+ public void PlateFinishesThroughDesktopBatchWithoutChangingInput(string key, bool lineLeads, int budgetPercent)
{
var path = TestConfig.GetExistingPath(key);
Skip.If(path == null, $"Configure {key} in ignored test-config.json.");
@@ -37,7 +40,8 @@ public class ContourRecoveryFileTests
Assert.True(planned.IsOverlapClear);
Assert.True(planned.Result.IndependentlyReplayed);
Assert.Equal(CuttingPlanStatus.Ready, planned.Result.Status);
- Assert.InRange(planned.Result.Expansions, 1, CuttingPlanBatch.PlateBudget(planned.Plate.Parts.Count));
+ Assert.InRange(planned.Result.Expansions, 1,
+ CuttingPlanBatch.PlateBudget(planned.Plate.Parts.Count) * budgetPercent / 100);
Assert.Equal(planned.Plate.Parts.Count, planned.PartCount);
var snapshot = planned.Result.Snapshot;
foreach (var part in planned.Result.ProposedOrder)
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 1dd0a8d1..749598ca 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -196,7 +196,9 @@ along an order that teaches such a rule ends the round; the directed-recovery an
full passes run only when the lighter pass taught nothing. 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. A cutoff is the exception: it is a fixed program and cutting it earlier never
+parts before it. The contours are the larger count of the blocked part and the part
+cut just before it, whose departure a retry changes, so one many-holed part elsewhere
+on the plate does not lengthen every stall. A cutoff is the exception: it is a fixed program and cutting it earlier never
makes its own cut harder, so an approach into a non-separating cutoff that crosses parts already
cut teaches "cut the cutoff before them" at once, without that stall. 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