From d328c5f69123cbf6193a0435b13f984e57ffd6f4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 20:33:17 -0400 Subject: [PATCH] perf(cutting): generate ranked alternatives only when visited --- .../CuttingPlanning/JointCuttingPlanSearch.cs | 63 ++++++++++++------- .../CuttingPlanning/LazyCuttingSearchTests.cs | 60 ++++++++++++++++++ docs/cutting-planner.md | 23 +++++-- 3 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/LazyCuttingSearchTests.cs 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