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.