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