fix(planning): swap the pair a blocked approach doubled back across

When a part could not be reached without crossing parts already cut, the
free search always learned "cut the blocked part before them". When the
crossed part was the one cut just before the part the tool left from, the
tool had gone on past it and come back: the cheap repair is to cut those
two the other way round. Without it, N26-23S learned a chain of wrong
rules (8 before 4 and 5, 3 before 1, ...) that excluded every valid order
and ran the full search out of budget, although swapping parts 5 and 6
gives a valid plan. It now plans ready in 786 expansions.

The problem-nest corpus keeps every outcome; three P260417-17 plates stay
Ready with more expansions (5341 -> 8937 the largest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-28 12:14:10 -04:00
1 parent cd51f7cfda
commit 16bd92567c
3 files changed
+43 -4

No files matched your search

@@ -195,7 +195,10 @@ internal static class JointCuttingPlanSearch
}, token);
}
// "Cut the blocked part before every part whose cut contour its approach crossed", unless that
// When the approach crossed the part cut just before the one it left from, the tool went on
// past that part and came back across it: "cut those two the other way round", so the tool
// leaves from the nearer one (two stacked strips, cut far one first). Otherwise "cut the
// blocked part before every part whose cut contour its approach crossed", unless that
// would contradict an order already required. When every such rule would (a sheet separator
// is cut after everything), the blocked part cannot come earlier, so the tool must arrive
// from beyond those parts instead: "cut the part it left from before them". False when
@@ -217,6 +220,10 @@ internal static class JointCuttingPlanSearch
{
if (attempt.Blocked is not int blocked)
return [];
if (attempt.LastCut?.SourceOrdinal is int last && attempt.CutBeforeLast?.SourceOrdinal is int previous
&& attempt.Crossed.Contains(previous) && !prerequisites[previous].Contains(last)
&& !Precedes(previous, last, prerequisites))
return [(last, previous)];
var before = New(blocked);
return before.Length != 0 || attempt.LastCut?.SourceOrdinal is not int departed ? before : New(departed);
@@ -1581,6 +1588,9 @@ internal static class JointCuttingPlanSearch
/// <summary>The part cut last before the deepest boundary, or null at the start point.</summary>
internal FixedProgramPlacement LastCut => Deepest?.Order is { Length: > 0 } order ? order[^1] : null;
/// <summary>The part cut just before <see cref="LastCut"/>, or null.</summary>
internal FixedProgramPlacement CutBeforeLast => Deepest?.Order is { Length: > 1 } order ? order[^2] : null;
/// <summary>The ordinal of the part the attempt could not get past, or null.</summary>
internal int? Blocked => sequence == null || Deepest == null || Deepest.Order.Length >= sequence.Length ? null
: sequence[Deepest.Order.Length];
@@ -118,6 +118,29 @@ public class ReorderSearchTests
Assert.True(result.IndependentlyReplayed);
}
[Fact]
public void FreeOrder_ApproachCrossesThePartCutJustBefore_SwapsThatPair()
{
// Two long bars, one above the other, and a small part off to the right below them. The
// shortest tour cuts the lower bar, the upper one, then the small part, whose approach
// from the upper bar's left side crosses the lower bar. Cutting the upper bar first lets
// the tool leave the lower bar below its corner instead; jumping the small part ahead of
// the lower bar would detour.
var lower = LeftLeadRectangle("lower", 0, 0, 19, 6, departure: new Vector(-0.25, -0.25));
var upper = LeftLeadRectangle("upper", 0, 7, 19, 6);
var small = LeftLeadRectangle("small", 28, -19, 4, 4);
var start = new Vector(9.5, -10);
Assert.Contains(Analyze(start, lower, upper, small).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
var result = CuttingPlanService.Plan(new CuttingPlanRequest([lower, upper, small], start,
confirmedParameters: ExplicitContourTests.Parameters()));
Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
Assert.True(result.IndependentlyReplayed);
Assert.Equal(new[] { upper, lower, small }, result.ProposedOrder.Select(p => p.SourcePart));
Assert.Empty(Analyze(start, upper, lower, small).Findings);
}
[Fact]
public void FreeOrder_LearnedOrderContradicts_FallsBackToTheFullSearch()
{
@@ -197,7 +220,9 @@ public class ReorderSearchTests
return part;
}
private static PostVerificationReport Analyze(params Part[] parts)
private static PostVerificationReport Analyze(params Part[] parts) => Analyze(Vector.Zero, parts);
private static PostVerificationReport Analyze(Vector start, params Part[] parts)
{
var nest = new Nest();
var plate = nest.CreatePlate();
@@ -207,7 +232,7 @@ public class ReorderSearchTests
Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
plate.Parts.Add(copy);
}
return PostVerificationAnalyzer.Analyze(nest, Vector.Zero);
return PostVerificationAnalyzer.Analyze(nest, start);
}
private static string Describe(CuttingPlanResult r) =>
+5 -1
View File
@@ -218,7 +218,11 @@ from the start point. Its 2-opt and Or-opt passes would grow with the square of
part count. Replans after a learned rule use the plain tour. If a part on that order cannot be
reached without crossing parts already cut, the search learns "cut this part
before those", backs up to just before the earliest of them and re-plans the rest
from the tool position there; parts cut before that point are kept. When the part
from the tool position there; parts cut before that point are kept. When the approach
crossed the part cut just before the one it left from, the tool went on past that part
and came back across it, so the search learns "cut those two the other way round"
instead: the tool then leaves from the nearer one, as with two stacked strips cut far
one first. When the part
cannot come earlier (a sheet separator is cut after everything), it learns the other
way round, "cut the part the approach left from before those", so the tool arrives
from beyond them. The first pass