diff --git a/OpenNest.Engine.Tests/Jobs/NestJobSpacingValidationTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobSpacingValidationTests.cs
new file mode 100644
index 0000000..523a09e
--- /dev/null
+++ b/OpenNest.Engine.Tests/Jobs/NestJobSpacingValidationTests.cs
@@ -0,0 +1,185 @@
+using OpenNest.Converters;
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.Jobs.Adapters;
+using OpenNest.Engine.Jobs.Placement;
+using OpenNest.Geometry;
+using OpenNest.Shapes;
+
+namespace OpenNest.Engine.Tests.Jobs;
+
+///
+/// The candidate validator flattens each placed contour once and reuses it across pairs. These
+/// tests compare its accept/reject decision with a reference that rebuilds every contour and
+/// checks every edge pair, on arc-heavy parts placed right around the spacing.
+///
+public class NestJobSpacingValidationTests
+{
+ private const double Spacing = 0.25;
+ private const double Epsilon = 0.0000001;
+ private const double Origin = 50;
+
+ public static IEnumerable Shapes() =>
+ new[]
+ {
+ new object[] { "rounded", new RoundedRectangleShape { Length = 12, Width = 6, Radius = 1.5 }.GetDrawing().Program },
+ new object[] { "ring", new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing().Program },
+ };
+
+ [Theory]
+ [MemberData(nameof(Shapes))]
+ public void SpacingDecisionMatchesBruteForceNearTheLimit(string name, OpenNest.CNC.Program program)
+ {
+ var geometry = PartGeometrySnapshot.FromProgram(program);
+ var part = new NestJobPart("part", geometry, 2);
+ var job = new NestJob(
+ new[] { part },
+ new[] { new NestPlateStock("stock", new Size(200, 200), 1, Spacing) }
+ );
+
+ var bounds = program.BoundingBox();
+ var random = new Random(name.GetHashCode(StringComparison.Ordinal) & 0x7fff);
+ var accepted = 0;
+ var rejected = 0;
+
+ for (var sample = 0; sample < 60; sample++)
+ {
+ // Second part beside or above the first with a gap near the spacing, shifted
+ // sideways so corners and arcs meet at varied angles, and sometimes turned.
+ var gap = Spacing + (random.NextDouble() - 0.5) * 0.06;
+ var rotation = sample % 3 == 0 ? System.Math.PI : sample % 3 == 1 ? 0.0 : 0.05;
+ var shift = (random.NextDouble() - 0.5) * 0.5 * bounds.Width;
+ var beside = sample % 2 == 0;
+ var second = beside
+ ? new NestJobPlacement("part", 1, Origin + bounds.Length + gap, Origin + shift, rotation)
+ : new NestJobPlacement("part", 1, Origin + shift, Origin + bounds.Width + gap, rotation);
+ var first = new NestJobPlacement("part", 0, Origin, Origin, 0);
+
+ var expectedValid = !ReferenceViolates(geometry, first, second);
+ var actualValid = IsValid(job, first, second);
+
+ Assert.True(
+ expectedValid == actualValid,
+ $"{name} sample {sample}: gap {gap:F5}, shift {shift:F4}, rotation {rotation}: "
+ + $"brute force {(expectedValid ? "accepts" : "rejects")}, validator {(actualValid ? "accepts" : "rejects")}"
+ );
+
+ if (actualValid)
+ accepted++;
+ else
+ rejected++;
+ }
+
+ // The sampling has to exercise both outcomes to mean anything.
+ Assert.True(accepted > 5, $"only {accepted} accepted");
+ Assert.True(rejected > 5, $"only {rejected} rejected");
+ }
+
+ [Fact]
+ public void RingsPlacedExactlyAtSpacingPass()
+ {
+ var program = new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing().Program;
+ var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 2);
+ var job = new NestJob(
+ new[] { part },
+ new[] { new NestPlateStock("stock", new Size(200, 200), 1, Spacing) }
+ );
+
+ // Inscribed chords never bring arcs closer, so an exact-spacing layout stays valid.
+ Assert.True(IsValid(
+ job,
+ new NestJobPlacement("part", 0, Origin, Origin, 0),
+ new NestJobPlacement("part", 1, Origin + 8 + Spacing, Origin, 0)
+ ));
+ Assert.False(IsValid(
+ job,
+ new NestJobPlacement("part", 0, Origin, Origin, 0),
+ new NestJobPlacement("part", 1, Origin + 8 + Spacing - 0.01, Origin, 0)
+ ));
+ }
+
+ private static bool IsValid(NestJob job, params NestJobPlacement[] placements)
+ {
+ try
+ {
+ new NestJobRunner(_ => new CandidateNester(placements)).Solve(job);
+ return true;
+ }
+ catch (InvalidOperationException)
+ {
+ return false;
+ }
+ }
+
+ private static bool ReferenceViolates(
+ PartGeometrySnapshot geometry,
+ NestJobPlacement first,
+ NestJobPlacement second
+ )
+ {
+ var a = Contours(geometry, first);
+ var b = Contours(geometry, second);
+
+ if (Collision.HasOverlap(a[0], b[0], a.Skip(1).ToList(), b.Skip(1).ToList()))
+ return true;
+
+ var limit = Spacing - Epsilon;
+ foreach (var left in a)
+ {
+ var leftLines = left.ToLines();
+ foreach (var right in b)
+ {
+ var rightLines = right.ToLines();
+ foreach (var l in leftLines)
+ foreach (var r in rightLines)
+ {
+ if (l.Intersects(r))
+ return true;
+ var d = System.Math.Min(
+ System.Math.Min(
+ l.ClosestPointTo(r.StartPoint).DistanceTo(r.StartPoint),
+ l.ClosestPointTo(r.EndPoint).DistanceTo(r.EndPoint)
+ ),
+ System.Math.Min(
+ r.ClosestPointTo(l.StartPoint).DistanceTo(l.StartPoint),
+ r.ClosestPointTo(l.EndPoint).DistanceTo(l.EndPoint)
+ )
+ );
+ if (d < limit)
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /// Perimeter polygon first, then cutouts, placed the way the validator places them.
+ private static List Contours(PartGeometrySnapshot geometry, NestJobPlacement placement)
+ {
+ var entities = ConvertProgram
+ .ToGeometry(DrawingJobMapper.ToProgram(geometry))
+ .Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid))
+ .ToList();
+ var profile = new ShapeProfile(entities);
+ profile.NormalizeWinding();
+
+ return new[] { profile.Perimeter }
+ .Concat(profile.Cutouts)
+ .Select(shape =>
+ {
+ var contour = (Shape)shape.Clone();
+ contour.Rotate(placement.Rotation);
+ contour.Offset(placement.X, placement.Y);
+ return contour.ToPolygonWithTolerance(0.001);
+ })
+ .ToList();
+ }
+
+ private sealed class CandidateNester(IEnumerable placements) : IPlateNester
+ {
+ public PlateCandidate Place(
+ PlatePlacementRequest request,
+ IProgress? progress = null,
+ CancellationToken token = default
+ ) => new(placements);
+ }
+}
diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
index 4ef0c39..97426aa 100644
--- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
+++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
@@ -12,6 +12,10 @@ namespace OpenNest.Engine.Jobs;
internal static class NestJobPlacementValidator
{
private const double Epsilon = 0.0000001;
+ // Flattening for placement overlap/spacing checks: the same 0.001 the benchmark's
+ // NestValidator and Part.Intersects use. Arcs are inscribed, so a layout placed exactly at
+ // the spacing passes; outward arcs may come up to this much closer than the spacing.
+ private const double PlacementChordTolerance = 0.001;
internal static void ValidateCandidate(
PlateCandidate candidate,
@@ -24,6 +28,7 @@ internal static class NestJobPlacementValidator
throw new InvalidOperationException("The plate nester returned a null candidate.");
var counts = new Dictionary(StringComparer.Ordinal);
var placed = new List();
+ var sources = new Dictionary(StringComparer.Ordinal);
foreach (var placement in candidate.Placements)
{
if (
@@ -48,7 +53,9 @@ internal static class NestJobPlacementValidator
"Candidate rotation is not allowed for the requirement."
);
- var shape = Transform(CreateShape(part.Geometry), placement);
+ if (!sources.TryGetValue(placement.PartId, out var source))
+ sources[placement.PartId] = source = CreateShape(part.Geometry);
+ var shape = Transform(source, placement);
if (!FitsWorkArea(shape, stock))
throw new InvalidOperationException(
"Candidate placement falls outside the usable stock area."
@@ -296,8 +303,8 @@ internal static class NestJobPlacementValidator
private static bool Overlaps(ShapeTopology left, ShapeTopology right)
{
- var leftPoly = ToPolygon(left.Perimeter);
- var rightPoly = ToPolygon(right.Perimeter);
+ var leftPoly = left.Contours[0].Polygon;
+ var rightPoly = right.Contours[0].Polygon;
if (!leftPoly.BoundingBox.Intersects(rightPoly.BoundingBox))
return false;
// True material overlap requires shared interior area, not boundary touching.
@@ -308,8 +315,8 @@ internal static class NestJobPlacementValidator
return Collision.HasOverlap(
leftPoly,
rightPoly,
- ToPolygons(left.Cutouts),
- ToPolygons(right.Cutouts)
+ left.CutoutPolygons,
+ right.CutoutPolygons
);
}
@@ -365,44 +372,22 @@ internal static class NestJobPlacementValidator
private static double Distance(ShapeTopology left, ShapeTopology right)
{
var result = double.PositiveInfinity;
- foreach (var leftContour in AllContours(left))
- foreach (var rightContour in AllContours(right))
- if (BoundsDistance(leftContour.BoundingBox, rightContour.BoundingBox) < result)
+ foreach (var leftContour in left.Contours)
+ foreach (var rightContour in right.Contours)
+ if (BoundsDistance(leftContour.Bounds, rightContour.Bounds) < result)
result = System.Math.Min(
result,
- BoundaryDistance(ToPolygon(leftContour), ToPolygon(rightContour))
+ BoundaryDistance(leftContour.Lines, rightContour.Lines)
);
return result;
}
- private static IEnumerable AllContours(ShapeTopology shape)
- {
- yield return shape.Perimeter;
- foreach (var cutout in shape.Cutouts)
- yield return cutout;
- }
-
- private static List ToPolygons(List contours)
- {
- var polygons = new List(contours.Count);
- foreach (var contour in contours)
- polygons.Add(ToPolygon(contour));
- return polygons;
- }
-
- private static Polygon ToPolygon(Shape contour)
- {
- var polygon = contour.ToPolygon();
- polygon.UpdateBounds();
- return polygon;
- }
-
- private static double BoundaryDistance(Polygon left, Polygon right)
+ private static double BoundaryDistance(List left, List right)
{
var result = double.PositiveInfinity;
- foreach (var leftLine in left.ToLines())
+ foreach (var leftLine in left)
{
- foreach (var rightLine in right.ToLines())
+ foreach (var rightLine in right)
{
if (leftLine.Intersects(rightLine))
return 0;
@@ -429,7 +414,56 @@ internal static class NestJobPlacementValidator
private sealed class ShapeTopology(Shape perimeter, List cutouts)
{
+ private Contour[] contours;
+ private List cutoutPolygons;
+
internal Shape Perimeter { get; } = perimeter;
internal List Cutouts { get; } = cutouts;
+
+ /// The perimeter first, then the cutouts, each flattened once on first use.
+ internal Contour[] Contours
+ {
+ get
+ {
+ if (contours != null)
+ return contours;
+ var result = new Contour[Cutouts.Count + 1];
+ result[0] = new Contour(Perimeter);
+ for (var i = 0; i < Cutouts.Count; i++)
+ result[i + 1] = new Contour(Cutouts[i]);
+ return contours = result;
+ }
+ }
+
+ internal List CutoutPolygons
+ {
+ get
+ {
+ if (cutoutPolygons != null)
+ return cutoutPolygons;
+ var result = new List(Cutouts.Count);
+ for (var i = 1; i < Contours.Length; i++)
+ result.Add(Contours[i].Polygon);
+ return cutoutPolygons = result;
+ }
+ }
+ }
+
+ ///
+ /// A contour flattened once, at , for the overlap and
+ /// spacing checks against every other placement.
+ ///
+ private sealed class Contour
+ {
+ internal Contour(Shape shape)
+ {
+ Polygon = shape.ToPolygonWithTolerance(PlacementChordTolerance);
+ Bounds = Polygon.BoundingBox;
+ Lines = Polygon.ToLines();
+ }
+
+ internal Box Bounds { get; }
+ internal Polygon Polygon { get; }
+ internal List Lines { get; }
}
}