perf(cutting): generate ranked alternatives only when visited

This commit is contained in:
aj committed 2026-10-09 20:40:10 -04:00
1 parent e897258a85
commit d328c5f691
3 files changed
+118 -28

No files matched your search

@@ -196,29 +196,39 @@ internal static class JointCuttingPlanSearch
var progressExpansions = Expansions;
var stack = new Stack<Frame>();
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;
}
/// <summary>
@@ -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.
/// </summary>
private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly)
private IEnumerable<Edge> 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<Edge> Children { get; set; }
}
}
@@ -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)),
];
}
+18 -5
View File
@@ -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