From 64459f7bc8f0e21866b2521cac88a766ad03bb0d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 7 Oct 2026 15:21:09 -0400 Subject: [PATCH] fix(cutting): try endpoint alternatives before hole combinations --- .../CuttingPlanning/JointCuttingPlanSearch.cs | 44 +++++++--- .../HoleEndpointBudgetTests.cs | 85 +++++++++++++++++++ docs/cutting-planner.md | 9 +- 3 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 848fc1f..c59e065 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -10,8 +10,9 @@ using OpenNest.Geometry; namespace OpenNest.Engine.CuttingPlanning; /// -/// Plans whole parts and their emitted contour prefixes along a part order. A preserved order is -/// followed with full backtracking. Otherwise the order comes from ; +/// Plans whole parts and their emitted contour prefixes along a part order. Multi-part holed +/// requests first try one ranked hole chain per endpoint, then retain full backtracking. +/// A preserved order stays fixed. Otherwise the order comes from ; /// when a part on it cannot be reached without crossing parts already cut, the search learns /// "cut this part before those", keeps the parts cut before them and re-plans the rest. Once /// nothing new can be learned, the remaining budget goes to a full search over every ready part. @@ -32,24 +33,39 @@ internal static class JointCuttingPlanSearch { var walk = new Walk(snapshot, token); var count = snapshot.Placements.Count; + var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1); + var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours; + var preferEndpoints = count > 1 && maxContours > 1; try { if (snapshot.PreservePartOrder) { - var kept = walk.Follow(Enumerable.Range(0, count).ToArray(), null, null); + var keptSequence = Enumerable.Range(0, count).ToArray(); + if (preferEndpoints) + { + var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true); + if (preferred.Order != null) + return walk.Ready(preferred.Order); + } + var kept = walk.Follow(keptSequence, null, null); return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted(); } var centres = snapshot.Placements.Select(Centre).ToArray(); var prerequisites = Enumerable.Range(0, count) .Select(i => new HashSet(snapshot.Dependencies.PrerequisitesOf(i))).ToArray(); - var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1); - var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours; + var sequence = CuttingPartOrder.Plan(Enumerable.Range(0, count).ToArray(), centres, snapshot.StartPoint, prerequisites, token); Node resume = null; while (true) { + if (preferEndpoints) + { + var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true); + if (preferred.Order != null) + return walk.Ready(preferred.Order); + } var attempt = walk.Follow(sequence, stall, resume); if (attempt.Order != null) return walk.Ready(attempt.Order); @@ -173,7 +189,7 @@ internal static class JointCuttingPlanSearch /// With a stall limit the attempt ends once that many expansions pass without getting /// further along the order; it never backtracks behind its starting node. /// - internal Attempt Follow(int[] sequence, int? stall, Node resume) + internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false) { var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null); var attempt = new Attempt(sequence); @@ -194,7 +210,7 @@ internal static class JointCuttingPlanSearch progressExpansions = Expansions; else if (stall is int limit && Expansions - progressExpansions > limit) return attempt; - frame.Children ??= OrderedChildren(node, sequence, attempt); + frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly); if (frame.Next == frame.Children.Length) { stack.Pop(); @@ -243,9 +259,9 @@ 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) + private Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly) { - var edges = Expand(node, sequence, attempt).ToList(); + var edges = Expand(node, sequence, attempt, preferredOnly).ToList(); if (edges.Count <= 1) return edges.ToArray(); // Stable source order: the minimum incremental rapid per source, ties ordinal. @@ -263,7 +279,7 @@ internal static class JointCuttingPlanSearch .ToArray(); } - private IEnumerable Expand(Node node, int[] sequence, Attempt attempt) + private IEnumerable Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly) { IEnumerable sources; if (node.Active is { } active) @@ -315,6 +331,11 @@ internal static class JointCuttingPlanSearch var boundary = node.Active?.Boundary ?? node; var contours = choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal } : Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c)); + // 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 (preferredOnly && preference != null) + contours = contours.OrderBy(c => Array.IndexOf(preference.Route, c)).Take(1); foreach (var contour in contours) { token.ThrowIfCancellationRequested(); @@ -346,7 +367,8 @@ internal static class JointCuttingPlanSearch } if (entries.Count == 0) continue; - for (var entry = 0; entry < entries.Count; entry++) + var entryCount = preferredOnly && node.Active?.Perimeter != null ? System.Math.Min(1, entries.Count) : entries.Count; + for (var entry = 0; entry < entryCount; entry++) { CountExpansion(source); // Before emission/native queries, including rejected candidates. var prefix = choices.Append(entries[entry]).ToArray(); diff --git a/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs b/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs new file mode 100644 index 0000000..b188e2c --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/HoleEndpointBudgetTests.cs @@ -0,0 +1,85 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Diagnostics; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class HoleEndpointBudgetTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LaterFixedApproachTriesOtherEndpointsBeforeHoleCombinations(bool preserveOrder) + { + var parameters = HoleLookAheadTests.Parameters(); + var clean = HoleLookAheadTests.MixedHoles(); + foreach (var centre in new[] { new Vector(4, 8), new Vector(8, 8) }) + { + clean.MoveTo(centre.X + 0.75, centre.Y); + clean.ArcTo(centre.X + 0.75, centre.Y, centre.X, centre.Y, RotationType.CCW); + } + var first = new Part(new Drawing("four holes", clean)); + var nextClean = LeadPathValidationTests.Rectangle(0, 0, 2, 2); + nextClean.MoveTo(-22, 10); + nextClean.Codes.Add(new LinearMove(-21, 10) { Layer = LayerType.Scribe }); + nextClean.MoveTo(0, 10); + nextClean.Codes.Add(new LinearMove(1, 10) { Layer = LayerType.Scribe }); + var next = new Part(new Drawing("fixed marked part", nextClean), new Vector(20, 2)); + var nextPrepared = PreparedContours.Capture(nextClean, parameters); + Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit( + [nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true)); + var before = new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray(); + var start = new Vector(-2, 4); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], start, + confirmedParameters: parameters, preservePartOrder: preserveOrder)); + var source = snapshot.Placements[0]; + var prepared = source.Prepared; + var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material, + snapshot.Placements.Select(p => p.Material).ToArray()); + var target = new Vector(21, 3); + var outside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(4, target) + .RankTowardNextCut(target, start), c => adapter.Check(c.Choice)).Choices[0]; + var centres = prepared.HoleCentres().Select(c => c ?? Vector.Zero).ToArray(); + var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2, 3 }, centres, start, + PreferredContourEntries.Pierce(prepared, outside, default)); + var preferred = PreferredContourEntries.TryPlan(prepared, outside, route, centres, start, + c => adapter.Check(c.Choice)); + Assert.True(preferred.IsPreferred, preferred.Reason); + var execution = ExecutionMotionReader.Read(prepared.Emit(preferred.HoleChoices.Append(outside).ToArray()), + Vector.Zero, start, default); + var checker = new ReleasedContourState(); + Assert.Empty(checker.Check(execution, start, 1)); + Assert.Contains(checker.Check(snapshot.Placements[1].Execution, execution.DeparturePoint, 2), + f => f.Kind == PostVerificationKind.RapidCrossing); + + var result = CuttingPlanService.Plan(snapshot); + + Assert.True(result.Status == CuttingPlanStatus.Ready, + $"{result.Status}, {result.Expansions} expansions: " + string.Join("; ", result.Findings.Select(f => f.Message))); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + Assert.Equal(5, result.ProposedOrder[0].ContourChoices.Count); + Assert.Equal(4, result.ProposedOrder[0].ContourChoices[^1].ContourOrdinal); + Assert.NotEqual(outside.Point, result.ProposedOrder[0].ContourChoices[^1].Point); + Assert.InRange(result.Expansions, 1, 20000); + var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default); + Assert.Equal(CuttingPlanStatus.Ready, replay.Status); + Assert.True(replay.IndependentlyReplayed); + Assert.Equal(before, new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program))); + Assert.Equal(before[1], ExplicitContourTests.Fingerprint(result.ProposedOrder[1].CopyProgram())); + + var bounded = CuttingPlanService.Plan(new CuttingPlanRequest([first, next], start, + 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 cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + Assert.Equal(CuttingPlanStatus.Cancelled, CuttingPlanService.Plan(snapshot, cancelled.Token).Status); + Assert.Equal(before, new[] { first, next }.Select(p => ExplicitContourTests.Fingerprint(p.Program))); + } +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index c5b1ee7..cdaeb4f 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -77,7 +77,14 @@ the final replay enforce them: With regeneration, the bounded deterministic search plans internal contour order and native entry candidates part by part along a whole-part order. Internal contours precede their own perimeter; parts remain contiguous. Backtracking can -revisit an earlier entry when a later part cannot be reached safely. +revisit an earlier entry when a later part cannot be reached safely. For multi-part +requests containing regenerated holes, it first tries one ranked hole chain for +each outside endpoint. This lets a later blocked approach change the previous +part's departure without first exhausting combinations of its earlier holes. +This preferred pass uses the same emitted-motion checks, shared expansion budget +and stall limit as the retained search; on failure the full entry/hole-order +backtracking pass remains available. Single-part and no-hole requests keep their +existing search order. A preserved order is followed as given. Otherwise the order is an open travelling-salesman path over part centres from the start point: nearest neighbour,