diff --git a/OpenNest.Core/Geometry/GeometryOptimizer.cs b/OpenNest.Core/Geometry/GeometryOptimizer.cs index dfe5b9a..ff9a888 100644 --- a/OpenNest.Core/Geometry/GeometryOptimizer.cs +++ b/OpenNest.Core/Geometry/GeometryOptimizer.cs @@ -133,17 +133,30 @@ namespace OpenNest.Geometry if (!arc1.Radius.IsEqualTo(arc2.Radius)) return false; - if (arc1.StartAngle > arc1.EndAngle) - arc1.StartAngle -= Angle.TwoPI; + var start1 = arc1.StartAngle; + var end1 = arc1.EndAngle; + var start2 = arc2.StartAngle; + var end2 = arc2.EndAngle; - if (arc2.StartAngle > arc2.EndAngle) - arc2.StartAngle -= Angle.TwoPI; + if (start1 > end1) + start1 -= Angle.TwoPI; - if (arc1.EndAngle < arc2.StartAngle || arc1.StartAngle > arc2.EndAngle) + if (start2 > end2) + start2 -= Angle.TwoPI; + + // Check that arcs are adjacent (endpoints touch), not overlapping + var touch1 = end1.IsEqualTo(start2) || (end1 + Angle.TwoPI).IsEqualTo(start2); + var touch2 = end2.IsEqualTo(start1) || (end2 + Angle.TwoPI).IsEqualTo(start1); + if (!touch1 && !touch2) return false; - var startAngle = arc1.StartAngle < arc2.StartAngle ? arc1.StartAngle : arc2.StartAngle; - var endAngle = arc1.EndAngle > arc2.EndAngle ? arc1.EndAngle : arc2.EndAngle; + var startAngle = start1 < start2 ? start1 : start2; + var endAngle = end1 > end2 ? end1 : end2; + + // Don't merge if the result would be a full circle (start == end) + var sweep = endAngle - startAngle; + if (sweep >= Angle.TwoPI - Tolerance.Epsilon) + return false; if (startAngle < 0) startAngle += Angle.TwoPI; if (endAngle < 0) endAngle += Angle.TwoPI; diff --git a/OpenNest.Tests/Splitting/DrawingSplitterTests.cs b/OpenNest.Tests/Splitting/DrawingSplitterTests.cs index df986f0..8c61294 100644 --- a/OpenNest.Tests/Splitting/DrawingSplitterTests.cs +++ b/OpenNest.Tests/Splitting/DrawingSplitterTests.cs @@ -1,30 +1,15 @@ using OpenNest.Converters; using OpenNest.Geometry; +using OpenNest.Shapes; namespace OpenNest.Tests.Splitting; public class DrawingSplitterTests { - /// - /// Helper: creates a Drawing from a rectangular perimeter. - /// - private static Drawing MakeRectangleDrawing(string name, double width, double height) - { - var entities = new List - { - new Line(new Vector(0, 0), new Vector(width, 0)), - new Line(new Vector(width, 0), new Vector(width, height)), - new Line(new Vector(width, height), new Vector(0, height)), - new Line(new Vector(0, height), new Vector(0, 0)) - }; - var pgm = ConvertGeometry.ToProgram(entities); - return new Drawing(name, pgm); - } - [Fact] public void Split_Rectangle_Vertical_ProducesTwoPieces() { - var drawing = MakeRectangleDrawing("RECT", 100, 50); + var drawing = new RectangleShape { Name = "RECT", Length = 100, Width = 50 }.GetDrawing(); var splitLines = new List { new SplitLine(50.0, CutOffAxis.Vertical) }; var parameters = new SplitParameters { Type = SplitType.Straight }; @@ -42,7 +27,7 @@ public class DrawingSplitterTests [Fact] public void Split_Rectangle_Horizontal_ProducesTwoPieces() { - var drawing = MakeRectangleDrawing("RECT", 100, 60); + var drawing = new RectangleShape { Name = "RECT", Length = 100, Width = 60 }.GetDrawing(); var splitLines = new List { new SplitLine(30.0, CutOffAxis.Horizontal) }; var parameters = new SplitParameters { Type = SplitType.Straight }; @@ -56,7 +41,7 @@ public class DrawingSplitterTests [Fact] public void Split_ThreePieces_NamesSequentially() { - var drawing = MakeRectangleDrawing("PART", 150, 50); + var drawing = new RectangleShape { Name = "PART", Length = 150, Width = 50 }.GetDrawing(); var splitLines = new List { new SplitLine(50.0, CutOffAxis.Vertical), @@ -75,7 +60,7 @@ public class DrawingSplitterTests [Fact] public void Split_CopiesDrawingProperties() { - var drawing = MakeRectangleDrawing("PART", 100, 50); + var drawing = new RectangleShape { Name = "PART", Length = 100, Width = 50 }.GetDrawing(); drawing.Color = System.Drawing.Color.Red; drawing.Priority = 5; @@ -93,7 +78,7 @@ public class DrawingSplitterTests [Fact] public void Split_PiecesNormalizedToOrigin() { - var drawing = MakeRectangleDrawing("PART", 100, 50); + var drawing = new RectangleShape { Name = "PART", Length = 100, Width = 50 }.GetDrawing(); var results = DrawingSplitter.Split(drawing, new List { new SplitLine(50.0, CutOffAxis.Vertical) }, new SplitParameters()); @@ -146,7 +131,7 @@ public class DrawingSplitterTests [Fact] public void Split_GridSplit_ProducesFourPieces() { - var drawing = MakeRectangleDrawing("GRID", 100, 100); + var drawing = new RectangleShape { Name = "GRID", Length = 100, Width = 100 }.GetDrawing(); var splitLines = new List { new SplitLine(50.0, CutOffAxis.Vertical), @@ -160,4 +145,263 @@ public class DrawingSplitterTests Assert.Equal("GRID-3", results[2].Name); Assert.Equal("GRID-4", results[3].Name); } + + [Fact] + public void Split_Square_Vertical_PieceWidthsSumToOriginal() + { + var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing(); + var splitLines = new List { new SplitLine(40.0, CutOffAxis.Vertical) }; + var parameters = new SplitParameters { Type = SplitType.Straight }; + + var results = DrawingSplitter.Split(drawing, splitLines, parameters); + + Assert.Equal(2, results.Count); + + var bb1 = results[0].Program.BoundingBox(); + var bb2 = results[1].Program.BoundingBox(); + + // Piece lengths should sum to original length + Assert.Equal(100.0, bb1.Width + bb2.Width, 1); + + // Both pieces should have the same width as the original + Assert.Equal(100.0, bb1.Length, 1); + Assert.Equal(100.0, bb2.Length, 1); + } + + [Fact] + public void Split_Square_Horizontal_PieceHeightsSumToOriginal() + { + var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing(); + var splitLines = new List { new SplitLine(60.0, CutOffAxis.Horizontal) }; + var parameters = new SplitParameters { Type = SplitType.Straight }; + + var results = DrawingSplitter.Split(drawing, splitLines, parameters); + + Assert.Equal(2, results.Count); + + var bb1 = results[0].Program.BoundingBox(); + var bb2 = results[1].Program.BoundingBox(); + + // Piece widths should sum to original width + Assert.Equal(100.0, bb1.Length + bb2.Length, 1); + + // Both pieces should have the same length as the original + Assert.Equal(100.0, bb1.Width, 1); + Assert.Equal(100.0, bb2.Width, 1); + } + + [Fact] + public void Split_Square_Vertical_AreaPreserved() + { + var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing(); + var originalArea = drawing.Area; + var splitLines = new List { new SplitLine(50.0, CutOffAxis.Vertical) }; + var parameters = new SplitParameters { Type = SplitType.Straight }; + + var results = DrawingSplitter.Split(drawing, splitLines, parameters); + + var totalArea = results.Sum(d => d.Area); + Assert.Equal(originalArea, totalArea, 1); + } + + [Fact] + public void Split_Square_Vertical_PiecesAreClosedPerimeters() + { + var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing(); + var splitLines = new List { new SplitLine(50.0, CutOffAxis.Vertical) }; + var parameters = new SplitParameters { Type = SplitType.Straight }; + + var results = DrawingSplitter.Split(drawing, splitLines, parameters); + + foreach (var piece in results) + { + var entities = ConvertProgram.ToGeometry(piece.Program) + .Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + + Assert.True(entities.Count >= 4, $"{piece.Name} should have at least 4 entities for a rectangle"); + + // First entity start should connect to last entity end (closed shape) + var firstStart = GetStartPoint(entities[0]); + var lastEnd = GetEndPoint(entities[^1]); + var closingGap = firstStart.DistanceTo(lastEnd); + Assert.True(closingGap < 0.01, + $"{piece.Name} is not closed: gap of {closingGap:F6} between last end and first start"); + + // Consecutive entities should connect + for (var i = 0; i < entities.Count - 1; i++) + { + var end = GetEndPoint(entities[i]); + var start = GetStartPoint(entities[i + 1]); + var gap = end.DistanceTo(start); + Assert.True(gap < 0.01, + $"Gap of {gap:F6} between entities {i} and {i + 1} in {piece.Name}"); + } + } + } + + [Fact] + public void Split_Square_Horizontal_PiecesAreClosedPerimeters() + { + var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing(); + var splitLines = new List { new SplitLine(50.0, CutOffAxis.Horizontal) }; + var parameters = new SplitParameters { Type = SplitType.Straight }; + + var results = DrawingSplitter.Split(drawing, splitLines, parameters); + + foreach (var piece in results) + { + var entities = ConvertProgram.ToGeometry(piece.Program) + .Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + + Assert.True(entities.Count >= 4, $"{piece.Name} should have at least 4 entities for a rectangle"); + + var firstStart = GetStartPoint(entities[0]); + var lastEnd = GetEndPoint(entities[^1]); + var closingGap = firstStart.DistanceTo(lastEnd); + Assert.True(closingGap < 0.01, + $"{piece.Name} is not closed: gap of {closingGap:F6} between last end and first start"); + + for (var i = 0; i < entities.Count - 1; i++) + { + var end = GetEndPoint(entities[i]); + var start = GetStartPoint(entities[i + 1]); + var gap = end.DistanceTo(start); + Assert.True(gap < 0.01, + $"Gap of {gap:F6} between entities {i} and {i + 1} in {piece.Name}"); + } + } + } + + [Fact] + public void Split_Square_AsymmetricSplit_PieceDimensionsMatchSplitPosition() + { + var drawing = new RectangleShape { Name = "SQ", Length = 100, Width = 100 }.GetDrawing(); + var splitLines = new List { new SplitLine(30.0, CutOffAxis.Vertical) }; + var parameters = new SplitParameters { Type = SplitType.Straight }; + + var results = DrawingSplitter.Split(drawing, splitLines, parameters); + + Assert.Equal(2, results.Count); + + var bb1 = results[0].Program.BoundingBox(); + var bb2 = results[1].Program.BoundingBox(); + + // Left piece should be 30 long, right piece should be 70 long + Assert.Equal(30.0, bb1.Width, 1); + Assert.Equal(70.0, bb2.Width, 1); + } + + [Fact] + public void Split_CircleHole_NotOnSplitLine_PreservedInCorrectPiece() + { + // Rectangle 100x50 with a circle hole at (20, 25) radius 3 + // Split vertically at x=50 — hole is entirely in the left piece + var perimeterEntities = new List + { + new Line(new Vector(0, 0), new Vector(100, 0)), + new Line(new Vector(100, 0), new Vector(100, 50)), + new Line(new Vector(100, 50), new Vector(0, 50)), + new Line(new Vector(0, 50), new Vector(0, 0)) + }; + var hole = new Circle(new Vector(20, 25), 3); + var allEntities = new List(); + allEntities.AddRange(perimeterEntities); + allEntities.Add(hole); + + var pgm = ConvertGeometry.ToProgram(allEntities); + var drawing = new Drawing("CIRC", pgm); + + var results = DrawingSplitter.Split(drawing, + new List { new SplitLine(50.0, CutOffAxis.Vertical) }, + new SplitParameters()); + + Assert.Equal(2, results.Count); + + // Left piece should have the hole — verify by checking it has arc entities + var leftEntities = ConvertProgram.ToGeometry(results[0].Program) + .Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + var leftArcs = leftEntities.OfType().ToList(); + + // Decomposed circle = 2 arcs. Both should be present. + Assert.True(leftArcs.Count >= 2, + $"Left piece should have at least 2 arcs (full circle), but has {leftArcs.Count}"); + + // Right piece should have no arcs (hole is on the left) + var rightEntities = ConvertProgram.ToGeometry(results[1].Program) + .Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + var rightArcs = rightEntities.OfType().ToList(); + Assert.Equal(0, rightArcs.Count); + } + + [Fact] + public void Split_DxfRoundTrip_CircleHolePreserved() + { + // Two semicircular arcs (decomposed circle) must survive DXF write→reimport. + // Regression: GeometryOptimizer.TryJoinArcs merged them into a single arc + // because it incorrectly handled the wrap-around case (π→2π written as π→0°). + var perimeterEntities = new List + { + new Line(new Vector(0, 0), new Vector(100, 0)), + new Line(new Vector(100, 0), new Vector(100, 50)), + new Line(new Vector(100, 50), new Vector(0, 50)), + new Line(new Vector(0, 50), new Vector(0, 0)) + }; + var hole = new Circle(new Vector(20, 25), 3); + var allEntities = new List(); + allEntities.AddRange(perimeterEntities); + allEntities.Add(hole); + + var pgm = ConvertGeometry.ToProgram(allEntities); + var drawing = new Drawing("CIRC", pgm); + drawing.Bends = new List(); + + // Split — the circle gets decomposed into two arcs + var results = DrawingSplitter.Split(drawing, + new List { new SplitLine(50.0, CutOffAxis.Vertical) }, + new SplitParameters()); + + Assert.Equal(2, results.Count); + + // Write left piece to DXF and re-import + var tempPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "split_roundtrip_test.dxf"); + try + { + var writer = new OpenNest.IO.SplitDxfWriter(); + writer.Write(tempPath, results[0]); + + var reimporter = new OpenNest.IO.DxfImporter(); + var reimportResult = reimporter.Import(tempPath); + + var afterArcs = reimportResult.Entities.OfType().Count(); + var afterCircles = reimportResult.Entities.OfType().Count(); + + Assert.True(afterArcs + afterCircles * 2 >= 2, + $"After DXF round-trip: {afterArcs} arcs, {afterCircles} circles (expected 2+ for full hole)"); + } + finally + { + if (System.IO.File.Exists(tempPath)) + System.IO.File.Delete(tempPath); + } + } + + private static Vector GetStartPoint(Entity entity) + { + return entity switch + { + Line l => l.StartPoint, + Arc a => a.StartPoint(), + _ => new Vector(0, 0) + }; + } + + private static Vector GetEndPoint(Entity entity) + { + return entity switch + { + Line l => l.EndPoint, + Arc a => a.EndPoint(), + _ => new Vector(0, 0) + }; + } }