feat: add PlateProcessor for per-part lead-in assignment and cut sequencing

Three-stage pipeline: IPartSequencer → ContourCuttingStrategy → IRapidPlanner
wired by PlateProcessor. 6 sequencing strategies, 2 rapid planners, 31 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 00:47:16 -04:00
31 changed files with 1240 additions and 16 deletions

View File

@@ -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
};
}

View File

@@ -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; }
}
}

View File

@@ -51,6 +51,8 @@ namespace OpenNest
public Program Program { get; private set; }
public bool HasManualLeadIns { get; set; }
/// <summary>
/// Gets the rotation of the part in radians.
/// </summary>

View File

@@ -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<ProcessedPart>(sequenced.Count);
var cutAreas = new List<Shape>();
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;
}
}
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.RapidPlanning;
namespace OpenNest.Engine
{
public class PlateResult
{
public List<ProcessedPart> Parts { get; init; }
}
public readonly struct ProcessedPart
{
public Part Part { get; init; }
public Program ProcessedProgram { get; init; }
public RapidPath RapidPath { get; init; }
}
}

View File

@@ -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<Shape> cutAreas)
{
var travelLine = new Line(from, to);
foreach (var cutArea in cutAreas)
{
if (TravelLineIntersectsShape(travelLine, cutArea))
{
return new RapidPath
{
HeadUp = true,
Waypoints = new List<Vector>()
};
}
}
return new RapidPath
{
HeadUp = false,
Waypoints = new List<Vector>()
};
}
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;
}
}
}

View File

@@ -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<Shape> cutAreas);
}
}

View File

@@ -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<Vector> Waypoints { get; init; }
}
}

View File

@@ -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<Shape> cutAreas)
{
return new RapidPath
{
HeadUp = true,
Waypoints = new List<Vector>()
};
}
}
}

View File

@@ -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<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
if (parts.Count == 0)
return new List<SequencedPart>();
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<SequencedPart>(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<PartRow> GroupIntoRows(IReadOnlyList<Part> parts, double minDistance)
{
// Sort parts by Y center
var sorted = parts
.OrderBy(p => p.BoundingBox.Center.Y)
.ToList();
var rows = new List<PartRow>();
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<Part> Parts { get; } = new List<Part>();
public PartRow(double rowY)
{
RowY = rowY;
}
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class BottomSideSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
return parts
.OrderBy(p => p.Location.Y)
.ThenBy(p => p.Location.X)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
}
}

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class EdgeStartSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> 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));
}
}
}

View File

@@ -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<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate);
}
}

View File

@@ -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<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
if (parts.Count == 0)
return new List<SequencedPart>();
var exit = PlateHelper.GetExitPoint(plate);
var ordered = NearestNeighbor(parts, exit);
TwoOpt(ordered, exit);
var result = new List<SequencedPart>(ordered.Count);
foreach (var p in ordered)
result.Add(new SequencedPart { Part = p });
return result;
}
private static List<Part> NearestNeighbor(IReadOnlyList<Part> parts, OpenNest.Geometry.Vector exit)
{
var remaining = new List<Part>(parts);
var ordered = new List<Part>(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<Part> 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;
}
}
/// <summary>
/// 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.
/// </summary>
private static double RouteDistance(List<Part> 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<Part> 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);
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class LeftSideSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
return parts
.OrderBy(p => p.Location.X)
.ThenBy(p => p.Location.Y)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
}
}

View File

@@ -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.")
};
}
}
}

View File

@@ -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)
};
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class RightSideSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
return parts
.OrderByDescending(p => p.Location.X)
.ThenBy(p => p.Location.Y)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<Shape>());
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<Shape> { 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<Shape> { cutArea });
Assert.True(result.HeadUp);
Assert.Empty(result.Waypoints);
}
}

View File

@@ -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<Shape>();
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> { shape };
var result = planner.Plan(from, to, cutAreas);
Assert.True(result.HeadUp);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<NotSupportedException>(() => PartSequencerFactory.Create(parameters));
}
}

View File

@@ -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;
}
}

View File

@@ -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