diff --git a/OpenNest.Core/CNC/RapidEnumerator.cs b/OpenNest.Core/CNC/RapidEnumerator.cs
index e8e45cc..bbedb01 100644
--- a/OpenNest.Core/CNC/RapidEnumerator.cs
+++ b/OpenNest.Core/CNC/RapidEnumerator.cs
@@ -7,10 +7,30 @@ namespace OpenNest.CNC
{
public readonly record struct Segment(Vector From, Vector To);
+ ///
+ /// Enumerates plate rapids in cutting order, advancing through all cutting
+ /// motions before connecting to the next part (including scrap cutoffs).
+ ///
+ public static List Enumerate(IEnumerable parts)
+ {
+ var results = new List();
+ var pos = Vector.Zero;
+
+ foreach (var part in parts)
+ pos = AppendProgram(part.Program, part.Location, pos, results);
+
+ return results;
+ }
+
public static List Enumerate(Program pgm, Vector basePos, Vector startPos)
{
var results = new List();
+ AppendProgram(pgm, basePos, startPos, results);
+ return results;
+ }
+ private static Vector AppendProgram(Program pgm, Vector basePos, Vector startPos, List results)
+ {
// Draw the rapid from the previous tool position to the program's first
// pierce point. The walk then starts at the program origin (basePos), not
// the pierce: the skipped first rapid still advances pos, so starting at
@@ -21,7 +41,8 @@ namespace OpenNest.CNC
var pos = basePos;
Walk(pgm, basePos, ref pos, skipFirst: true, results);
- return results;
+ // The last rapid ends at a pierce, not necessarily the final tool position.
+ return pos;
}
private static Vector FirstPiercePoint(Program pgm, Vector basePos)
diff --git a/OpenNest.Tests/CNC/PlateRapidEnumeratorTests.cs b/OpenNest.Tests/CNC/PlateRapidEnumeratorTests.cs
new file mode 100644
index 0000000..91c458c
--- /dev/null
+++ b/OpenNest.Tests/CNC/PlateRapidEnumeratorTests.cs
@@ -0,0 +1,141 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using OpenNest.IO;
+
+namespace OpenNest.Tests.CNC;
+
+public class PlateRapidEnumeratorTests
+{
+ [Theory]
+ [InlineData(1, CutDirection.AwayFromOrigin)]
+ [InlineData(2, CutDirection.AwayFromOrigin)]
+ [InlineData(3, CutDirection.AwayFromOrigin)]
+ [InlineData(4, CutDirection.AwayFromOrigin)]
+ [InlineData(1, CutDirection.TowardOrigin)]
+ [InlineData(2, CutDirection.TowardOrigin)]
+ [InlineData(3, CutDirection.TowardOrigin)]
+ [InlineData(4, CutDirection.TowardOrigin)]
+ public void Enumerate_AppliedAutomaticCutoffs_ConnectsFromFinalCut(int quadrant, CutDirection direction)
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(0, 0));
+ program.Codes.Add(new LinearMove(70, 0));
+ program.Codes.Add(new LinearMove(70, 20));
+ program.Codes.Add(new LinearMove(0, 20));
+ program.Codes.Add(new LinearMove(0, 0));
+ var drawing = new Drawing("rectangle", program);
+ var plate = new Plate(81, 120) { Quadrant = quadrant, PartSpacing = 0.5 };
+ plate.Parts.Add(new Part(drawing,
+ new Vector(quadrant is 2 or 3 ? -80 : 10, quadrant is 3 or 4 ? -40 : 20)));
+ var settings = new CutOffSettings { CutDirection = direction, Overtravel = 2 };
+ var plan = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings);
+ Assert.False(plan.HasBlockingDiagnostics);
+ Assert.Equal(3, plan.Definitions.Count);
+ foreach (var definition in plan.Definitions)
+ plate.CutOffs.Add(definition);
+ plate.RegenerateCutOffs(settings);
+
+ // Two interrupted skeleton cuts followed by an uninterrupted tail separator.
+ var cutoffs = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToArray();
+ Assert.Equal(new[] { 2, 2, 1 }, cutoffs.Select(p => p.Program.Codes.OfType().Count()));
+ var before = plate.Parts.Select(p => NestWriter.GetProgramText(p.Program)).ToArray();
+ var expected = new List();
+ var position = Vector.Zero;
+ foreach (var part in plate.Parts)
+ {
+ Assert.Equal(Mode.Absolute, part.Program.Mode);
+ foreach (var motion in part.Program.Codes.Cast())
+ {
+ var destination = part.Location + motion.EndPoint;
+ if (motion is RapidMove)
+ expected.Add(new RapidEnumerator.Segment(position, destination));
+ position = destination;
+ }
+ }
+
+ var actual = RapidEnumerator.Enumerate(plate.Parts);
+
+ Assert.Equal(expected, actual);
+ Assert.Equal(before, plate.Parts.Select(p => NestWriter.GetProgramText(p.Program)));
+ plate.RegenerateCutOffs(settings);
+ Assert.Equal(expected, RapidEnumerator.Enumerate(plate.Parts));
+ }
+
+ [Theory]
+ [InlineData(Mode.Absolute, false)]
+ [InlineData(Mode.Incremental, false)]
+ [InlineData(Mode.Absolute, true)]
+ [InlineData(Mode.Incremental, true)]
+ public void Enumerate_OpenProgram_AdvancesPastLastPierce(Mode mode, bool endWithArc)
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(2, 3));
+ program.Codes.Add(new LinearMove(7, 3));
+ if (endWithArc)
+ program.Codes.Add(new ArcMove(8, 4, 7, 4));
+ program.Mode = mode;
+ var first = new Part(new Drawing("open path", program), new Vector(100, 200));
+ var next = NextPart();
+ var before = NestWriter.GetProgramText(first.Program);
+
+ var segments = RapidEnumerator.Enumerate(new[] { first, next });
+
+ Assert.Equal(2, segments.Count);
+ Assert.Equal(new Vector(102, 203), segments[0].To);
+ Assert.Equal(endWithArc ? new Vector(108, 204) : new Vector(107, 203), segments[1].From);
+ Assert.Equal(new Vector(12, 23), segments[1].To);
+ Assert.Equal(before, NestWriter.GetProgramText(first.Program));
+ }
+
+ [Fact]
+ public void Enumerate_FinalSubprogram_UsesItsCutEndpointForNextPart()
+ {
+ var hole = new Program(Mode.Incremental);
+ hole.Codes.Add(new RapidMove(0.5, 0));
+ hole.Codes.Add(new LinearMove(0, 0.1));
+ var program = new Program();
+ program.Codes.Add(new RapidMove(1, 0));
+ program.Codes.Add(new LinearMove(2, 0));
+ program.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(2, 2) });
+ var first = new Part(new Drawing("hole last", program), new Vector(100, 200));
+
+ var segments = RapidEnumerator.Enumerate(new[] { first, NextPart() });
+
+ Assert.Equal(3, segments.Count);
+ Assert.Equal(new Vector(102, 200), segments[1].From);
+ Assert.Equal(new Vector(102.5, 202), segments[1].To);
+ Assert.Equal(new Vector(102.5, 202.1), segments[2].From);
+ Assert.Equal(new Vector(12, 23), segments[2].To);
+ }
+
+ [Fact]
+ public void Enumerate_TrailingRapid_RemainsTheNextPartsStartPosition()
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(1, 0));
+ program.Codes.Add(new LinearMove(2, 0));
+ program.Codes.Add(new RapidMove(3, 4));
+ var first = new Part(new Drawing("park after cut", program), new Vector(100, 200));
+
+ var segments = RapidEnumerator.Enumerate(new[] { first, NextPart() });
+
+ Assert.Equal(3, segments.Count);
+ Assert.Equal(new Vector(102, 200), segments[1].From);
+ Assert.Equal(new Vector(103, 204), segments[1].To);
+ Assert.Equal(new Vector(103, 204), segments[2].From);
+ }
+
+ [Fact]
+ public void Enumerate_EmptyPlate_HasNoRapids()
+ {
+ Assert.Empty(RapidEnumerator.Enumerate(Array.Empty()));
+ }
+
+ private static Part NextPart()
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(2, 3));
+ program.Codes.Add(new LinearMove(4, 3));
+ return new Part(new Drawing("next", program), new Vector(10, 20));
+ }
+}
diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs
index 94cefba..057769a 100644
--- a/OpenNest/Controls/PlateRenderer.cs
+++ b/OpenNest/Controls/PlateRenderer.cs
@@ -426,19 +426,8 @@ namespace OpenNest.Controls
private void DrawRapids(Graphics g)
{
var pen = view.ColorScheme.RapidPen;
- var pos = new Vector(0, 0);
-
- for (var i = 0; i < view.Plate.Parts.Count; ++i)
- {
- var part = view.Plate.Parts[i];
- var segments = RapidEnumerator.Enumerate(part.Program, part.Location, pos);
-
- foreach (var seg in segments)
- {
- DrawLine(g, seg.From, seg.To, pen);
- pos = seg.To;
- }
- }
+ foreach (var segment in RapidEnumerator.Enumerate(view.Plate.Parts))
+ DrawLine(g, segment.From, segment.To, pen);
}
private void DrawAllPiercePoints(Graphics g)
diff --git a/docs/automatic-scrap-cutoffs.md b/docs/automatic-scrap-cutoffs.md
index d8e93b6..4ff9722 100644
--- a/docs/automatic-scrap-cutoffs.md
+++ b/docs/automatic-scrap-cutoffs.md
@@ -22,6 +22,8 @@ For a 120-by-81-inch sheet whose parts extend through 80 inches, the nominal ske
## Limits and operator review
+**View > Draw Rapids** follows the complete cutting sequence: an incoming rapid ends at a cutoff's first pierce, gaps between its trimmed segments remain rapids, and the next part's rapid starts at the cutoff's final cutting endpoint—not its last pierce. This display does not add a return move or alter the cutoff program.
+
- Spacing is nominal. Clearance gaps and suppressed short segments can leave bridges between scrap regions. This command does **not** certify that every connected scrap piece is disconnected or fits a hopper.
- The active sheet's physical width is used, not a hard-coded 81 inches. Check the actual width against the hopper; a wider sheet is not automatically hopper-compatible.
- Internal-hole scrap is not processed. This command reuses the outside-skeleton behavior of manual cutoffs; it does not force cuts through parts or remove clearance to guarantee separation.