fix(planning): route a cut-off from its start to its far end

A cut-off always runs away from its fixed edge: the origin side, the only
edge guaranteed to be a real sheet edge. The part order is proposed as a
traveling-salesman path over one point per placement, and a cut-off's
point was the middle of its line. The route did not know the tool must
arrive at the line's start and leaves at its far end, so the parts
before a sheet-splitting line finished level with its middle, and the
rapid down to its start crossed parts already cut.

Each placement now has an entry and an exit point; they differ only for
cut-offs (start and departure). Nearest neighbor and Or-opt go on from
each part's exit, and 2-opt costs a reversed run along its own directed
legs; with no cut-off on the plate the exits are not passed and the
symmetric route is unchanged. The part cut before a cut-off faces its
start.

Cut-off direction is not chosen here: the order reads each cut-off's start
and end from its program as given, and the joint search may still reverse a
head-locked far-edge segment.
This commit is contained in:
aj committed 2026-11-01 16:51:09 -05:00
1 parent 277719a1d6
commit 4e92d0e8f3
6 files changed
+196 -27

No files matched your search

@@ -20,9 +20,11 @@ internal static class CuttingNestedOrder
/// <param name="Prerequisites">Ordinals that must come before each part, learned rules included.</param>
/// <param name="Inside">Per part ordinal, the parts nested inside its cutouts at any depth.</param>
/// <param name="HostEntry">The point on a host's contours nearest a tool position.</param>
/// <param name="Exits">Optional point each part is left from (a cut-off's far end); null when
/// every part is left from its center.</param>
internal sealed record Context(IReadOnlyList<Vector> Centers,
IReadOnlyList<IReadOnlyCollection<int>> Prerequisites, IReadOnlyList<IReadOnlyCollection<int>> Inside,
Func<int, Vector, Vector> HostEntry, CancellationToken Token);
Func<int, Vector, Vector> HostEntry, CancellationToken Token, IReadOnlyList<Vector> Exits = null);
/// <param name="parts">The part ordinals to order; prerequisites outside this set count as done.</param>
/// <param name="start">The modeled tool position before the first of these parts.</param>
@@ -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<int>();
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);
}
}
@@ -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.
/// </summary>
internal static class CuttingPartOrder
{
@@ -24,8 +26,11 @@ internal static class CuttingPartOrder
/// <param name="start">The modeled tool position before the first of these parts.</param>
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
/// <param name="endpoint">Optional fixed final position after the last of these parts.</param>
/// <param name="exits">Optional point each part is left from, per part ordinal; null when every
/// part is left from its center.</param>
internal static int[] Plan(IReadOnlyList<int> parts, IReadOnlyList<Vector> centers, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token, Vector? endpoint = null)
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token, Vector? endpoint = null,
IReadOnlyList<Vector> exits = null)
{
var local = new Dictionary<int, int>();
for (var i = 0; i < parts.Count; i++)
@@ -34,7 +39,8 @@ internal static class CuttingPartOrder
var localPrerequisites = parts
.Select(p => (IReadOnlyCollection<int>)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();
}
/// <param name="centers">One representative point per part ordinal.</param>
@@ -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.
/// </param>
/// <param name="exits">
/// Optional point each part is left from; null (every part left from its center) keeps the
/// symmetric route exactly.
/// </param>
internal static int[] Plan(IReadOnlyList<Vector> centers, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token, Vector? endpoint = null)
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token, Vector? endpoint = null,
IReadOnlyList<Vector> 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<Vector> centers, Vector start,
private static int[] NearestNeighbor(IReadOnlyList<Vector> centers, IReadOnlyList<Vector> exits, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> 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<Vector> 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<Vector> centers, IReadOnlyList<Vector> exits, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> 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<Vector> centers, Vector start,
private static bool OrOpt(int[] order, IReadOnlyList<Vector> centers, IReadOnlyList<Vector> exits, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> 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)
@@ -199,6 +199,7 @@ internal static class JointCuttingPlanSearch
private static CuttingNestedOrder.Context NestedOrder(CuttingPlanSnapshot snapshot, Vector[] centers,
HashSet<int>[] 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<int, Entity[]>();
@@ -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
@@ -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;
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -86,6 +86,42 @@ public class CuttingPartOrderTests
Assert.Throws<InvalidOperationException>(() => 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<int>[] prerequisites) =>
CuttingPartOrder.Plan(centers, Vector.Zero, prerequisites, CancellationToken.None);
+6 -1
View File
@@ -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,