diff --git a/OpenNest.Core/Splitting/DrawingSplitter.cs b/OpenNest.Core/Splitting/DrawingSplitter.cs
new file mode 100644
index 0000000..0a9785b
--- /dev/null
+++ b/OpenNest.Core/Splitting/DrawingSplitter.cs
@@ -0,0 +1,375 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Converters;
+using OpenNest.Geometry;
+
+namespace OpenNest;
+
+///
+/// Splits a Drawing into multiple pieces along split lines with optional feature geometry.
+///
+public static class DrawingSplitter
+{
+ public static List Split(Drawing drawing, List splitLines, SplitParameters parameters)
+ {
+ if (splitLines.Count == 0)
+ return new List { drawing };
+
+ // 1. Convert program to geometry -> ShapeProfile separates perimeter from cutouts
+ // Filter out rapid-layer entities so ShapeBuilder doesn't chain cutouts to the perimeter
+ var entities = ConvertProgram.ToGeometry(drawing.Program)
+ .Where(e => e.Layer != SpecialLayers.Rapid)
+ .ToList();
+ var profile = new ShapeProfile(entities);
+
+ // Decompose circles to arcs so all entities support SplitAt()
+ DecomposeCircles(profile);
+
+ var perimeter = profile.Perimeter;
+ var bounds = perimeter.BoundingBox;
+
+ // 2. Sort split lines by position, discard any outside the part
+ var sortedLines = splitLines
+ .Where(l => IsLineInsideBounds(l, bounds))
+ .OrderBy(l => l.Position)
+ .ToList();
+
+ if (sortedLines.Count == 0)
+ return new List { drawing };
+
+ // 3. Build clip regions (grid cells between split lines)
+ var regions = BuildClipRegions(sortedLines, bounds);
+
+ // 4. Get the split feature strategy
+ var feature = GetFeature(parameters.Type);
+
+ // 5. For each region, clip the perimeter and build a new drawing
+ var results = new List();
+ var pieceIndex = 1;
+
+ foreach (var region in regions)
+ {
+ var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters);
+
+ if (pieceEntities.Count == 0)
+ continue;
+
+ // Assign cutouts fully inside this region
+ var cutoutEntities = new List();
+ foreach (var cutout in profile.Cutouts)
+ {
+ if (IsCutoutInRegion(cutout, region))
+ cutoutEntities.AddRange(cutout.Entities);
+ else if (DoesCutoutCrossSplitLine(cutout, sortedLines))
+ {
+ // Cutout crosses a split line -- clip it to this region too
+ var clippedCutout = ClipCutoutToRegion(cutout, region);
+ if (clippedCutout.Count > 0)
+ cutoutEntities.AddRange(clippedCutout);
+ }
+ }
+
+ // Normalize origin: translate so bounding box starts at (0,0)
+ var allEntities = new List();
+ allEntities.AddRange(pieceEntities);
+ allEntities.AddRange(cutoutEntities);
+
+ var pieceBounds = allEntities.Select(e => e.BoundingBox).ToList().GetBoundingBox();
+ var offsetX = -pieceBounds.X;
+ var offsetY = -pieceBounds.Y;
+
+ foreach (var e in allEntities)
+ e.Offset(offsetX, offsetY);
+
+ // Build program (ConvertGeometry.ToProgram internally identifies perimeter vs cutouts)
+ var pgm = ConvertGeometry.ToProgram(allEntities);
+
+ // Create drawing with copied properties
+ var piece = new Drawing($"{drawing.Name}-{pieceIndex}", pgm);
+ piece.Color = drawing.Color;
+ piece.Priority = drawing.Priority;
+ piece.Material = drawing.Material;
+ piece.Constraints = drawing.Constraints;
+ piece.Customer = drawing.Customer;
+ piece.Source = drawing.Source;
+
+ piece.Quantity.Required = drawing.Quantity.Required;
+
+ results.Add(piece);
+ pieceIndex++;
+ }
+
+ return results;
+ }
+
+ private static void DecomposeCircles(ShapeProfile profile)
+ {
+ DecomposeCirclesInShape(profile.Perimeter);
+ foreach (var cutout in profile.Cutouts)
+ DecomposeCirclesInShape(cutout);
+ }
+
+ private static void DecomposeCirclesInShape(Shape shape)
+ {
+ for (var i = shape.Entities.Count - 1; i >= 0; i--)
+ {
+ if (shape.Entities[i] is Circle circle)
+ {
+ var arc1 = new Arc(circle.Center, circle.Radius, 0, System.Math.PI);
+ var arc2 = new Arc(circle.Center, circle.Radius, System.Math.PI, System.Math.PI * 2);
+ shape.Entities.RemoveAt(i);
+ shape.Entities.Insert(i, arc2);
+ shape.Entities.Insert(i, arc1);
+ }
+ }
+ }
+
+ private static bool IsLineInsideBounds(SplitLine line, Box bounds)
+ {
+ return line.Axis == CutOffAxis.Vertical
+ ? line.Position > bounds.Left + OpenNest.Math.Tolerance.Epsilon
+ && line.Position < bounds.Right - OpenNest.Math.Tolerance.Epsilon
+ : line.Position > bounds.Bottom + OpenNest.Math.Tolerance.Epsilon
+ && line.Position < bounds.Top - OpenNest.Math.Tolerance.Epsilon;
+ }
+
+ private static List BuildClipRegions(List sortedLines, Box bounds)
+ {
+ var verticals = sortedLines.Where(l => l.Axis == CutOffAxis.Vertical).OrderBy(l => l.Position).ToList();
+ var horizontals = sortedLines.Where(l => l.Axis == CutOffAxis.Horizontal).OrderBy(l => l.Position).ToList();
+
+ var xEdges = new List { bounds.Left };
+ xEdges.AddRange(verticals.Select(v => v.Position));
+ xEdges.Add(bounds.Right);
+
+ var yEdges = new List { bounds.Bottom };
+ yEdges.AddRange(horizontals.Select(h => h.Position));
+ yEdges.Add(bounds.Top);
+
+ var regions = new List();
+ for (var yi = 0; yi < yEdges.Count - 1; yi++)
+ for (var xi = 0; xi < xEdges.Count - 1; xi++)
+ regions.Add(new Box(xEdges[xi], yEdges[yi], xEdges[xi + 1] - xEdges[xi], yEdges[yi + 1] - yEdges[yi]));
+
+ return regions;
+ }
+
+ ///
+ /// Clip perimeter to a region using Clipper2, then recover original arcs and stitch in feature edges.
+ ///
+ private static List ClipPerimeterToRegion(Shape perimeter, Box region,
+ List splitLines, ISplitFeature feature, SplitParameters parameters)
+ {
+ var perimPoly = perimeter.ToPolygonWithTolerance(0.01);
+
+ var regionPoly = new Polygon();
+ regionPoly.Vertices.Add(new Vector(region.Left, region.Bottom));
+ regionPoly.Vertices.Add(new Vector(region.Right, region.Bottom));
+ regionPoly.Vertices.Add(new Vector(region.Right, region.Top));
+ regionPoly.Vertices.Add(new Vector(region.Left, region.Top));
+ regionPoly.Close();
+
+ // Reuse existing Clipper2 helpers from NoFitPolygon
+ var subj = new Clipper2Lib.PathsD { NoFitPolygon.ToClipperPath(perimPoly) };
+ var clip = new Clipper2Lib.PathsD { NoFitPolygon.ToClipperPath(regionPoly) };
+ var result = Clipper2Lib.Clipper.Intersect(subj, clip, Clipper2Lib.FillRule.NonZero);
+
+ if (result.Count == 0)
+ return new List();
+
+ var clippedPoly = NoFitPolygon.FromClipperPath(result[0]);
+ var clippedEntities = new List();
+
+ var verts = clippedPoly.Vertices;
+ for (var i = 0; i < verts.Count - 1; i++)
+ {
+ var start = verts[i];
+ var end = verts[i + 1];
+
+ // Check if this edge lies on a split line -- replace with feature geometry
+ var splitLine = FindSplitLineForEdge(start, end, splitLines);
+ if (splitLine != null)
+ {
+ var extentStart = splitLine.Axis == CutOffAxis.Vertical
+ ? System.Math.Min(start.Y, end.Y)
+ : System.Math.Min(start.X, end.X);
+ var extentEnd = splitLine.Axis == CutOffAxis.Vertical
+ ? System.Math.Max(start.Y, end.Y)
+ : System.Math.Max(start.X, end.X);
+
+ var featureResult = feature.GenerateFeatures(splitLine, extentStart, extentEnd, parameters);
+
+ var regionCenter = splitLine.Axis == CutOffAxis.Vertical
+ ? (region.Left + region.Right) / 2
+ : (region.Bottom + region.Top) / 2;
+ var isNegativeSide = regionCenter < splitLine.Position;
+ var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge;
+
+ // Ensure feature edge direction matches the polygon winding
+ if (featureEdge.Count > 0)
+ {
+ var featureStart = GetStartPoint(featureEdge[0]);
+ var featureEnd = GetEndPoint(featureEdge[^1]);
+ var edgeGoesForward = splitLine.Axis == CutOffAxis.Vertical
+ ? start.Y < end.Y : start.X < end.X;
+ var featureGoesForward = splitLine.Axis == CutOffAxis.Vertical
+ ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X;
+
+ if (edgeGoesForward != featureGoesForward)
+ {
+ featureEdge = new List(featureEdge);
+ featureEdge.Reverse();
+ foreach (var e in featureEdge)
+ e.Reverse();
+ }
+ }
+
+ clippedEntities.AddRange(featureEdge);
+ }
+ else
+ {
+ // Try to recover original arc for this chord edge
+ var originalArc = FindMatchingArc(start, end, perimeter);
+ if (originalArc != null)
+ clippedEntities.Add(originalArc);
+ else
+ clippedEntities.Add(new Line(start, end));
+ }
+ }
+
+ // Ensure CW winding for perimeter (positive area = CCW in Polygon, so CW = negative)
+ var shape = new Shape();
+ shape.Entities.AddRange(clippedEntities);
+ var poly = shape.ToPolygon();
+ if (poly != null && poly.RotationDirection() != RotationType.CW)
+ shape.Reverse();
+
+ return shape.Entities;
+ }
+
+ private static SplitLine FindSplitLineForEdge(Vector start, Vector end, List splitLines)
+ {
+ foreach (var sl in splitLines)
+ {
+ if (sl.Axis == CutOffAxis.Vertical)
+ {
+ if (System.Math.Abs(start.X - sl.Position) < 0.1 && System.Math.Abs(end.X - sl.Position) < 0.1)
+ return sl;
+ }
+ else
+ {
+ if (System.Math.Abs(start.Y - sl.Position) < 0.1 && System.Math.Abs(end.Y - sl.Position) < 0.1)
+ return sl;
+ }
+ }
+ return null;
+ }
+
+ ///
+ /// Search original perimeter for an arc whose circle matches this polygon chord edge.
+ /// Returns a new arc segment between the chord endpoints if found.
+ ///
+ private static Arc FindMatchingArc(Vector start, Vector end, Shape perimeter)
+ {
+ foreach (var entity in perimeter.Entities)
+ {
+ if (entity is Arc arc)
+ {
+ var distStart = start.DistanceTo(arc.Center) - arc.Radius;
+ var distEnd = end.DistanceTo(arc.Center) - arc.Radius;
+
+ if (System.Math.Abs(distStart) < 0.1 && System.Math.Abs(distEnd) < 0.1)
+ {
+ var startAngle = OpenNest.Math.Angle.NormalizeRad(arc.Center.AngleTo(start));
+ var endAngle = OpenNest.Math.Angle.NormalizeRad(arc.Center.AngleTo(end));
+ return new Arc(arc.Center, arc.Radius, startAngle, endAngle, arc.IsReversed);
+ }
+ }
+ }
+ return null;
+ }
+
+ private static bool IsCutoutInRegion(Shape cutout, Box region)
+ {
+ if (cutout.Entities.Count == 0) return false;
+ var pt = GetStartPoint(cutout.Entities[0]);
+ return region.Contains(pt);
+ }
+
+ private static bool DoesCutoutCrossSplitLine(Shape cutout, List splitLines)
+ {
+ var bb = cutout.BoundingBox;
+ foreach (var sl in splitLines)
+ {
+ if (sl.Axis == CutOffAxis.Vertical && bb.Left < sl.Position && bb.Right > sl.Position)
+ return true;
+ if (sl.Axis == CutOffAxis.Horizontal && bb.Bottom < sl.Position && bb.Top > sl.Position)
+ return true;
+ }
+ return false;
+ }
+
+ private static List ClipCutoutToRegion(Shape cutout, Box region)
+ {
+ var cutoutPoly = cutout.ToPolygonWithTolerance(0.01);
+ var regionPoly = new Polygon();
+ regionPoly.Vertices.Add(new Vector(region.Left, region.Bottom));
+ regionPoly.Vertices.Add(new Vector(region.Right, region.Bottom));
+ regionPoly.Vertices.Add(new Vector(region.Right, region.Top));
+ regionPoly.Vertices.Add(new Vector(region.Left, region.Top));
+ regionPoly.Close();
+
+ var subj = new Clipper2Lib.PathsD { NoFitPolygon.ToClipperPath(cutoutPoly) };
+ var clip = new Clipper2Lib.PathsD { NoFitPolygon.ToClipperPath(regionPoly) };
+ var result = Clipper2Lib.Clipper.Intersect(subj, clip, Clipper2Lib.FillRule.NonZero);
+
+ if (result.Count == 0)
+ return new List();
+
+ var clippedPoly = NoFitPolygon.FromClipperPath(result[0]);
+ var lineEntities = new List();
+ var verts = clippedPoly.Vertices;
+ for (var i = 0; i < verts.Count - 1; i++)
+ lineEntities.Add(new Line(verts[i], verts[i + 1]));
+
+ // Ensure CCW winding for cutouts
+ var shape = new Shape();
+ shape.Entities.AddRange(lineEntities);
+ var poly = shape.ToPolygon();
+ if (poly != null && poly.RotationDirection() != RotationType.CCW)
+ shape.Reverse();
+
+ return shape.Entities;
+ }
+
+ private static Vector GetStartPoint(Entity entity)
+ {
+ return entity switch
+ {
+ Line l => l.StartPoint,
+ Arc a => a.StartPoint(), // Arc.StartPoint() is a method
+ _ => new Vector(0, 0)
+ };
+ }
+
+ private static Vector GetEndPoint(Entity entity)
+ {
+ return entity switch
+ {
+ Line l => l.EndPoint,
+ Arc a => a.EndPoint(), // Arc.EndPoint() is a method
+ _ => new Vector(0, 0)
+ };
+ }
+
+ private static ISplitFeature GetFeature(SplitType type)
+ {
+ return type switch
+ {
+ SplitType.Straight => new StraightSplit(),
+ SplitType.WeldGapTabs => new WeldGapTabSplit(),
+ SplitType.SpikeGroove => new SpikeGrooveSplit(),
+ _ => new StraightSplit()
+ };
+ }
+}
diff --git a/OpenNest.Tests/Splitting/DrawingSplitterTests.cs b/OpenNest.Tests/Splitting/DrawingSplitterTests.cs
new file mode 100644
index 0000000..df986f0
--- /dev/null
+++ b/OpenNest.Tests/Splitting/DrawingSplitterTests.cs
@@ -0,0 +1,163 @@
+using OpenNest.Converters;
+using OpenNest.Geometry;
+
+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 splitLines = new List { new SplitLine(50.0, CutOffAxis.Vertical) };
+ var parameters = new SplitParameters { Type = SplitType.Straight };
+
+ var results = DrawingSplitter.Split(drawing, splitLines, parameters);
+
+ Assert.Equal(2, results.Count);
+ Assert.Equal("RECT-1", results[0].Name);
+ Assert.Equal("RECT-2", results[1].Name);
+
+ // Each piece should have area close to half the original
+ var totalArea = results.Sum(d => d.Area);
+ Assert.Equal(drawing.Area, totalArea, 1);
+ }
+
+ [Fact]
+ public void Split_Rectangle_Horizontal_ProducesTwoPieces()
+ {
+ var drawing = MakeRectangleDrawing("RECT", 100, 60);
+ var splitLines = new List { new SplitLine(30.0, CutOffAxis.Horizontal) };
+ var parameters = new SplitParameters { Type = SplitType.Straight };
+
+ var results = DrawingSplitter.Split(drawing, splitLines, parameters);
+
+ Assert.Equal(2, results.Count);
+ Assert.Equal("RECT-1", results[0].Name);
+ Assert.Equal("RECT-2", results[1].Name);
+ }
+
+ [Fact]
+ public void Split_ThreePieces_NamesSequentially()
+ {
+ var drawing = MakeRectangleDrawing("PART", 150, 50);
+ var splitLines = new List
+ {
+ new SplitLine(50.0, CutOffAxis.Vertical),
+ new SplitLine(100.0, CutOffAxis.Vertical)
+ };
+ var parameters = new SplitParameters { Type = SplitType.Straight };
+
+ var results = DrawingSplitter.Split(drawing, splitLines, parameters);
+
+ Assert.Equal(3, results.Count);
+ Assert.Equal("PART-1", results[0].Name);
+ Assert.Equal("PART-2", results[1].Name);
+ Assert.Equal("PART-3", results[2].Name);
+ }
+
+ [Fact]
+ public void Split_CopiesDrawingProperties()
+ {
+ var drawing = MakeRectangleDrawing("PART", 100, 50);
+ drawing.Color = System.Drawing.Color.Red;
+ drawing.Priority = 5;
+
+ var results = DrawingSplitter.Split(drawing,
+ new List { new SplitLine(50.0, CutOffAxis.Vertical) },
+ new SplitParameters());
+
+ Assert.All(results, d =>
+ {
+ Assert.Equal(System.Drawing.Color.Red, d.Color);
+ Assert.Equal(5, d.Priority);
+ });
+ }
+
+ [Fact]
+ public void Split_PiecesNormalizedToOrigin()
+ {
+ var drawing = MakeRectangleDrawing("PART", 100, 50);
+ var results = DrawingSplitter.Split(drawing,
+ new List { new SplitLine(50.0, CutOffAxis.Vertical) },
+ new SplitParameters());
+
+ // Each piece's program bounding box should start near (0,0)
+ foreach (var d in results)
+ {
+ var bb = d.Program.BoundingBox();
+ Assert.True(bb.X < 1.0, $"Piece {d.Name} not normalized: X={bb.X}");
+ Assert.True(bb.Y < 1.0, $"Piece {d.Name} not normalized: Y={bb.Y}");
+ }
+ }
+
+ [Fact]
+ public void Split_WithCutout_AssignsCutoutToCorrectPiece()
+ {
+ // Rectangle 100x50 with a small square cutout at (20,20)-(30,30)
+ 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 cutoutEntities = new List
+ {
+ new Line(new Vector(20, 20), new Vector(30, 20)),
+ new Line(new Vector(30, 20), new Vector(30, 30)),
+ new Line(new Vector(30, 30), new Vector(20, 30)),
+ new Line(new Vector(20, 30), new Vector(20, 20))
+ };
+ var allEntities = new List();
+ allEntities.AddRange(perimeterEntities);
+ allEntities.AddRange(cutoutEntities);
+
+ var pgm = ConvertGeometry.ToProgram(allEntities);
+ var drawing = new Drawing("HOLE", pgm);
+
+ // Split at X=50 — cutout is in the left half
+ var results = DrawingSplitter.Split(drawing,
+ new List { new SplitLine(50.0, CutOffAxis.Vertical) },
+ new SplitParameters());
+
+ Assert.Equal(2, results.Count);
+ // Left piece should have smaller area (has the cutout)
+ Assert.True(results[0].Area < results[1].Area,
+ "Left piece should have less area due to cutout");
+ }
+
+ [Fact]
+ public void Split_GridSplit_ProducesFourPieces()
+ {
+ var drawing = MakeRectangleDrawing("GRID", 100, 100);
+ var splitLines = new List
+ {
+ new SplitLine(50.0, CutOffAxis.Vertical),
+ new SplitLine(50.0, CutOffAxis.Horizontal)
+ };
+ var results = DrawingSplitter.Split(drawing, splitLines, new SplitParameters());
+
+ Assert.Equal(4, results.Count);
+ Assert.Equal("GRID-1", results[0].Name);
+ Assert.Equal("GRID-2", results[1].Name);
+ Assert.Equal("GRID-3", results[2].Name);
+ Assert.Equal("GRID-4", results[3].Name);
+ }
+}