From 1c6d02c480bead6f89ccfb10cee6e72bdcc52b8e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 9 Oct 2026 21:26:40 -0400 Subject: [PATCH] perf(cutting): try a bounded entry catalogue before full search --- .../CuttingPlanning/CuttingPlanBatch.cs | 22 ++++++- .../CuttingPlanning/CuttingPlanModels.cs | 7 +++ .../CuttingPlanning/CuttingPlanBatchTests.cs | 57 +++++++++++++++++++ docs/cutting-planner.md | 8 +++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs index a170297..e91fa5a 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs @@ -43,6 +43,10 @@ public sealed class CuttingPlanBatch /// public const int ExpansionsPerPart = 400; + // Extra bounded work on hard/refused plates; the full-cap search still gets its whole budget. + internal const int FirstPassBudget = 1000; + internal const int FirstPassEntries = 8; + private readonly Entry[] entries; private readonly CuttingParameters ownedParameters; @@ -142,7 +146,7 @@ public sealed class CuttingPlanBatch if (entry.Reorder != null) { progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering)); - reorder = CuttingPlanService.Plan(entry.Reorder, token); + reorder = PlanReorder(entry.Reorder, token); if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget) { plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap); @@ -156,6 +160,22 @@ public sealed class CuttingPlanBatch return new(plans, ownedParameters); } + internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token, + int firstPassBudget = FirstPassBudget) + { + // A failed first pass is not a proof of impossibility: retain all original + // full-cap alternatives and the complete original budget for the retry. + if (snapshot.Failure == null && snapshot.Regeneration && snapshot.MaxEntries > FirstPassEntries + && snapshot.ExpansionBudget > firstPassBudget) + { + var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token); + if ((first.Status == CuttingPlanStatus.Ready && first.IndependentlyReplayed) + || first.Status == CuttingPlanStatus.Cancelled) + return first; + } + return CuttingPlanService.Plan(snapshot, token); + } + private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token) { if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null) diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs index 59092ce..388266c 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs @@ -93,6 +93,13 @@ public sealed class CuttingPlanSnapshot public int ExpansionBudget { get; } internal CuttingPlanStatus? Failure { get; } internal IReadOnlyList Findings { get; } + + // A worker-only search variant over the same owned capture. Never recapture the live plate: + // both proposals retain the identical freshness record and confirmed settings. + internal CuttingPlanSnapshot WithSearchLimits(int expansionBudget, int maxEntries) => + new(Placements, StartPoint, expansionBudget, Failure, Findings, Regeneration, + PreservePartOrder, maxEntries, ExpansionObserver, PlateState, OwnedParameters, + Dependencies, BestEffort); } public sealed class FixedProgramPlacement diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs index d889c4c..c3604a7 100644 --- a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs @@ -144,6 +144,63 @@ public class CuttingPlanBatchTests Assert.Null(proposal.BuildPreview(0)); } + [Fact] + public void PlanReorder_ShortPassReady_RetainsReplayedApplicableProposal() + { + var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1)); + var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: ExplicitContourTests.Parameters())); + var full = CuttingPlanService.Plan(snapshot); + var result = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None); + + Assert.Equal(CuttingPlanStatus.Ready, result.Status); + Assert.True(result.IndependentlyReplayed); + Assert.True(result.Expansions < full.Expansions, + $"Short pass {result.Expansions}, full {full.Expansions}"); + Assert.InRange(result.Expansions, 1, CuttingPlanBatch.FirstPassBudget - 1); + Assert.Equal(2, result.ProposedOrder.Count); + Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns)); + Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status); + } + + [Fact] + public void PlanReorder_ShortPassExhausted_UsesFullCapAndOriginalBudget() + { + var plate = Plate(new Nest(), Grid(4)); + var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: ExplicitContourTests.Parameters())); + var full = CuttingPlanService.Plan(snapshot); + var recovered = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None, firstPassBudget: 1); + + Assert.Equal(CuttingPlanStatus.Ready, full.Status); + Assert.Equal(CuttingPlanStatus.Ready, recovered.Status); + Assert.True(recovered.IndependentlyReplayed); + Assert.Equal(full.Expansions, recovered.Expansions); + Assert.Equal(full.RapidDistance, recovered.RapidDistance); + Assert.Equal(full.ProposedOrder.Select(p => p.SourceOrdinal), recovered.ProposedOrder.Select(p => p.SourceOrdinal)); + for (var i = 0; i < full.ProposedOrder.Count; i++) + Assert.True(ProgramContent.Equal(full.ProposedOrder[i].CopyProgram(), recovered.ProposedOrder[i].CopyProgram())); + Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns)); + } + + [Fact] + public void PlanReorder_CancelledDuringShortPass_DoesNotRetryOrMutate() + { + var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1)); + using var cancellation = new CancellationTokenSource(); + var observations = 0; + var request = new CuttingPlanRequest(plate.Parts, confirmedParameters: ExplicitContourTests.Parameters()) + { ExpansionObserver = _ => { observations++; cancellation.Cancel(); } }; + var snapshot = CuttingPlanService.Capture(request); + + var result = CuttingPlanBatch.PlanReorder(snapshot, cancellation.Token); + + Assert.Equal(CuttingPlanStatus.Cancelled, result.Status); + Assert.Equal(1, observations); + Assert.Empty(result.ProposedOrder); + Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns)); + } + [Fact] public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo() { diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 7214f7f..d2aca1c 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -319,6 +319,14 @@ plans every plate that has parts. Both open one dialog built on obstruct it, spacing the parts farther apart. These are suggestions, not guaranteed fixes: replanning runs the same strict checks; an unverified fallback requires separate warning acceptance and is not approval to post or cut. +- The desktop free-order batch first tries up to eight entries per contour for at most + 1,000 expansions. Only a Ready proposal that passes independent replay is used; + otherwise it runs the original 16-entry search with its entire plate budget. + Cancellation stops both attempts. This short pass can choose a different valid + route, and a hard plate can take up to 1,000 extra expansions before the full + attempt. The current-order retry still uses its original 16-entry budget. + Returned expansion counts describe the retained attempt, not a discarded + short pass plus its full-cap retry. - Every plate is captured on the UI thread and checked and planned on a worker. Clean part material is checked for overlaps with the pre-post overlap analyzer; known overlapping parts still block that plate whatever its route. Incomplete checks remain visible warnings, never