diff --git a/OpenNest.Engine/CuttingPlanning/CuttingNestedOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingNestedOrder.cs index 0b5d8d3b..a3ad0395 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingNestedOrder.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingNestedOrder.cs @@ -20,9 +20,11 @@ internal static class CuttingNestedOrder /// Ordinals that must come before each part, learned rules included. /// Per part ordinal, the parts nested inside its cutouts at any depth. /// The point on a host's contours nearest a tool position. + /// Optional point each part is left from (a cut-off's far end); null when + /// every part is left from its center. internal sealed record Context(IReadOnlyList Centers, IReadOnlyList> Prerequisites, IReadOnlyList> Inside, - Func HostEntry, CancellationToken Token); + Func HostEntry, CancellationToken Token, IReadOnlyList Exits = null); /// The part ordinals to order; prerequisites outside this set count as done. /// The modeled tool position before the first of these parts. @@ -63,13 +65,14 @@ internal static class CuttingNestedOrder int[] rootOrder; try { - rootOrder = CuttingPartOrder.Plan(roots, context.Centers, start, hostsLast, context.Token, endpoint); + rootOrder = CuttingPartOrder.Plan(roots, context.Centers, start, hostsLast, context.Token, endpoint, context.Exits); } catch (InvalidOperationException) { try { - rootOrder = CuttingPartOrder.Plan(roots, context.Centers, start, groupRules, context.Token, endpoint); + rootOrder = CuttingPartOrder.Plan(roots, context.Centers, start, groupRules, context.Token, endpoint, + context.Exits); } catch (InvalidOperationException) { @@ -84,7 +87,7 @@ internal static class CuttingNestedOrder if (inner.Length != 0) order.AddRange(Plan(inner, position, context, context.HostEntry(root, position))); order.Add(root); - position = context.Centers[root]; + position = (context.Exits ?? context.Centers)[root]; } var placed = new HashSet(); foreach (var part in order) @@ -95,6 +98,7 @@ internal static class CuttingNestedOrder } return order.ToArray(); - int[] Flat() => CuttingPartOrder.Plan(parts, context.Centers, start, context.Prerequisites, context.Token, endpoint); + int[] Flat() => CuttingPartOrder.Plan(parts, context.Centers, start, context.Prerequisites, context.Token, endpoint, + context.Exits); } } diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs index 39035eda..89e30817 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs @@ -10,7 +10,9 @@ namespace OpenNest.Engine.CuttingPlanning; /// Proposes a whole-part visiting order as an open traveling-salesman path from the start point /// over part centers: nearest neighbor, then 2-opt and Or-opt improvement, never placing a part /// before one of its prerequisites. It is only a proposal; the forward search still checks every -/// rapid and lead along it. +/// rapid and lead along it. A part may be left from somewhere other than where it is reached (a +/// cut-off is entered at its start and left at its far end): each leg then runs from one part's +/// exit to the next part's center, and a reversed run is costed along its own directed legs. /// internal static class CuttingPartOrder { @@ -24,8 +26,11 @@ internal static class CuttingPartOrder /// The modeled tool position before the first of these parts. /// Ordinals that must come before each part; must be acyclic. /// Optional fixed final position after the last of these parts. + /// Optional point each part is left from, per part ordinal; null when every + /// part is left from its center. internal static int[] Plan(IReadOnlyList parts, IReadOnlyList centers, Vector start, - IReadOnlyList> prerequisites, CancellationToken token, Vector? endpoint = null) + IReadOnlyList> prerequisites, CancellationToken token, Vector? endpoint = null, + IReadOnlyList exits = null) { var local = new Dictionary(); for (var i = 0; i < parts.Count; i++) @@ -34,7 +39,8 @@ internal static class CuttingPartOrder var localPrerequisites = parts .Select(p => (IReadOnlyCollection)prerequisites[p].Where(local.ContainsKey).Select(q => local[q]).ToArray()) .ToArray(); - return Plan(localCenters, start, localPrerequisites, token, endpoint).Select(i => parts[i]).ToArray(); + var localExits = exits == null ? null : parts.Select(p => exits[p]).ToArray(); + return Plan(localCenters, start, localPrerequisites, token, endpoint, localExits).Select(i => parts[i]).ToArray(); } /// One representative point per part ordinal. @@ -45,24 +51,29 @@ internal static class CuttingPartOrder /// terminal edge). The terminal edge joins EVERY improvement delta, not just final /// scoring. Null keeps the previous whole-part open-route semantics exactly. /// + /// + /// Optional point each part is left from; null (every part left from its center) keeps the + /// symmetric route exactly. + /// internal static int[] Plan(IReadOnlyList centers, Vector start, - IReadOnlyList> prerequisites, CancellationToken token, Vector? endpoint = null) + IReadOnlyList> prerequisites, CancellationToken token, Vector? endpoint = null, + IReadOnlyList exits = null) { var count = centers.Count; - var order = NearestNeighbor(centers, start, prerequisites, token); + var order = NearestNeighbor(centers, exits ?? centers, start, prerequisites, token); var position = new int[count]; for (var pass = 0; pass < MaxPasses; pass++) { token.ThrowIfCancellationRequested(); - var improved = TwoOpt(order, centers, start, prerequisites, position, token, endpoint); - improved |= OrOpt(order, centers, start, prerequisites, position, token, endpoint); + var improved = TwoOpt(order, centers, exits, start, prerequisites, position, token, endpoint); + improved |= OrOpt(order, centers, exits ?? centers, start, prerequisites, position, token, endpoint); if (!improved) break; } return order; } - private static int[] NearestNeighbor(IReadOnlyList centers, Vector start, + private static int[] NearestNeighbor(IReadOnlyList centers, IReadOnlyList exits, Vector start, IReadOnlyList> prerequisites, CancellationToken token) { var count = centers.Count; @@ -89,39 +100,56 @@ internal static class CuttingPartOrder throw new InvalidOperationException("Part prerequisites form a cycle."); placed[best] = true; order[step] = best; - current = centers[best]; + current = exits[best]; } return order; } // Reverses order[i..j] when that shortens the open path and keeps every prerequisite earlier. - private static bool TwoOpt(int[] order, IReadOnlyList centers, Vector start, + // With exits, a leg runs from one part's exit to the next part's center, so the reversed run's + // own legs change length too; without them they cancel and are left out, exactly as before. + private static bool TwoOpt(int[] order, IReadOnlyList centers, IReadOnlyList exits, Vector start, IReadOnlyList> prerequisites, int[] position, CancellationToken token, Vector? endpoint = null) { var improved = false; var count = order.Length; + var leave = exits ?? centers; // The terminal edge belongs to every delta: reversing the route's tail swaps which // endpoint-side center faces the fixed final position. double Tail(Vector from) => endpoint is { } e ? from.DistanceTo(e) : 0; for (var i = 0; i < count - 1; i++) { token.ThrowIfCancellationRequested(); + // The run's internal legs, forward and reversed, grown one part at a time. + double forward = 0, reversed = 0; for (var j = i + 1; j < count; j++) { - var before = Point(i - 1).DistanceTo(centers[order[i]]) - + (j + 1 < count ? centers[order[j]].DistanceTo(centers[order[j + 1]]) : Tail(centers[order[j]])); - var after = Point(i - 1).DistanceTo(centers[order[j]]) - + (j + 1 < count ? centers[order[i]].DistanceTo(centers[order[j + 1]]) : Tail(centers[order[i]])); + if (exits != null) + { + forward += leave[order[j - 1]].DistanceTo(centers[order[j]]); + reversed += leave[order[j]].DistanceTo(centers[order[j - 1]]); + } + var before = Point(i - 1).DistanceTo(centers[order[i]]) + forward + + (j + 1 < count ? leave[order[j]].DistanceTo(centers[order[j + 1]]) : Tail(leave[order[j]])); + var after = Point(i - 1).DistanceTo(centers[order[j]]) + reversed + + (j + 1 < count ? leave[order[i]].DistanceTo(centers[order[j + 1]]) : Tail(leave[order[i]])); if (after >= before - Epsilon || !CanReverse(order, i, j, prerequisites, position)) continue; Array.Reverse(order, i, j - i + 1); improved = true; + // The run is reversed now; its legs are re-measured from scratch. + forward = reversed = 0; + for (var k = i + 1; k <= j && exits != null; k++) + { + forward += leave[order[k - 1]].DistanceTo(centers[order[k]]); + reversed += leave[order[k]].DistanceTo(centers[order[k - 1]]); + } } } return improved; - Vector Point(int index) => index < 0 ? start : centers[order[index]]; + Vector Point(int index) => index < 0 ? start : leave[order[index]]; } // A reversal breaks a prerequisite only when both parts lie inside the reversed span. @@ -138,7 +166,7 @@ internal static class CuttingPartOrder } // Moves a run of one to three parts to a later or earlier gap when that shortens the path. - private static bool OrOpt(int[] order, IReadOnlyList centers, Vector start, + private static bool OrOpt(int[] order, IReadOnlyList centers, IReadOnlyList exits, Vector start, IReadOnlyList> prerequisites, int[] position, CancellationToken token, Vector? endpoint = null) { @@ -172,7 +200,7 @@ internal static class CuttingPartOrder // end — the fixed endpoint when one was supplied, so the terminal edge is in every delta). double Gap(int a, int b) { - var from = a < 0 ? start : centers[order[a]]; + var from = a < 0 ? start : exits[order[a]]; if (b >= count) return endpoint is { } e ? from.DistanceTo(e) : 0; if (b < 0) diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 336b0f04..3103bfab 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -199,6 +199,7 @@ internal static class JointCuttingPlanSearch private static CuttingNestedOrder.Context NestedOrder(CuttingPlanSnapshot snapshot, Vector[] centers, HashSet[] prerequisites, CancellationToken token) { + var exits = snapshot.Placements.Any(p => p.IsCutOff) ? snapshot.Placements.Select(Exit).ToArray() : null; var inside = snapshot.Placements.Select((placement, host) => placement.IsCutOff ? [] : snapshot.Dependencies.PrerequisitesOf(host).Where(p => !snapshot.Placements[p].IsCutOff).ToArray()).ToArray(); var contours = new Dictionary(); @@ -210,7 +211,7 @@ internal static class JointCuttingPlanSearch .Select(m => m.Curve.ToEntity()).ToArray(); return entities.Length == 0 ? centers[host] : entities.Select(e => e.ClosestPointTo(position)).MinBy(point => point.DistanceTo(position)); - }, token); + }, token, exits); } // When the approach crossed the part cut just before the one it left from, the tool went on @@ -268,9 +269,17 @@ internal static class JointCuttingPlanSearch return false; } - // The center of the part's placed cut material, for ordering only. + // Where the tool reaches a part, for ordering only: the center of its placed cut material, + // or a cut-off's start. A cut-off runs from a fixed edge, so it is entered at its start and + // left at its far end (Exit); its middle is neither. private static Vector Center(FixedProgramPlacement placement) => - CutExtent(placement)?.Center ?? placement.Execution.DeparturePoint; + placement.IsCutOff && placement.Execution.Motions.FirstOrDefault(m => !m.Rapid) is { Start: { } start } + ? start + : CutExtent(placement)?.Center ?? placement.Execution.DeparturePoint; + + // Where the tool leaves a part, for ordering only: a cut-off's far end, otherwise its center. + private static Vector Exit(FixedProgramPlacement placement) => + placement.IsCutOff ? placement.Execution.DeparturePoint : Center(placement); // The extent of the part's placed cut material, or null when it has none. private static Box CutExtent(FixedProgramPlacement placement) @@ -444,7 +453,7 @@ internal static class JointCuttingPlanSearch for (var i = node.Order.Length + 1; i < sequence.Length; i++) if (!finished.Contains(sequence[i]) && !IsSegment(snapshot.Placements[sequence[i]])) return snapshot.Placements[sequence[i]]; - var from = Center(source); + var from = Exit(source); FixedProgramPlacement best = null; var bestDistance = double.PositiveInfinity; foreach (var candidate in snapshot.Placements diff --git a/OpenNest.Tests/CuttingPlanning/CutOffArrivalOrderTests.cs b/OpenNest.Tests/CuttingPlanning/CutOffArrivalOrderTests.cs new file mode 100644 index 00000000..682e8972 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/CutOffArrivalOrderTests.cs @@ -0,0 +1,87 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// A cut-off is entered at its start, on the origin-side edge, and left at its far end. The route +/// plans it that way: the parts before a line that splits the sheet finish beside its start, so +/// the rapid onto it crosses nothing already cut. +/// +public class CutOffArrivalOrderTests +{ + // Two columns of squares left of a full-height line, which is cut last, with the tool + // arriving from the top right. Aimed at the line's middle, the route ends at the top of the + // left column and the rapid down to the line's start crosses the right one. Aimed at the + // start, it ends at the bottom of the right column, beside it. + [Fact] + public void ColumnsBesideASplittingLine_FinishBesideTheLinesStart() + { + var nest = new Nest(); + var plate = nest.CreatePlate(); + plate.Size = new Size(48, 8); + var square = new Drawing("square", Square()); + foreach (var x in new[] { 0.5, 2.5 }) + for (var y = 2.0; y <= 42; y += 4) + plate.Parts.Add(new Part(square, new Vector(x, y))); + var line = new CutOff(new Vector(4, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(line); + plate.RegenerateCutOffs(new CutOffSettings()); + PlateSequencing.PlaceCutOffs(plate, [line]); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, new Vector(6, 48), + confirmedParameters: ExplicitContourTests.Parameters())); + + Assert.True(result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed, + $"{result.Status}: {string.Join("; ", result.Findings.Select(f => f.Message).Take(3))}"); + var order = result.ProposedOrder.Select(o => o.SourcePart).ToArray(); + Assert.True(order[^1].BaseDrawing.IsCutOff); + Assert.Equal(new Vector(2.5, 2), order[^2].Location); + } + + // A line through open scrap from (10, 0) to (10, 30) precedes nothing, so it is routed like + // a part: reached at its start, left at its far end. Square C sits beside the start and B + // beside the end, so the route goes C, the line, B; one that thought the tool stays at the + // start would go the line, C, B and travel the column twice. + [Fact] + public void LineThroughScrap_RouteGoesOnFromItsFarEnd() + { + var nest = new Nest(); + var plate = nest.CreatePlate(); + plate.Size = new Size(40, 20); + var square = new Drawing("square", Square()); + var c = new Part(square, new Vector(12, 1)); + var b = new Part(square, new Vector(12, 31)); + plate.Parts.Add(b); + plate.Parts.Add(c); + var line = new CutOff(new Vector(10, 0), CutOffAxis.Vertical) { StartLimit = 0, EndLimit = 30 }; + plate.CutOffs.Add(line); + plate.RegenerateCutOffs(new CutOffSettings()); + PlateSequencing.PlaceCutOffs(plate, [line]); + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, + confirmedParameters: ExplicitContourTests.Parameters())); + + Assert.True(result.Status == CuttingPlanStatus.Ready && result.IndependentlyReplayed, + $"{result.Status}: {string.Join("; ", result.Findings.Select(f => f.Message).Take(3))}"); + var order = result.ProposedOrder.Select(o => o.SourcePart).ToArray(); + Assert.Equal(3, order.Length); + Assert.Same(c, order[0]); + Assert.True(order[1].BaseDrawing.IsCutOff); + Assert.Same(b, order[2]); + } + + private static Program Square() + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(1, 0); + program.LineTo(1, 1); + program.LineTo(0, 1); + program.LineTo(0, 0); + return program; + } +} diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPartOrderTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPartOrderTests.cs index a068dd5d..3c284469 100644 --- a/OpenNest.Tests/CuttingPlanning/CuttingPartOrderTests.cs +++ b/OpenNest.Tests/CuttingPlanning/CuttingPartOrderTests.cs @@ -86,6 +86,42 @@ public class CuttingPartOrderTests Assert.Throws(() => Plan([new(0, 0), new(1, 0)], prerequisites)); } + [Fact] + public void Plan_LeavesAPartFromItsExitPoint() + { + // Part 1 is a line entered at (2, 0) and left at (2, 100). Toured from its entry + // alone, 1, 0, 2 looks shortest; left from its far end, 0, 1, 2 is (5 + 5.39 + 5.39). + Vector[] entries = [new(0, 5), new(2, 0), new(0, 95)]; + Vector[] exits = [new(0, 5), new(2, 100), new(0, 95)]; + + Assert.Equal(new[] { 0, 1, 2 }, CuttingPartOrder.Plan(entries, Vector.Zero, NoPrerequisites(3), + CancellationToken.None, exits: exits)); + } + + // Part 3 is entered at (0, 6) and left at (0, 13). A reversed run is costed along its own + // legs, from each part's exit: 0, 2, 4, 1, 3 (14.97), not 3, 1, 4, 2, 0 (20.17). + [Fact] + public void Plan_CostsAReversedRunAlongItsOwnLegsFromEachExit() + { + Vector[] entries = [new(6, 1), new(2, 6), new(5, 2), new(0, 6), new(4, 2)]; + Vector[] exits = [new(6, 1), new(2, 6), new(5, 2), new(0, 13), new(4, 2)]; + + Assert.Equal(new[] { 0, 2, 4, 1, 3 }, CuttingPartOrder.Plan(entries, Vector.Zero, NoPrerequisites(5), + CancellationToken.None, exits: exits)); + } + + // Part 4 is entered at (4, 4) and left at (4, 13). Nearest first goes on from where each + // part is left: 4, 2, 1, 0, 3 (19.92), not 0, 3, 2, 1, 4 (20.49). + [Fact] + public void Plan_NearestFirstGoesOnFromEachPartsExit() + { + Vector[] entries = [new(7, 4), new(5, 7), new(7, 9), new(9, 6), new(4, 4)]; + Vector[] exits = [new(7, 4), new(5, 7), new(7, 9), new(9, 6), new(4, 13)]; + + Assert.Equal(new[] { 4, 2, 1, 0, 3 }, CuttingPartOrder.Plan(entries, Vector.Zero, NoPrerequisites(5), + CancellationToken.None, exits: exits)); + } + private static int[] Plan(Vector[] centers, IReadOnlyCollection[] prerequisites) => CuttingPartOrder.Plan(centers, Vector.Zero, prerequisites, CancellationToken.None); diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 3ba2b5fb..88ac3775 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -198,7 +198,12 @@ strip lines were added, and the segments are placed into that plan afterwards (s [cutoff segments](#cutoff-segments) below). The order is an open traveling-salesman path over part centers from the start point: nearest neighbor, then 2-opt reversals and Or-opt moves of one to three parts, never placing a part -before a cutoff or nested-part prerequisite. A part with other parts nested in its +before a cutoff or nested-part prerequisite. A cutoff is not a center: the path reaches +it where its cut starts and leaves from where it ends (read from its program as given; +the search may still reverse a head-locked far-edge segment), and a reversed run is +costed along its own legs from each part's exit. The parts +before a line that splits the sheet therefore finish beside its start, and the part cut +just before it faces that start. A part with other parts nested in its cutouts (a host) is cut after all of them, so the tool reaches it from whichever was cut last, and from the middle of a crowded cutout every straight approach crosses a finished neighbor. The nested parts of each host are therefore toured as one run,