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