Files
OpenNest/OpenNest.Core/Splitting/DrawingSplitter.cs
AJ Isaacs 4acd8b8bad feat: add DrawingSplitter core split pipeline
Implements the main drawing splitting algorithm that orchestrates splitting
a Drawing into multiple pieces along split lines using Clipper2 polygon
clipping. After clipping, recovers original arcs by matching clipped edges
back to perimeter entities, stitches in feature edges from ISplitFeature
where polygon edges lie on split lines, and normalizes each piece's origin.

Key fix from plan: filters rapid-layer entities before ShapeProfile
construction so cutouts are properly separated from perimeters.

Includes 7 integration tests covering vertical/horizontal splits, three-way
splits, property copying, origin normalization, cutout assignment, and
grid (cross) splits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:13:37 -04:00

376 lines
14 KiB
C#

using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest;
/// <summary>
/// Splits a Drawing into multiple pieces along split lines with optional feature geometry.
/// </summary>
public static class DrawingSplitter
{
public static List<Drawing> Split(Drawing drawing, List<SplitLine> splitLines, SplitParameters parameters)
{
if (splitLines.Count == 0)
return new List<Drawing> { 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> { 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<Drawing>();
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<Entity>();
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<Entity>();
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<Box> BuildClipRegions(List<SplitLine> 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<double> { bounds.Left };
xEdges.AddRange(verticals.Select(v => v.Position));
xEdges.Add(bounds.Right);
var yEdges = new List<double> { bounds.Bottom };
yEdges.AddRange(horizontals.Select(h => h.Position));
yEdges.Add(bounds.Top);
var regions = new List<Box>();
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;
}
/// <summary>
/// Clip perimeter to a region using Clipper2, then recover original arcs and stitch in feature edges.
/// </summary>
private static List<Entity> ClipPerimeterToRegion(Shape perimeter, Box region,
List<SplitLine> 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<Entity>();
var clippedPoly = NoFitPolygon.FromClipperPath(result[0]);
var clippedEntities = new List<Entity>();
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<Entity>(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<SplitLine> 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;
}
/// <summary>
/// Search original perimeter for an arc whose circle matches this polygon chord edge.
/// Returns a new arc segment between the chord endpoints if found.
/// </summary>
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<SplitLine> 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<Entity> 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<Entity>();
var clippedPoly = NoFitPolygon.FromClipperPath(result[0]);
var lineEntities = new List<Entity>();
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()
};
}
}