From cf32d1cab5ee39de429741938f16681c7a51f37d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 18:10:27 -0400 Subject: [PATCH] fix(cutting): scale the reorder stall with the blocked part, not the plate's largest An attempt along a part order stopped backtracking after 8 x entries x contours expansions without progress, with contours taken as the most of any part on the plate. One many-holed part therefore let every blocked part, even a two-contour tab, replay its predecessors' entries for the whole of that allowance; a plate whose rounds each stalled that long ran out of budget before the recovery pass that would have finished it. Along an order the stall now uses the larger contour count of the blocked part and of the part cut just before it, whose departure a retry changes, capped by the old limit. The sequence-free recovery pass keeps the plate maximum. Planning characterization digests are unchanged. The optional private-fixture test now also runs RecoveryCuttingNestPath with default lead-ins, and requires that file's plates to plan within 60% of their budget. Co-Authored-By: Claude Opus 5.5 --- .../CuttingPlanning/JointCuttingPlanSearch.cs | 21 +++++++++++++++++-- .../ContourRecoveryFileTests.cs | 14 ++++++++----- docs/cutting-planner.md | 4 +++- 3 files changed, 31 insertions(+), 8 deletions(-) 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