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