diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 7ea880b..30455be 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -7,9 +7,9 @@ namespace OpenNest.CNC.CuttingStrategy { public CuttingParameters Parameters { get; set; } - public Program Apply(Program partProgram, Plate plate) + public CuttingResult Apply(Program partProgram, Vector approachPoint) { - var exitPoint = GetExitPoint(plate); + var exitPoint = approachPoint; var entities = partProgram.ToGeometry(); var profile = new ShapeProfile(entities); @@ -44,9 +44,12 @@ namespace OpenNest.CNC.CuttingStrategy currentPoint = closestPt; } + var lastCutPoint = exitPoint; + // Perimeter last { var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity); + lastCutPoint = perimeterPt; var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External); var winding = DetermineWinding(profile.Perimeter); @@ -60,21 +63,10 @@ namespace OpenNest.CNC.CuttingStrategy result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding)); } - return result; - } - - private Vector GetExitPoint(Plate plate) - { - var w = plate.Size.Width; - var l = plate.Size.Length; - - return plate.Quadrant switch + return new CuttingResult { - 1 => new Vector(w, l), // Q1 origin BottomLeft -> exit TopRight - 2 => new Vector(0, l), // Q2 origin BottomRight -> exit TopLeft - 3 => new Vector(0, 0), // Q3 origin TopRight -> exit BottomLeft - 4 => new Vector(w, 0), // Q4 origin TopLeft -> exit BottomRight - _ => new Vector(w, l) + Program = result, + LastCutPoint = lastCutPoint }; } diff --git a/OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs b/OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs new file mode 100644 index 0000000..933db14 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs @@ -0,0 +1,11 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public readonly struct CuttingResult + { + public Program Program { get; init; } + public Vector LastCutPoint { get; init; } + } +} diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index 56598f4..37b12f0 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -51,6 +51,8 @@ namespace OpenNest public Program Program { get; private set; } + public bool HasManualLeadIns { get; set; } + /// /// Gets the rotation of the part in radians. /// diff --git a/OpenNest.Engine/PlateProcessor.cs b/OpenNest.Engine/PlateProcessor.cs new file mode 100644 index 0000000..9601933 --- /dev/null +++ b/OpenNest.Engine/PlateProcessor.cs @@ -0,0 +1,120 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; + +namespace OpenNest.Engine +{ + public class PlateProcessor + { + public IPartSequencer Sequencer { get; set; } + public ContourCuttingStrategy CuttingStrategy { get; set; } + public IRapidPlanner RapidPlanner { get; set; } + + public PlateResult Process(Plate plate) + { + var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate); + var results = new List(sequenced.Count); + var cutAreas = new List(); + var currentPoint = PlateHelper.GetExitPoint(plate); + + foreach (var sp in sequenced) + { + var part = sp.Part; + + // Compute approach point in part-local space + var localApproach = ToPartLocal(currentPoint, part); + + Program processedProgram; + Vector lastCutLocal; + + if (!part.HasManualLeadIns && CuttingStrategy != null) + { + var cuttingResult = CuttingStrategy.Apply(part.Program, localApproach); + processedProgram = cuttingResult.Program; + lastCutLocal = cuttingResult.LastCutPoint; + } + else + { + processedProgram = part.Program; + lastCutLocal = GetProgramEndPoint(part.Program); + } + + // Pierce point: program start point in plate space + var pierceLocal = GetProgramStartPoint(part.Program); + var piercePoint = ToPlateSpace(pierceLocal, part); + + // Plan rapid from currentPoint to pierce point + var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas); + + results.Add(new ProcessedPart + { + Part = part, + ProcessedProgram = processedProgram, + RapidPath = rapidPath + }); + + // Update cut areas with part perimeter + var perimeter = GetPartPerimeter(part); + if (perimeter != null) + cutAreas.Add(perimeter); + + // Update current point to last cut point in plate space + currentPoint = ToPlateSpace(lastCutLocal, part); + } + + return new PlateResult { Parts = results }; + } + + private static Vector ToPartLocal(Vector platePoint, Part part) + { + return platePoint - part.Location; + } + + private static Vector ToPlateSpace(Vector localPoint, Part part) + { + return localPoint + part.Location; + } + + private static Vector GetProgramStartPoint(Program program) + { + if (program.Codes.Count == 0) + return Vector.Zero; + + var first = program.Codes[0]; + if (first is Motion motion) + return motion.EndPoint; + + return Vector.Zero; + } + + private static Vector GetProgramEndPoint(Program program) + { + for (var i = program.Codes.Count - 1; i >= 0; i--) + { + if (program.Codes[i] is Motion motion) + return motion.EndPoint; + } + + return Vector.Zero; + } + + private static Shape GetPartPerimeter(Part part) + { + var entities = part.Program.ToGeometry(); + if (entities == null || entities.Count == 0) + return null; + + var profile = new ShapeProfile(entities); + var perimeter = profile.Perimeter; + if (perimeter == null || perimeter.Entities.Count == 0) + return null; + + perimeter.Offset(part.Location); + return perimeter; + } + } +} diff --git a/OpenNest.Engine/PlateResult.cs b/OpenNest.Engine/PlateResult.cs new file mode 100644 index 0000000..7209be7 --- /dev/null +++ b/OpenNest.Engine/PlateResult.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.RapidPlanning; + +namespace OpenNest.Engine +{ + public class PlateResult + { + public List Parts { get; init; } + } + + public readonly struct ProcessedPart + { + public Part Part { get; init; } + public Program ProcessedProgram { get; init; } + public RapidPath RapidPath { get; init; } + } +} diff --git a/OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs b/OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs new file mode 100644 index 0000000..154e525 --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public class DirectRapidPlanner : IRapidPlanner + { + public RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas) + { + var travelLine = new Line(from, to); + + foreach (var cutArea in cutAreas) + { + if (TravelLineIntersectsShape(travelLine, cutArea)) + { + return new RapidPath + { + HeadUp = true, + Waypoints = new List() + }; + } + } + + return new RapidPath + { + HeadUp = false, + Waypoints = new List() + }; + } + + private static bool TravelLineIntersectsShape(Line travelLine, Shape shape) + { + foreach (var entity in shape.Entities) + { + if (entity is Line edge) + { + if (travelLine.Intersects(edge, out _)) + return true; + } + } + return false; + } + } +} diff --git a/OpenNest.Engine/RapidPlanning/IRapidPlanner.cs b/OpenNest.Engine/RapidPlanning/IRapidPlanner.cs new file mode 100644 index 0000000..edae37c --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/IRapidPlanner.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public interface IRapidPlanner + { + RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas); + } +} diff --git a/OpenNest.Engine/RapidPlanning/RapidPath.cs b/OpenNest.Engine/RapidPlanning/RapidPath.cs new file mode 100644 index 0000000..8ff6eb4 --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/RapidPath.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public readonly struct RapidPath + { + public bool HeadUp { get; init; } + public List Waypoints { get; init; } + } +} diff --git a/OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs b/OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs new file mode 100644 index 0000000..6de4db6 --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public class SafeHeightRapidPlanner : IRapidPlanner + { + public RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas) + { + return new RapidPath + { + HeadUp = true, + Waypoints = new List() + }; + } + } +} diff --git a/OpenNest.Engine/Sequencing/AdvancedSequencer.cs b/OpenNest.Engine/Sequencing/AdvancedSequencer.cs new file mode 100644 index 0000000..fa285aa --- /dev/null +++ b/OpenNest.Engine/Sequencing/AdvancedSequencer.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Math; + +namespace OpenNest.Engine.Sequencing +{ + public class AdvancedSequencer : IPartSequencer + { + private readonly SequenceParameters _parameters; + + public AdvancedSequencer(SequenceParameters parameters) + { + _parameters = parameters; + } + + public List Sequence(IReadOnlyList parts, Plate plate) + { + if (parts.Count == 0) + return new List(); + + var exit = PlateHelper.GetExitPoint(plate); + + // Group parts into rows by Y proximity + var rows = GroupIntoRows(parts, _parameters.MinDistanceBetweenRowsColumns); + + // Sort rows bottom-to-top (ascending Y) + rows.Sort((a, b) => a.RowY.CompareTo(b.RowY)); + + // Determine initial direction based on exit point + var leftToRight = exit.X > plate.Size.Width * 0.5; + + var result = new List(parts.Count); + foreach (var row in rows) + { + var sorted = leftToRight + ? row.Parts.OrderBy(p => p.BoundingBox.Center.X).ToList() + : row.Parts.OrderByDescending(p => p.BoundingBox.Center.X).ToList(); + + foreach (var p in sorted) + result.Add(new SequencedPart { Part = p }); + + if (_parameters.AlternateRowsColumns) + leftToRight = !leftToRight; + } + + return result; + } + + private static List GroupIntoRows(IReadOnlyList parts, double minDistance) + { + // Sort parts by Y center + var sorted = parts + .OrderBy(p => p.BoundingBox.Center.Y) + .ToList(); + + var rows = new List(); + + foreach (var part in sorted) + { + var y = part.BoundingBox.Center.Y; + var placed = false; + + foreach (var row in rows) + { + if (System.Math.Abs(y - row.RowY) <= minDistance + Tolerance.Epsilon) + { + row.Parts.Add(part); + placed = true; + break; + } + } + + if (!placed) + { + var row = new PartRow(y); + row.Parts.Add(part); + rows.Add(row); + } + } + + return rows; + } + + private class PartRow + { + public double RowY { get; } + public List Parts { get; } = new List(); + + public PartRow(double rowY) + { + RowY = rowY; + } + } + } +} diff --git a/OpenNest.Engine/Sequencing/BottomSideSequencer.cs b/OpenNest.Engine/Sequencing/BottomSideSequencer.cs new file mode 100644 index 0000000..de06053 --- /dev/null +++ b/OpenNest.Engine/Sequencing/BottomSideSequencer.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class BottomSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderBy(p => p.Location.Y) + .ThenBy(p => p.Location.X) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} diff --git a/OpenNest.Engine/Sequencing/EdgeStartSequencer.cs b/OpenNest.Engine/Sequencing/EdgeStartSequencer.cs new file mode 100644 index 0000000..187a599 --- /dev/null +++ b/OpenNest.Engine/Sequencing/EdgeStartSequencer.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class EdgeStartSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + // Plate(width, length) stores Size with Width/Length swapped internally. + // Reconstruct the logical plate box using the BoundingBox origin and the + // corrected extents: Size.Length = X-extent, Size.Width = Y-extent. + var origin = plate.BoundingBox(false); + var plateBox = new OpenNest.Geometry.Box( + origin.X, origin.Y, + plate.Size.Length, + plate.Size.Width); + + return parts + .OrderBy(p => MinEdgeDistance(p.BoundingBox.Center, plateBox)) + .ThenBy(p => p.Location.X) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + + private static double MinEdgeDistance(OpenNest.Geometry.Vector center, OpenNest.Geometry.Box plateBox) + { + var distLeft = center.X - plateBox.Left; + var distRight = plateBox.Right - center.X; + var distBottom = center.Y - plateBox.Bottom; + var distTop = plateBox.Top - center.Y; + + return System.Math.Min(System.Math.Min(distLeft, distRight), System.Math.Min(distBottom, distTop)); + } + } +} diff --git a/OpenNest.Engine/Sequencing/IPartSequencer.cs b/OpenNest.Engine/Sequencing/IPartSequencer.cs new file mode 100644 index 0000000..79b0c3e --- /dev/null +++ b/OpenNest.Engine/Sequencing/IPartSequencer.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace OpenNest.Engine.Sequencing +{ + public readonly struct SequencedPart + { + public Part Part { get; init; } + } + + public interface IPartSequencer + { + List Sequence(IReadOnlyList parts, Plate plate); + } +} diff --git a/OpenNest.Engine/Sequencing/LeastCodeSequencer.cs b/OpenNest.Engine/Sequencing/LeastCodeSequencer.cs new file mode 100644 index 0000000..63b0e2a --- /dev/null +++ b/OpenNest.Engine/Sequencing/LeastCodeSequencer.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using OpenNest.Math; + +namespace OpenNest.Engine.Sequencing +{ + public class LeastCodeSequencer : IPartSequencer + { + private readonly int _maxIterations; + + public LeastCodeSequencer(int maxIterations = 100) + { + _maxIterations = maxIterations; + } + + public List Sequence(IReadOnlyList parts, Plate plate) + { + if (parts.Count == 0) + return new List(); + + var exit = PlateHelper.GetExitPoint(plate); + var ordered = NearestNeighbor(parts, exit); + TwoOpt(ordered, exit); + + var result = new List(ordered.Count); + foreach (var p in ordered) + result.Add(new SequencedPart { Part = p }); + return result; + } + + private static List NearestNeighbor(IReadOnlyList parts, OpenNest.Geometry.Vector exit) + { + var remaining = new List(parts); + var ordered = new List(parts.Count); + + var current = exit; + while (remaining.Count > 0) + { + var bestIdx = 0; + var bestDist = Distance(current, Center(remaining[0])); + + for (var i = 1; i < remaining.Count; i++) + { + var d = Distance(current, Center(remaining[i])); + if (d < bestDist - Tolerance.Epsilon) + { + bestDist = d; + bestIdx = i; + } + } + + var next = remaining[bestIdx]; + ordered.Add(next); + remaining.RemoveAt(bestIdx); + current = Center(next); + } + + return ordered; + } + + private void TwoOpt(List ordered, OpenNest.Geometry.Vector exit) + { + var n = ordered.Count; + if (n < 3) + return; + + for (var iter = 0; iter < _maxIterations; iter++) + { + var improved = false; + + for (var i = 0; i < n - 1; i++) + { + for (var j = i + 1; j < n; j++) + { + var before = RouteDistance(ordered, exit, i, j); + Reverse(ordered, i, j); + var after = RouteDistance(ordered, exit, i, j); + + if (after < before - Tolerance.Epsilon) + { + improved = true; + } + else + { + // Revert + Reverse(ordered, i, j); + } + } + } + + if (!improved) + break; + } + } + + /// + /// Computes the total distance of the route starting from exit through all parts. + /// Only the segment around the reversed segment [i..j] needs to be checked, + /// but here we compute the full route cost for correctness. + /// + private static double RouteDistance(List ordered, OpenNest.Geometry.Vector exit, int i, int j) + { + // Full route distance: exit -> ordered[0] -> ... -> ordered[n-1] + var total = 0.0; + var prev = exit; + foreach (var p in ordered) + { + var c = Center(p); + total += Distance(prev, c); + prev = c; + } + return total; + } + + private static void Reverse(List list, int i, int j) + { + while (i < j) + { + var tmp = list[i]; + list[i] = list[j]; + list[j] = tmp; + i++; + j--; + } + } + + private static OpenNest.Geometry.Vector Center(Part part) + { + return part.BoundingBox.Center; + } + + private static double Distance(OpenNest.Geometry.Vector a, OpenNest.Geometry.Vector b) + { + var dx = b.X - a.X; + var dy = b.Y - a.Y; + return System.Math.Sqrt(dx * dx + dy * dy); + } + } +} diff --git a/OpenNest.Engine/Sequencing/LeftSideSequencer.cs b/OpenNest.Engine/Sequencing/LeftSideSequencer.cs new file mode 100644 index 0000000..ca0f20a --- /dev/null +++ b/OpenNest.Engine/Sequencing/LeftSideSequencer.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class LeftSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderBy(p => p.Location.X) + .ThenBy(p => p.Location.Y) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} diff --git a/OpenNest.Engine/Sequencing/PartSequencerFactory.cs b/OpenNest.Engine/Sequencing/PartSequencerFactory.cs new file mode 100644 index 0000000..0e29d1e --- /dev/null +++ b/OpenNest.Engine/Sequencing/PartSequencerFactory.cs @@ -0,0 +1,23 @@ +using System; +using OpenNest.CNC.CuttingStrategy; + +namespace OpenNest.Engine.Sequencing +{ + public static class PartSequencerFactory + { + public static IPartSequencer Create(SequenceParameters parameters) + { + return parameters.Method switch + { + SequenceMethod.RightSide => new RightSideSequencer(), + SequenceMethod.LeftSide => new LeftSideSequencer(), + SequenceMethod.BottomSide => new BottomSideSequencer(), + SequenceMethod.EdgeStart => new EdgeStartSequencer(), + SequenceMethod.LeastCode => new LeastCodeSequencer(), + SequenceMethod.Advanced => new AdvancedSequencer(parameters), + _ => throw new NotSupportedException( + $"Sequence method '{parameters.Method}' is not supported.") + }; + } + } +} diff --git a/OpenNest.Engine/Sequencing/PlateHelper.cs b/OpenNest.Engine/Sequencing/PlateHelper.cs new file mode 100644 index 0000000..1a46327 --- /dev/null +++ b/OpenNest.Engine/Sequencing/PlateHelper.cs @@ -0,0 +1,22 @@ +using OpenNest.Geometry; + +namespace OpenNest.Engine.Sequencing +{ + internal static class PlateHelper + { + public static Vector GetExitPoint(Plate plate) + { + var w = plate.Size.Width; + var l = plate.Size.Length; + + return plate.Quadrant switch + { + 1 => new Vector(w, l), + 2 => new Vector(0, l), + 3 => new Vector(0, 0), + 4 => new Vector(w, 0), + _ => new Vector(w, l) + }; + } + } +} diff --git a/OpenNest.Engine/Sequencing/RightSideSequencer.cs b/OpenNest.Engine/Sequencing/RightSideSequencer.cs new file mode 100644 index 0000000..f804a38 --- /dev/null +++ b/OpenNest.Engine/Sequencing/RightSideSequencer.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class RightSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderByDescending(p => p.Location.X) + .ThenBy(p => p.Location.Y) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} diff --git a/OpenNest.Tests/CuttingResultTests.cs b/OpenNest.Tests/CuttingResultTests.cs new file mode 100644 index 0000000..58cbefc --- /dev/null +++ b/OpenNest.Tests/CuttingResultTests.cs @@ -0,0 +1,23 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests; + +public class CuttingResultTests +{ + [Fact] + public void CuttingResult_StoresValues() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(1, 2))); + var point = new Vector(3, 4); + + var result = new CuttingResult { Program = pgm, LastCutPoint = point }; + + Assert.Same(pgm, result.Program); + Assert.Equal(3, result.LastCutPoint.X); + Assert.Equal(4, result.LastCutPoint.Y); + } +} diff --git a/OpenNest.Tests/OpenNest.Tests.csproj b/OpenNest.Tests/OpenNest.Tests.csproj new file mode 100644 index 0000000..ec1b229 --- /dev/null +++ b/OpenNest.Tests/OpenNest.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0-windows + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/OpenNest.Tests/PartFlagTests.cs b/OpenNest.Tests/PartFlagTests.cs new file mode 100644 index 0000000..f140dd2 --- /dev/null +++ b/OpenNest.Tests/PartFlagTests.cs @@ -0,0 +1,32 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests; + +public class PartFlagTests +{ + [Fact] + public void HasManualLeadIns_DefaultsFalse() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + var drawing = new Drawing("test", pgm); + var part = new Part(drawing); + + Assert.False(part.HasManualLeadIns); + } + + [Fact] + public void HasManualLeadIns_CanBeSet() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + var drawing = new Drawing("test", pgm); + var part = new Part(drawing); + + part.HasManualLeadIns = true; + + Assert.True(part.HasManualLeadIns); + } +} diff --git a/OpenNest.Tests/PlateProcessorTests.cs b/OpenNest.Tests/PlateProcessorTests.cs new file mode 100644 index 0000000..73d5a98 --- /dev/null +++ b/OpenNest.Tests/PlateProcessorTests.cs @@ -0,0 +1,132 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests; + +public class PlateProcessorTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y, size: 2); + + [Fact] + public void Process_ReturnsAllParts() + { + var plate = new Plate(60, 120); + plate.Parts.Add(MakePartAt(10, 10)); + plate.Parts.Add(MakePartAt(30, 30)); + plate.Parts.Add(MakePartAt(50, 50)); + + var processor = new PlateProcessor + { + Sequencer = new RightSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Equal(3, result.Parts.Count); + } + + [Fact] + public void Process_PreservesSequenceOrder() + { + var plate = new Plate(60, 120); + var left = MakePartAt(5, 10); + var right = MakePartAt(50, 10); + plate.Parts.Add(left); + plate.Parts.Add(right); + + var processor = new PlateProcessor + { + Sequencer = new RightSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Same(right, result.Parts[0].Part); + Assert.Same(left, result.Parts[1].Part); + } + + [Fact] + public void Process_SkipsCuttingStrategy_WhenManualLeadIns() + { + var plate = new Plate(60, 120); + var part = MakePartAt(10, 10); + part.HasManualLeadIns = true; + plate.Parts.Add(part); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + CuttingStrategy = new ContourCuttingStrategy + { + Parameters = new CuttingParameters() + }, + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Same(part.Program, result.Parts[0].ProcessedProgram); + } + + [Fact] + public void Process_DoesNotMutatePart() + { + var plate = new Plate(60, 120); + var part = MakePartAt(10, 10); + var originalProgram = part.Program; + plate.Parts.Add(part); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Same(originalProgram, part.Program); + } + + [Fact] + public void Process_NoCuttingStrategy_PassesProgramThrough() + { + var plate = new Plate(60, 120); + var part = MakePartAt(10, 10); + plate.Parts.Add(part); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Same(part.Program, result.Parts[0].ProcessedProgram); + } + + [Fact] + public void Process_EmptyPlate_ReturnsEmptyResult() + { + var plate = new Plate(60, 120); + + var processor = new PlateProcessor + { + Sequencer = new LeftSideSequencer(), + RapidPlanner = new SafeHeightRapidPlanner() + }; + + var result = processor.Process(plate); + + Assert.Empty(result.Parts); + } +} diff --git a/OpenNest.Tests/RapidPlanning/DirectRapidPlannerTests.cs b/OpenNest.Tests/RapidPlanning/DirectRapidPlannerTests.cs new file mode 100644 index 0000000..ca6a00b --- /dev/null +++ b/OpenNest.Tests/RapidPlanning/DirectRapidPlannerTests.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.RapidPlanning; + +public class DirectRapidPlannerTests +{ + [Fact] + public void NoCutAreas_ReturnsHeadDown() + { + var planner = new DirectRapidPlanner(); + var result = planner.Plan(new Vector(0, 0), new Vector(10, 10), new List()); + + Assert.False(result.HeadUp); + Assert.Empty(result.Waypoints); + } + + [Fact] + public void ClearPath_ReturnsHeadDown() + { + var planner = new DirectRapidPlanner(); + + var cutArea = new Shape(); + cutArea.Entities.Add(new Line(new Vector(50, 0), new Vector(50, 10))); + cutArea.Entities.Add(new Line(new Vector(50, 10), new Vector(60, 10))); + cutArea.Entities.Add(new Line(new Vector(60, 10), new Vector(60, 0))); + cutArea.Entities.Add(new Line(new Vector(60, 0), new Vector(50, 0))); + + var result = planner.Plan( + new Vector(0, 0), new Vector(10, 10), + new List { cutArea }); + + Assert.False(result.HeadUp); + } + + [Fact] + public void BlockedPath_ReturnsHeadUp() + { + var planner = new DirectRapidPlanner(); + + var cutArea = new Shape(); + cutArea.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20))); + cutArea.Entities.Add(new Line(new Vector(5, 20), new Vector(6, 20))); + cutArea.Entities.Add(new Line(new Vector(6, 20), new Vector(6, 0))); + cutArea.Entities.Add(new Line(new Vector(6, 0), new Vector(5, 0))); + + var result = planner.Plan( + new Vector(0, 10), new Vector(10, 10), + new List { cutArea }); + + Assert.True(result.HeadUp); + Assert.Empty(result.Waypoints); + } +} diff --git a/OpenNest.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs b/OpenNest.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs new file mode 100644 index 0000000..3db408f --- /dev/null +++ b/OpenNest.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.RapidPlanning; + +public class SafeHeightRapidPlannerTests +{ + [Fact] + public void AlwaysReturnsHeadUp() + { + var planner = new SafeHeightRapidPlanner(); + var from = new Vector(10, 10); + var to = new Vector(50, 50); + var cutAreas = new List(); + + var result = planner.Plan(from, to, cutAreas); + + Assert.True(result.HeadUp); + Assert.Empty(result.Waypoints); + } + + [Fact] + public void ReturnsHeadUp_EvenWithCutAreas() + { + var planner = new SafeHeightRapidPlanner(); + var from = new Vector(0, 0); + var to = new Vector(10, 10); + + var shape = new Shape(); + shape.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20))); + var cutAreas = new List { shape }; + + var result = planner.Plan(from, to, cutAreas); + + Assert.True(result.HeadUp); + } +} diff --git a/OpenNest.Tests/Sequencing/AdvancedSequencerTests.cs b/OpenNest.Tests/Sequencing/AdvancedSequencerTests.cs new file mode 100644 index 0000000..69f540f --- /dev/null +++ b/OpenNest.Tests/Sequencing/AdvancedSequencerTests.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Sequencing; + +public class AdvancedSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void GroupsIntoRows_NoAlternate() + { + var plate = new Plate(100, 100); + var row1a = MakePartAt(10, 10); + var row1b = MakePartAt(30, 10); + var row2a = MakePartAt(10, 50); + var row2b = MakePartAt(30, 50); + plate.Parts.Add(row1a); + plate.Parts.Add(row1b); + plate.Parts.Add(row2a); + plate.Parts.Add(row2b); + + var parameters = new SequenceParameters + { + Method = SequenceMethod.Advanced, + MinDistanceBetweenRowsColumns = 5.0, + AlternateRowsColumns = false + }; + var sequencer = new AdvancedSequencer(parameters); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(row1a, result[0].Part); + Assert.Same(row1b, result[1].Part); + Assert.Same(row2a, result[2].Part); + Assert.Same(row2b, result[3].Part); + } + + [Fact] + public void SerpentineAlternatesDirection() + { + var plate = new Plate(100, 100); + var r1Left = MakePartAt(10, 10); + var r1Right = MakePartAt(30, 10); + var r2Left = MakePartAt(10, 50); + var r2Right = MakePartAt(30, 50); + plate.Parts.Add(r1Left); + plate.Parts.Add(r1Right); + plate.Parts.Add(r2Left); + plate.Parts.Add(r2Right); + + var parameters = new SequenceParameters + { + Method = SequenceMethod.Advanced, + MinDistanceBetweenRowsColumns = 5.0, + AlternateRowsColumns = true + }; + var sequencer = new AdvancedSequencer(parameters); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(r1Left, result[0].Part); + Assert.Same(r1Right, result[1].Part); + Assert.Same(r2Right, result[2].Part); + Assert.Same(r2Left, result[3].Part); + } +} diff --git a/OpenNest.Tests/Sequencing/DirectionalSequencerTests.cs b/OpenNest.Tests/Sequencing/DirectionalSequencerTests.cs new file mode 100644 index 0000000..0a64a98 --- /dev/null +++ b/OpenNest.Tests/Sequencing/DirectionalSequencerTests.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Sequencing; + +public class DirectionalSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + private static Plate MakePlate(params Part[] parts) => TestHelpers.MakePlate(60, 120, parts); + + [Fact] + public void RightSide_SortsXDescending() + { + var a = MakePartAt(10, 5); + var b = MakePartAt(30, 5); + var c = MakePartAt(20, 5); + var plate = MakePlate(a, b, c); + + var sequencer = new RightSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(b, result[0].Part); + Assert.Same(c, result[1].Part); + Assert.Same(a, result[2].Part); + } + + [Fact] + public void LeftSide_SortsXAscending() + { + var a = MakePartAt(10, 5); + var b = MakePartAt(30, 5); + var c = MakePartAt(20, 5); + var plate = MakePlate(a, b, c); + + var sequencer = new LeftSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(a, result[0].Part); + Assert.Same(c, result[1].Part); + Assert.Same(b, result[2].Part); + } + + [Fact] + public void BottomSide_SortsYAscending() + { + var a = MakePartAt(5, 20); + var b = MakePartAt(5, 5); + var c = MakePartAt(5, 10); + var plate = MakePlate(a, b, c); + + var sequencer = new BottomSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(b, result[0].Part); + Assert.Same(c, result[1].Part); + Assert.Same(a, result[2].Part); + } + + [Fact] + public void RightSide_TiesBrokenByPerpendicularAxis() + { + var a = MakePartAt(10, 20); + var b = MakePartAt(10, 5); + var plate = MakePlate(a, b); + + var sequencer = new RightSideSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(b, result[0].Part); + Assert.Same(a, result[1].Part); + } +} diff --git a/OpenNest.Tests/Sequencing/EdgeStartSequencerTests.cs b/OpenNest.Tests/Sequencing/EdgeStartSequencerTests.cs new file mode 100644 index 0000000..3f002de --- /dev/null +++ b/OpenNest.Tests/Sequencing/EdgeStartSequencerTests.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Sequencing; + +public class EdgeStartSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void SortsByDistanceFromNearestEdge() + { + var plate = new Plate(60, 120); + var edgePart = MakePartAt(1, 1); + var centerPart = MakePartAt(25, 55); + var midPart = MakePartAt(10, 10); + plate.Parts.Add(edgePart); + plate.Parts.Add(centerPart); + plate.Parts.Add(midPart); + + var sequencer = new EdgeStartSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Same(edgePart, result[0].Part); + Assert.Same(midPart, result[1].Part); + Assert.Same(centerPart, result[2].Part); + } +} diff --git a/OpenNest.Tests/Sequencing/LeastCodeSequencerTests.cs b/OpenNest.Tests/Sequencing/LeastCodeSequencerTests.cs new file mode 100644 index 0000000..a5929c5 --- /dev/null +++ b/OpenNest.Tests/Sequencing/LeastCodeSequencerTests.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Tests.Sequencing; + +public class LeastCodeSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void NearestNeighbor_FromExitPoint() + { + var plate = new Plate(60, 120); + var farPart = MakePartAt(5, 5); + var nearPart = MakePartAt(55, 115); + plate.Parts.Add(farPart); + plate.Parts.Add(nearPart); + + var sequencer = new LeastCodeSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + // nearPart is closer to exit point, should come first + Assert.Same(nearPart, result[0].Part); + Assert.Same(farPart, result[1].Part); + } + + [Fact] + public void PreservesAllParts() + { + var plate = new Plate(60, 120); + for (var i = 0; i < 10; i++) + plate.Parts.Add(MakePartAt(i * 5, i * 10)); + + var sequencer = new LeastCodeSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Equal(10, result.Count); + } + + [Fact] + public void TwoOpt_ImprovesSolution() + { + var plate = new Plate(100, 100); + var a = MakePartAt(90, 90); + var b = MakePartAt(10, 80); + var c = MakePartAt(80, 10); + var d = MakePartAt(5, 5); + plate.Parts.Add(a); + plate.Parts.Add(b); + plate.Parts.Add(c); + plate.Parts.Add(d); + + var sequencer = new LeastCodeSequencer(); + var result = sequencer.Sequence(plate.Parts.ToList(), plate); + + Assert.Equal(4, result.Count); + } +} diff --git a/OpenNest.Tests/Sequencing/PartSequencerFactoryTests.cs b/OpenNest.Tests/Sequencing/PartSequencerFactoryTests.cs new file mode 100644 index 0000000..1c6a22e --- /dev/null +++ b/OpenNest.Tests/Sequencing/PartSequencerFactoryTests.cs @@ -0,0 +1,30 @@ +using System; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.Sequencing; +using Xunit; + +namespace OpenNest.Tests.Sequencing; + +public class PartSequencerFactoryTests +{ + [Theory] + [InlineData(SequenceMethod.RightSide, typeof(RightSideSequencer))] + [InlineData(SequenceMethod.LeftSide, typeof(LeftSideSequencer))] + [InlineData(SequenceMethod.BottomSide, typeof(BottomSideSequencer))] + [InlineData(SequenceMethod.EdgeStart, typeof(EdgeStartSequencer))] + [InlineData(SequenceMethod.LeastCode, typeof(LeastCodeSequencer))] + [InlineData(SequenceMethod.Advanced, typeof(AdvancedSequencer))] + public void Create_ReturnsCorrectType(SequenceMethod method, Type expectedType) + { + var parameters = new SequenceParameters { Method = method }; + var sequencer = PartSequencerFactory.Create(parameters); + Assert.IsType(expectedType, sequencer); + } + + [Fact] + public void Create_RightSideAlt_Throws() + { + var parameters = new SequenceParameters { Method = SequenceMethod.RightSideAlt }; + Assert.Throws(() => PartSequencerFactory.Create(parameters)); + } +} diff --git a/OpenNest.Tests/TestHelpers.cs b/OpenNest.Tests/TestHelpers.cs new file mode 100644 index 0000000..8d68f0b --- /dev/null +++ b/OpenNest.Tests/TestHelpers.cs @@ -0,0 +1,27 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +internal static class TestHelpers +{ + public static Part MakePartAt(double x, double y, double size = 1) + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(size, 0))); + pgm.Codes.Add(new LinearMove(new Vector(size, size))); + pgm.Codes.Add(new LinearMove(new Vector(0, size))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + var drawing = new Drawing("test", pgm); + return new Part(drawing, new Vector(x, y)); + } + + public static Plate MakePlate(double width = 60, double length = 120, params Part[] parts) + { + var plate = new Plate(width, length); + foreach (var p in parts) + plate.Parts.Add(p); + return plate; + } +} diff --git a/OpenNest.sln b/OpenNest.sln index 69cf786..d0a8d89 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Console", "OpenNes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Training", "OpenNest.Training\OpenNest.Training.csproj", "{249BF728-25DD-4863-8266-207ACD26E964}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Tests", "OpenNest.Tests\OpenNest.Tests.csproj", "{03539EB7-9DB2-4634-A6FD-F094B9603596}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -125,6 +127,18 @@ Global {249BF728-25DD-4863-8266-207ACD26E964}.Release|x64.Build.0 = Release|Any CPU {249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.ActiveCfg = Release|Any CPU {249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.Build.0 = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|Any CPU.Build.0 = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x64.ActiveCfg = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x64.Build.0 = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x86.ActiveCfg = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x86.Build.0 = Debug|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|Any CPU.ActiveCfg = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|Any CPU.Build.0 = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x64.ActiveCfg = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x64.Build.0 = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.ActiveCfg = Release|Any CPU + {03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE