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.