diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 9a02ad2..466e370 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -196,29 +196,39 @@ internal static class JointCuttingPlanSearch
var progressExpansions = Expansions;
var stack = new Stack();
stack.Push(new(root));
- while (stack.Count != 0)
+ try
{
- token.ThrowIfCancellationRequested();
- var frame = stack.Peek();
- var node = frame.Node;
- if (node.Order.Length == snapshot.Placements.Count)
+ while (stack.Count != 0)
{
- attempt.Order = node.Order;
- return attempt;
+ token.ThrowIfCancellationRequested();
+ var frame = stack.Peek();
+ var node = frame.Node;
+ if (node.Order.Length == snapshot.Placements.Count)
+ {
+ attempt.Order = node.Order;
+ return attempt;
+ }
+ if (attempt.Advance(node))
+ progressExpansions = Expansions;
+ else if (stall is int limit && Expansions - progressExpansions > limit)
+ return attempt;
+ frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly).GetEnumerator();
+ if (!frame.Children.MoveNext())
+ {
+ stack.Pop().Children.Dispose();
+ continue;
+ }
+ stack.Push(new(frame.Children.Current.Node));
}
- if (attempt.Advance(node))
- progressExpansions = Expansions;
- else if (stall is int limit && Expansions - progressExpansions > limit)
- return attempt;
- frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly);
- if (frame.Next == frame.Children.Length)
- {
- stack.Pop();
- continue;
- }
- stack.Push(new(frame.Children[frame.Next++].Node));
+ return attempt;
+ }
+ finally
+ {
+ // Ready, stalled, cancelled and budget-exhausted searches can all leave
+ // suspended siblings. Release their captured prefixes on every exit.
+ foreach (var frame in stack)
+ frame.Children?.Dispose();
}
- return attempt;
}
///
@@ -259,8 +269,14 @@ internal static class JointCuttingPlanSearch
/// one part and contour stage the automatic rank leads — OrderBy(Distance) alone
/// would undo the look-ahead facing. Legacy (unranked) children keep distance order.
///
- private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
+ private IEnumerable OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
{
+ // A prescribed next part (or an active part's next contour) has a single
+ // source. Expand already yields its contour/entry rank order, so emit and
+ // validate a sibling only when DFS reaches it. Free whole-part selection
+ // still needs every source's distances before it can rank them.
+ if (sequence != null || node.Active != null)
+ return Expand(node, sequence, attempt, preferredOnly);
var edges = Expand(node, sequence, attempt, preferredOnly).ToList();
if (edges.Count <= 1)
return edges.ToArray();
@@ -334,8 +350,10 @@ internal static class JointCuttingPlanSearch
// First try one ranked hole chain per endpoint. A later-part failure then
// changes the endpoint before replaying all earlier hole combinations.
// The retained pass below still searches every entry and hole order.
+ if (preference != null)
+ contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).ThenBy(c => c);
if (preferredOnly && preference != null)
- contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1);
+ contours = contours.Take(1);
foreach (var contour in contours)
{
token.ThrowIfCancellationRequested();
@@ -593,7 +611,6 @@ internal static class JointCuttingPlanSearch
private sealed class Frame(Node node)
{
internal Node Node { get; } = node;
- internal Edge[] Children { get; set; }
- internal int Next { get; set; }
+ internal IEnumerator Children { get; set; }
}
}
diff --git a/OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs b/OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs
new file mode 100644
index 0000000..10c97b9
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs
@@ -0,0 +1,60 @@
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class LazyCuttingSearchTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void RankedRouteDoesNotSpendBudgetEmittingUnusedSiblings(bool preserveOrder)
+ {
+ var parts = Parts();
+ var programs = parts.Select(p => p.Program).ToArray();
+ var parameters = ExplicitContourTests.Parameters();
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts, new Vector(-2, -2),
+ expansionBudget: 36, confirmedParameters: parameters, preservePartOrder: preserveOrder));
+ var result = CuttingPlanService.Plan(snapshot);
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal));
+ Assert.InRange(result.Expansions, 1, 36);
+ var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default);
+ Assert.Equal(CuttingPlanStatus.Ready, replay.Status);
+ Assert.True(replay.IndependentlyReplayed);
+ Assert.Equal(programs, parts.Select(p => p.Program));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void LazyTraversalKeepsBudgetAndMidSearchCancellationFailClosed(bool preserveOrder)
+ {
+ var parts = Parts();
+ var programs = parts.Select(p => p.Program).ToArray();
+ var parameters = ExplicitContourTests.Parameters();
+ var bounded = CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(-2, -2),
+ expansionBudget: 1, confirmedParameters: parameters, preservePartOrder: preserveOrder));
+ Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status);
+ Assert.Equal(1, bounded.Expansions);
+ Assert.Empty(bounded.ProposedOrder);
+ Assert.False(bounded.IndependentlyReplayed);
+ using var cancellation = new CancellationTokenSource();
+ var cancelled = CuttingPlanService.Plan(new CuttingPlanRequest(parts, new Vector(-2, -2),
+ confirmedParameters: parameters, preservePartOrder: preserveOrder)
+ { ExpansionObserver = n => { if (n == 10) cancellation.Cancel(); } }, cancellation.Token);
+ Assert.Equal(CuttingPlanStatus.Cancelled, cancelled.Status);
+ Assert.Equal(10, cancelled.Expansions);
+ Assert.Empty(cancelled.ProposedOrder);
+ Assert.False(cancelled.IndependentlyReplayed);
+ Assert.Equal(programs, parts.Select(p => p.Program));
+ }
+
+ private static Part[] Parts() =>
+ [
+ new(new Drawing("first", LeadPathValidationTests.Rectangle(0, 0, 10, 10)), Vector.Zero),
+ new(new Drawing("second", LeadPathValidationTests.Rectangle(0, 0, 10, 10)), new Vector(20, 0)),
+ ];
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index a7af77d..7214f7f 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -146,11 +146,24 @@ other curve already touches, while a contact anywhere else on the line still ref
Source parts rank by modeled travel (material-centre distance at a holed-part
boundary); within a part, preferred contour order and facing-entry rank precede
-travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names are not tie breakers. The expansion budget
-counts rejected candidates and frontier ranking as well as accepted moves, before
-emission; it is not a wall-clock timeout. Callers can cancel. Exhaustion may occur
-before already-generated siblings are traversed; it returns a refusal, not an
-unranked fallback or a proof of geometric impossibility.
+travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names
+are not tie breakers.
+
+When the next whole part is fixed by the tour or a part is already active, the
+search generates and checks contour alternatives on demand in contour/entry rank
+order. It does not emit every sibling before trying the first. Backtracking still
+visits the remaining alternatives when needed; none is pruned. The free whole-part
+fallback still evaluates all ready sources before ranking their travel distances.
+
+The expansion budget counts evaluated candidates and frontier ranking, including
+rejections, before emission; it is not a wall-clock timeout. An unvisited sibling
+consumes no additional DFS-prefix emission work or per-sibling expansion charge;
+entry-catalogue prechecks still emit isolated contours and consume budget.
+Consequently a bounded search can
+reach a different frontier and report different rejected approaches than eager
+expansion did. All visited candidates retain their lead/rapid checks, and a selected
+plan still receives fresh independent replay. Callers can cancel. Exhaustion returns
+a refusal, not an unranked fallback or proof of geometric impossibility.
## Automatic outside entries and look-ahead