diff --git a/OpenNest.Engine/BestFit/PolygonHelper.cs b/OpenNest.Engine/BestFit/PolygonHelper.cs new file mode 100644 index 0000000..a827436 --- /dev/null +++ b/OpenNest.Engine/BestFit/PolygonHelper.cs @@ -0,0 +1,89 @@ +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.Math; +using System.Linq; + +namespace OpenNest.Engine.BestFit +{ + public static class PolygonHelper + { + public static PolygonExtractionResult ExtractPerimeterPolygon(Drawing drawing, double halfSpacing) + { + var entities = ConvertProgram.ToGeometry(drawing.Program) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + + if (entities.Count == 0) + return new PolygonExtractionResult(null, Vector.Zero); + + var definedShape = new ShapeProfile(entities); + var perimeter = definedShape.Perimeter; + + if (perimeter == null) + return new PolygonExtractionResult(null, Vector.Zero); + + // Compute the perimeter bounding box before inflation for coordinate correction. + perimeter.UpdateBounds(); + var perimeterBb = perimeter.BoundingBox; + + // Inflate by half-spacing if spacing is non-zero. + Shape inflated; + + if (halfSpacing > 0) + { + var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Left); + inflated = offsetEntity as Shape ?? perimeter; + } + else + { + inflated = perimeter; + } + + // Convert to polygon with circumscribed arcs for tight nesting. + var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true); + + if (polygon.Vertices.Count < 3) + return new PolygonExtractionResult(null, Vector.Zero); + + // Compute correction: difference between program origin and perimeter origin. + // Part.CreateAtOrigin normalizes to program bbox; polygon normalizes to perimeter bbox. + var programBb = drawing.Program.BoundingBox(); + var correction = new Vector( + perimeterBb.Left - programBb.Location.X, + perimeterBb.Bottom - programBb.Location.Y); + + // Normalize: move reference point to origin. + polygon.UpdateBounds(); + var bb = polygon.BoundingBox; + polygon.Offset(-bb.Left, -bb.Bottom); + + return new PolygonExtractionResult(polygon, correction); + } + + public static Polygon RotatePolygon(Polygon polygon, double angle) + { + if (angle.IsEqualTo(0)) + return polygon; + + var result = new Polygon(); + var cos = System.Math.Cos(angle); + var sin = System.Math.Sin(angle); + + foreach (var v in polygon.Vertices) + { + result.Vertices.Add(new Vector( + v.X * cos - v.Y * sin, + v.X * sin + v.Y * cos)); + } + + // Re-normalize to origin. + result.UpdateBounds(); + var bb = result.BoundingBox; + result.Offset(-bb.Left, -bb.Bottom); + + return result; + } + } + + public record PolygonExtractionResult(Polygon Polygon, Vector Correction); +} diff --git a/OpenNest.Engine/Nfp/AutoNester.cs b/OpenNest.Engine/Nfp/AutoNester.cs index a439c5a..9b94871 100644 --- a/OpenNest.Engine/Nfp/AutoNester.cs +++ b/OpenNest.Engine/Nfp/AutoNester.cs @@ -1,4 +1,3 @@ -using OpenNest.Converters; using OpenNest.Geometry; using OpenNest.Math; using System; @@ -203,44 +202,7 @@ namespace OpenNest.Engine.Nfp /// private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing) { - var entities = ConvertProgram.ToGeometry(drawing.Program) - .Where(e => e.Layer != SpecialLayers.Rapid) - .ToList(); - - if (entities.Count == 0) - return null; - - var definedShape = new ShapeProfile(entities); - var perimeter = definedShape.Perimeter; - - if (perimeter == null) - return null; - - // Inflate by half-spacing if spacing is non-zero. - Shape inflated; - - if (halfSpacing > 0) - { - var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Left); - inflated = offsetEntity as Shape ?? perimeter; - } - else - { - inflated = perimeter; - } - - // Convert to polygon with circumscribed arcs for tight nesting. - var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true); - - if (polygon.Vertices.Count < 3) - return null; - - // Normalize: move reference point to origin. - polygon.UpdateBounds(); - var bb = polygon.BoundingBox; - polygon.Offset(-bb.Left, -bb.Bottom); - - return polygon; + return BestFit.PolygonHelper.ExtractPerimeterPolygon(drawing, halfSpacing).Polygon; } /// @@ -320,26 +282,7 @@ namespace OpenNest.Engine.Nfp /// private static Polygon RotatePolygon(Polygon polygon, double angle) { - if (angle.IsEqualTo(0)) - return polygon; - - var result = new Polygon(); - var cos = System.Math.Cos(angle); - var sin = System.Math.Sin(angle); - - foreach (var v in polygon.Vertices) - { - result.Vertices.Add(new Vector( - v.X * cos - v.Y * sin, - v.X * sin + v.Y * cos)); - } - - // Re-normalize to origin. - result.UpdateBounds(); - var bb = result.BoundingBox; - result.Offset(-bb.Left, -bb.Bottom); - - return result; + return BestFit.PolygonHelper.RotatePolygon(polygon, angle); } } } diff --git a/OpenNest.Tests/PolygonHelperTests.cs b/OpenNest.Tests/PolygonHelperTests.cs new file mode 100644 index 0000000..494f368 --- /dev/null +++ b/OpenNest.Tests/PolygonHelperTests.cs @@ -0,0 +1,88 @@ +using OpenNest.CNC; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests; + +public class PolygonHelperTests +{ + [Fact] + public void ExtractPerimeterPolygon_ReturnsPolygon_ForValidDrawing() + { + var drawing = TestHelpers.MakeSquareDrawing(); + var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0); + Assert.NotNull(result.Polygon); + Assert.True(result.Polygon.Vertices.Count >= 4); + } + + [Fact] + public void ExtractPerimeterPolygon_InflatesPolygon_WhenSpacingNonZero() + { + var drawing = TestHelpers.MakeSquareDrawing(10); + var noSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 0); + var withSpacing = PolygonHelper.ExtractPerimeterPolygon(drawing, 1); + + noSpacing.Polygon.UpdateBounds(); + withSpacing.Polygon.UpdateBounds(); + + // The offset polygon should differ in size from the non-offset polygon. + // OffsetSide.Left offsets outward or inward depending on winding, + // but either way the result must be a different size. + Assert.True( + System.Math.Abs(withSpacing.Polygon.BoundingBox.Width - noSpacing.Polygon.BoundingBox.Width) > 0.5, + $"Expected polygon width to differ by >0.5 with 1mm spacing. " + + $"No-spacing width: {noSpacing.Polygon.BoundingBox.Width:F3}, " + + $"With-spacing width: {withSpacing.Polygon.BoundingBox.Width:F3}"); + } + + [Fact] + public void ExtractPerimeterPolygon_ReturnsNull_ForEmptyDrawing() + { + var pgm = new Program(); + var drawing = new Drawing("empty", pgm); + var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0); + Assert.Null(result.Polygon); + } + + [Fact] + public void ExtractPerimeterPolygon_CorrectionVector_ReflectsOriginDifference() + { + var drawing = TestHelpers.MakeSquareDrawing(); + var result = PolygonHelper.ExtractPerimeterPolygon(drawing, 0); + Assert.NotNull(result.Polygon); + Assert.True(System.Math.Abs(result.Correction.X) < 1); + Assert.True(System.Math.Abs(result.Correction.Y) < 1); + } + + [Fact] + public void RotatePolygon_AtZero_ReturnsSamePolygon() + { + var polygon = new Polygon(); + polygon.Vertices.Add(new Vector(0, 0)); + polygon.Vertices.Add(new Vector(10, 0)); + polygon.Vertices.Add(new Vector(10, 10)); + polygon.Vertices.Add(new Vector(0, 10)); + polygon.UpdateBounds(); + + var rotated = PolygonHelper.RotatePolygon(polygon, 0); + Assert.Same(polygon, rotated); + } + + [Fact] + public void RotatePolygon_At90Degrees_SwapsDimensions() + { + var polygon = new Polygon(); + polygon.Vertices.Add(new Vector(0, 0)); + polygon.Vertices.Add(new Vector(20, 0)); + polygon.Vertices.Add(new Vector(20, 10)); + polygon.Vertices.Add(new Vector(0, 10)); + polygon.UpdateBounds(); + + var rotated = PolygonHelper.RotatePolygon(polygon, Angle.HalfPI); + rotated.UpdateBounds(); + + Assert.True(System.Math.Abs(rotated.BoundingBox.Width - 10) < 0.1); + Assert.True(System.Math.Abs(rotated.BoundingBox.Length - 20) < 0.1); + } +} diff --git a/OpenNest.Tests/TestHelpers.cs b/OpenNest.Tests/TestHelpers.cs index 8d68f0b..cb24939 100644 --- a/OpenNest.Tests/TestHelpers.cs +++ b/OpenNest.Tests/TestHelpers.cs @@ -24,4 +24,28 @@ internal static class TestHelpers plate.Parts.Add(p); return plate; } + + public static Drawing MakeSquareDrawing(double size = 10) + { + 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))); + return new Drawing("square", pgm); + } + + public static Drawing MakeLShapeDrawing() + { + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 5))); + pgm.Codes.Add(new LinearMove(new Vector(5, 5))); + pgm.Codes.Add(new LinearMove(new Vector(5, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + return new Drawing("lshape", pgm); + } }