From a1458a355befc34f59bba6a3ccdd3d6344bddc67 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 07:46:19 -0400 Subject: [PATCH] fix(cutting): route best-effort holes instead of cutting them in source order The best-effort fallback (used whenever a part has touching or uncertain material boundaries, which sends the whole plate there) cut each part's holes in raw source order with the nearest entry. On hole grids that order doubles back, so the rapids between holes cross; any crossing means a shorter uncrossed order exists. On A0228-01 plate 1 this cost ~300 in of rapid travel and drew crossing rapids on PT08 in the simulator. Tour the holes with CuttingHoleOrder (the nearest-neighbour + 2-opt/Or-opt proposal the strict search already uses), as an open path from the arrival, perimeter still last. Hole-centre selection is shared with the strict search. Co-Authored-By: Claude Opus 5.5 --- .../CuttingPlanning/BestEffortCuttingPlan.cs | 11 +++++- .../CuttingPlanning/CuttingHoleOrder.cs | 21 +++++++++- .../CuttingPlanning/JointCuttingPlanSearch.cs | 8 +--- .../BestEffortCuttingPlanTests.cs | 39 +++++++++++++++++++ docs/cutting-planner.md | 4 +- 5 files changed, 71 insertions(+), 12 deletions(-) diff --git a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs index 821f7ced..c01ff656 100644 --- a/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs +++ b/OpenNest.Engine/CuttingPlanning/BestEffortCuttingPlan.cs @@ -9,7 +9,8 @@ namespace OpenNest.Engine.CuttingPlanning; /// /// A deliberately uncertified fallback, not a relaxed safety checker. Reuses closed native -/// contour preparation and emission, retaining source order except for proven prerequisites. +/// contour preparation and emission, retaining source part order except for proven +/// prerequisites; each part's holes follow a proposed tour, its perimeter last. /// Unknown program semantics, invalid motions and unrepresentable contours still refuse. /// internal static class BestEffortCuttingPlan @@ -44,7 +45,13 @@ internal static class BestEffortCuttingPlan { var choices = new List(); var approach = position - source.Location; - for (var contour = 0; contour < prepared.Count; contour++) + // Source order is not a route: tour the holes from the arrival (the same + // nearest-neighbour + 2-opt proposal the search uses) so the rapids between + // them do not double back across each other. The perimeter is still last. + var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray(); + var route = CuttingHoleOrder.Plan(holes, CuttingHoleOrder.Centres(prepared, approach, token), + approach, null, token); + foreach (var contour in route.Append(prepared.PerimeterOrdinal)) { token.ThrowIfCancellationRequested(); choices.Add(prepared.ClosestEntry(contour, approach)); diff --git a/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs index e716ea4d..35a9fa09 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using OpenNest.CNC.CuttingPlanning; using OpenNest.Geometry; namespace OpenNest.Engine.CuttingPlanning; @@ -18,14 +19,30 @@ namespace OpenNest.Engine.CuttingPlanning; /// internal static class CuttingHoleOrder { + /// + /// One representative point per hole ordinal of , in its LOCAL + /// coordinates. The tool arriving inside a cutout from a part nested there says nothing + /// useful about that cutout's centre: it is routed from where the tool is instead, before + /// a later hop to it has to cross the other parts nested in it. + /// + internal static Vector[] Centres(PreparedContours prepared, Vector localArrival, CancellationToken token) + { + var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray(); + for (var hole = 0; hole < prepared.PerimeterOrdinal; hole++) + if (prepared.HoleContains(hole, localArrival)) + centres[hole] = localArrival; + return centres; + } + /// /// Orders (stable ordinals, each appearing exactly once in the /// result, never the perimeter) to minimize arrival -> holes -> distance. supplies one - /// representative point per hole ordinal. Empty holes give an empty order. + /// representative point per hole ordinal. Empty holes give an empty order. Without a + /// perimeter entry the path is open: it ends at whichever hole keeps it shortest. /// internal static IReadOnlyList Plan(IReadOnlyList holes, - IReadOnlyList centresByOrdinal, Vector arrival, Vector perimeterEntry, + IReadOnlyList centresByOrdinal, Vector arrival, Vector? perimeterEntry, CancellationToken token = default) { if (holes == null) diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index ff003a25..0335b495 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -770,15 +770,9 @@ internal static class JointCuttingPlanSearch { var source = active.Source; var prepared = source.Prepared; - var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray(); var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray(); var localArrival = active.Arrival - source.Location; - // The tool arrives inside a cutout from a part nested there. That cutout's centre - // says nothing about where its cut can be reached: route it from where the tool is, - // before a later hop to it has to cross the other parts nested in it. - foreach (var hole in holes) - if (prepared.HoleContains(hole, localArrival)) - centres[hole] = localArrival; + var centres = CuttingHoleOrder.Centres(prepared, localArrival, token); var endpoint = perimeter.Point; var entries = new Dictionary(); var pierces = holes.ToDictionary(h => h, h => centres[h]); diff --git a/OpenNest.Tests/CuttingPlanning/BestEffortCuttingPlanTests.cs b/OpenNest.Tests/CuttingPlanning/BestEffortCuttingPlanTests.cs index 606ce128..bb8d9f76 100644 --- a/OpenNest.Tests/CuttingPlanning/BestEffortCuttingPlanTests.cs +++ b/OpenNest.Tests/CuttingPlanning/BestEffortCuttingPlanTests.cs @@ -237,6 +237,45 @@ public class BestEffortCuttingPlanTests Assert.All(parts, p => Assert.False(p.HasManualLeadIns)); } + [Fact] + public void Holes_FollowARouteWhoseRapidsDoNotCross() + { + var program = new Program(); + program.MoveTo(0, 0); program.LineTo(0, 20); program.LineTo(40, 20); + program.LineTo(40, 0); program.LineTo(0, 0); + // Touching holes force the best-effort plan; the others zig-zag in source order. + program.MoveTo(2, 2); program.LineTo(4, 2); program.LineTo(4, 4); + program.LineTo(2, 4); program.LineTo(2, 2); + program.MoveTo(4, 2); program.LineTo(6, 2); program.LineTo(6, 4); + program.LineTo(4, 4); program.LineTo(4, 2); + foreach (var (x, y) in new[] { (10, 5), (30, 15), (30, 5), (10, 15), (20, 5), (20, 15) }) + { + program.MoveTo(x - 0.5, y - 0.5); program.LineTo(x + 0.5, y - 0.5); program.LineTo(x + 0.5, y + 0.5); + program.LineTo(x - 0.5, y + 0.5); program.LineTo(x - 0.5, y - 0.5); + } + var part = new Part(new Drawing("zig-zag holes", program), new Vector(1, 1)); + + var proposal = CuttingPlanBatch.Capture([Plate(part)], ExplicitContourTests.Parameters(), false).Plan(); + + var result = Assert.Single(proposal.Plates).Result; + Assert.Equal(CuttingPlanStatus.BestEffort, result.Status); + var generated = Assert.Single(result.ProposedOrder); + Assert.Equal(9, generated.ContourChoices.Count); + var rapids = ExecutionMotionReader.ReadSupported(generated.CopyProgram(), part.Location, null).Motions + .Where(m => m.Rapid && m.Start != null).Select(m => (Start: m.Start!.Value, m.End)).ToArray(); + Assert.Equal(8, rapids.Length); + for (var i = 0; i < rapids.Length; i++) + for (var j = i + 1; j < rapids.Length; j++) + Assert.False(Cross(rapids[i].Start, rapids[i].End, rapids[j].Start, rapids[j].End), + $"Rapid {i + 1} crosses rapid {j + 1}."); + + static bool Cross(Vector a, Vector b, Vector c, Vector d) + { + static double Side(Vector p, Vector q, Vector r) => (q.X - p.X) * (r.Y - p.Y) - (q.Y - p.Y) * (r.X - p.X); + return Side(c, d, a) * Side(c, d, b) < 0 && Side(a, b, c) * Side(a, b, d) < 0; + } + } + [SkippableTheory] [InlineData(0)] [InlineData(45)] diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 0f391020..eb8a9652 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -627,7 +627,9 @@ automatically attempts a bounded, deterministic best-effort proposal. Touching, or numerically uncertain material boundaries need not prevent lead-in generation when their closed executable contours are readable. The fallback reuses the existing contour emitter: internal contours first, the largest bounding perimeter last, nearest entry from the preceding -departure, with the confirmed lead styles. It retains source part order except to satisfy +departure, with the confirmed lead styles. The internal contours follow the same open +nearest-neighbour + 2-opt/Or-opt tour over hole centres that the strict search proposes, not +source order, so their rapids do not cross each other. It retains source part order except to satisfy proven cutoff and nested-part prerequisites. `Keep the current part order` still refuses an order that contradicts a proven prerequisite. Uncertain containment is named as a warning.