From 4afab63046de0d902cf6ff80960822f04599b9a8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 23:19:08 -0400 Subject: [PATCH] fix(cnc): advance rapid display through cutoff cutting moves --- OpenNest.Core/CNC/RapidEnumerator.cs | 23 ++- .../CNC/PlateRapidEnumeratorTests.cs | 141 ++++++++++++++++++ OpenNest/Controls/PlateRenderer.cs | 15 +- docs/automatic-scrap-cutoffs.md | 2 + 4 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 OpenNest.Tests/CNC/PlateRapidEnumeratorTests.cs 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.