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 }; var profile = BuildProfile(drawing); DecomposeCircles(profile); var perimeter = profile.Perimeter; var bounds = perimeter.BoundingBox; var sortedLines = splitLines .Where(l => IsLineInsideBounds(l, bounds)) .OrderBy(l => l.Position) .ToList(); if (sortedLines.Count == 0) return new List { drawing }; var regions = BuildClipRegions(sortedLines, bounds); var feature = GetFeature(parameters.Type); // Polygonize cutouts once. Used for trimming feature edges (so cut lines // don't travel through a cutout interior) and for hole/containment tests // in the final component-assembly pass. var cutoutPolygons = profile.Cutouts .Select(c => c.ToPolygon()) .Where(p => p != null) .ToList(); var results = new List(); var pieceIndex = 1; foreach (var region in regions) { var pieceEntities = ClipPerimeterToRegion(perimeter, region, sortedLines, feature, parameters, cutoutPolygons); if (pieceEntities.Count == 0) continue; var cutoutEntities = CollectCutouts(profile.Cutouts, region, sortedLines); var allEntities = new List(); allEntities.AddRange(pieceEntities); allEntities.AddRange(cutoutEntities); // A single region may yield multiple physically-disjoint pieces when an // interior cutout spans across it. Group the region's entities into // connected closed loops, nest holes by containment, and emit one // Drawing per outer loop (with its contained holes). foreach (var pieceOfRegion in AssemblePieces(allEntities)) { var piece = BuildPieceDrawing(drawing, pieceOfRegion, pieceIndex, region); results.Add(piece); pieceIndex++; } } return results; } private static ShapeProfile BuildProfile(Drawing drawing) { var entities = ConvertProgram.ToGeometry(drawing.Program) .Where(e => e.Layer != SpecialLayers.Rapid) .ToList(); return new ShapeProfile(entities); } private static List CollectCutouts(List cutouts, Box region, List splitLines) { var entities = new List(); foreach (var cutout in cutouts) { if (IsCutoutInRegion(cutout, region)) entities.AddRange(cutout.Entities); else if (DoesCutoutCrossSplitLine(cutout, splitLines)) { var clipped = ClipCutoutToRegion(cutout, region, splitLines); if (clipped.Count > 0) entities.AddRange(clipped); } } return entities; } private static Drawing BuildPieceDrawing(Drawing source, List entities, int pieceIndex, Box region) { var pieceBounds = entities.Select(e => e.BoundingBox).ToList().GetBoundingBox(); var offsetX = -pieceBounds.X; var offsetY = -pieceBounds.Y; foreach (var e in entities) e.Offset(offsetX, offsetY); var pgm = ConvertGeometry.ToProgram(entities); var piece = new Drawing($"{source.Name}-{pieceIndex}", pgm); piece.Color = source.Color; piece.Priority = source.Priority; piece.Material = source.Material; piece.Constraints = source.Constraints; piece.Customer = source.Customer; piece.Source = source.Source; piece.Quantity.Required = source.Quantity.Required; if (source.Bends != null && source.Bends.Count > 0) { piece.Bends = new List(); foreach (var bend in source.Bends) { var clipped = ClipLineToBox(bend.StartPoint, bend.EndPoint, region); if (clipped == null) continue; piece.Bends.Add(new Bending.Bend { StartPoint = new Vector(clipped.Value.Start.X + offsetX, clipped.Value.Start.Y + offsetY), EndPoint = new Vector(clipped.Value.End.X + offsetX, clipped.Value.End.Y + offsetY), Direction = bend.Direction, Angle = bend.Angle, Radius = bend.Radius, NoteText = bend.NoteText, }); } } return piece; } /// /// Clips a line segment to an axis-aligned box using Liang-Barsky algorithm. /// Returns the clipped start/end or null if the line is entirely outside. /// private static (Vector Start, Vector End)? ClipLineToBox(Vector start, Vector end, Box box) { var dx = end.X - start.X; var dy = end.Y - start.Y; double t0 = 0, t1 = 1; double[] p = { -dx, dx, -dy, dy }; double[] q = { start.X - box.Left, box.Right - start.X, start.Y - box.Bottom, box.Top - start.Y }; for (var i = 0; i < 4; i++) { if (System.Math.Abs(p[i]) < Math.Tolerance.Epsilon) { if (q[i] < -Math.Tolerance.Epsilon) return null; } else { var t = q[i] / p[i]; if (p[i] < 0) t0 = System.Math.Max(t0, t); else t1 = System.Math.Min(t1, t); if (t0 > t1) return null; } } var clippedStart = new Vector(start.X + t0 * dx, start.Y + t0 * dy); var clippedEnd = new Vector(start.X + t1 * dx, start.Y + t1 * dy); return (clippedStart, clippedEnd); } 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 by walking entities, splitting at split line crossings, /// and stitching in feature edges. No polygon clipping library needed. /// private static List ClipPerimeterToRegion(Shape perimeter, Box region, List splitLines, ISplitFeature feature, SplitParameters parameters, List cutoutPolygons) { var boundarySplitLines = GetBoundarySplitLines(region, splitLines); var entities = new List(); foreach (var entity in perimeter.Entities) ProcessEntity(entity, region, entities); if (entities.Count == 0) return new List(); InsertFeatureEdges(entities, region, boundarySplitLines, feature, parameters, cutoutPolygons); // Winding is handled later in AssemblePieces, once connected components // are known. At this stage the piece may still be multiple disjoint loops. return entities; } private static void ProcessEntity(Entity entity, Box region, List entities) { if (entity is Line line) { var clipped = ClipLineToBox(line.StartPoint, line.EndPoint, region); if (clipped == null) return; if (clipped.Value.Start.DistanceTo(clipped.Value.End) < Math.Tolerance.Epsilon) return; entities.Add(new Line(clipped.Value.Start, clipped.Value.End)); return; } if (entity is Arc arc) { foreach (var sub in ClipArcToRegion(arc, region)) entities.Add(sub); return; } } /// /// Clips an arc against the four edges of a region box. Returns the sub-arcs /// whose midpoints lie inside the region. Uses line-arc intersection to find /// split points, then iteratively bisects the arc at each crossing. /// private static List ClipArcToRegion(Arc arc, Box region) { var edges = new[] { new Line(new Vector(region.Left, region.Bottom), new Vector(region.Right, region.Bottom)), new Line(new Vector(region.Right, region.Bottom), new Vector(region.Right, region.Top)), new Line(new Vector(region.Right, region.Top), new Vector(region.Left, region.Top)), new Line(new Vector(region.Left, region.Top), new Vector(region.Left, region.Bottom)) }; var arcs = new List { arc }; foreach (var edge in edges) { var next = new List(); foreach (var a in arcs) { if (!Intersect.Intersects(a, edge, out var pts) || pts.Count == 0) { next.Add(a); continue; } // Split the arc at each intersection that actually lies on one of // the working sub-arcs. Prior splits may make some original hits // moot for the sub-arc that now holds them. var working = new List { a }; foreach (var pt in pts) { var replaced = new List(); foreach (var w in working) { var onArc = OpenNest.Math.Angle.IsBetweenRad( w.Center.AngleTo(pt), w.StartAngle, w.EndAngle, w.IsReversed); if (!onArc) { replaced.Add(w); continue; } var (first, second) = w.SplitAt(pt); if (first != null && first.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(first); if (second != null && second.SweepAngle() > Math.Tolerance.Epsilon) replaced.Add(second); } working = replaced; } next.AddRange(working); } arcs = next; } var result = new List(); foreach (var a in arcs) { if (region.Contains(a.MidPoint())) result.Add(a); } return result; } /// /// Returns split lines whose position matches a boundary edge of the region. /// private static List GetBoundarySplitLines(Box region, List splitLines) { var result = new List(); foreach (var sl in splitLines) { if (sl.Axis == CutOffAxis.Vertical) { if (System.Math.Abs(sl.Position - region.Left) < OpenNest.Math.Tolerance.Epsilon || System.Math.Abs(sl.Position - region.Right) < OpenNest.Math.Tolerance.Epsilon) result.Add(sl); } else { if (System.Math.Abs(sl.Position - region.Bottom) < OpenNest.Math.Tolerance.Epsilon || System.Math.Abs(sl.Position - region.Top) < OpenNest.Math.Tolerance.Epsilon) result.Add(sl); } } return result; } /// /// Returns -1 or +1 indicating which side of the split line the region center is on. /// private static int RegionSideOf(Box region, SplitLine sl) { return SplitLineIntersect.SideOf(region.Center, sl); } /// /// Returns the midpoint of an entity. For lines: average of endpoints. /// For arcs: point at the mid-angle. /// private static Vector MidPoint(Entity entity) { if (entity is Line line) return line.MidPoint; if (entity is Arc arc) { var midAngle = (arc.StartAngle + arc.EndAngle) / 2; return new Vector( arc.Center.X + arc.Radius * System.Math.Cos(midAngle), arc.Center.Y + arc.Radius * System.Math.Sin(midAngle)); } return new Vector(0, 0); } /// /// For each boundary split line of the region, generates a feature edge that /// spans the full region boundary along that split line and trims it against /// interior cutouts. This produces one (or zero) feature edge per contiguous /// material interval on the boundary, handling corner regions (one perimeter /// crossing), spanning cutouts (two holes puncturing the line), and /// normal mid-part splits uniformly. /// private static void InsertFeatureEdges(List entities, Box region, List boundarySplitLines, ISplitFeature feature, SplitParameters parameters, List cutoutPolygons) { foreach (var sl in boundarySplitLines) { var isVertical = sl.Axis == CutOffAxis.Vertical; var extentStart = isVertical ? region.Bottom : region.Left; var extentEnd = isVertical ? region.Top : region.Right; if (extentEnd - extentStart < Math.Tolerance.Epsilon) continue; var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters); var isNegativeSide = RegionSideOf(region, sl) < 0; var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge; // Trim any line segments that cross a cutout — cut lines must never // travel through a hole. featureEdge = TrimFeatureEdgeAgainstCutouts(featureEdge, cutoutPolygons); entities.AddRange(featureEdge); } } /// /// Subtracts any portions of line entities in that /// lie inside any of the supplied cutout polygons. Non-line entities (arcs) are /// passed through unchanged; a tighter fix for arcs in feature edges (weld-gap /// tabs, spike-groove) can be added later if a test demands it. /// private static List TrimFeatureEdgeAgainstCutouts(List featureEdge, List cutoutPolygons) { if (cutoutPolygons.Count == 0 || featureEdge.Count == 0) return featureEdge; var result = new List(); foreach (var entity in featureEdge) { if (entity is Line line) result.AddRange(SubtractCutoutsFromLine(line, cutoutPolygons)); else result.Add(entity); } return result; } /// /// Returns the sub-segments of that lie outside every /// cutout polygon. Handles the common axis-aligned feature-edge case exactly. /// private static List SubtractCutoutsFromLine(Line line, List cutoutPolygons) { // Collect parameter values t in [0,1] where the line crosses any cutout edge. var ts = new List { 0.0, 1.0 }; foreach (var poly in cutoutPolygons) { var polyLines = poly.ToLines(); foreach (var edge in polyLines) { if (TryIntersectSegments(line.StartPoint, line.EndPoint, edge.StartPoint, edge.EndPoint, out var t)) { if (t > Math.Tolerance.Epsilon && t < 1.0 - Math.Tolerance.Epsilon) ts.Add(t); } } } ts.Sort(); var segments = new List(); for (var i = 0; i < ts.Count - 1; i++) { var t0 = ts[i]; var t1 = ts[i + 1]; if (t1 - t0 < Math.Tolerance.Epsilon) continue; var tMid = (t0 + t1) * 0.5; var mid = new Vector( line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * tMid, line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * tMid); var insideCutout = false; foreach (var poly in cutoutPolygons) { if (poly.ContainsPoint(mid)) { insideCutout = true; break; } } if (insideCutout) continue; var p0 = new Vector( line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t0, line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t0); var p1 = new Vector( line.StartPoint.X + (line.EndPoint.X - line.StartPoint.X) * t1, line.StartPoint.Y + (line.EndPoint.Y - line.StartPoint.Y) * t1); segments.Add(new Line(p0, p1)); } return segments; } /// /// Segment-segment intersection. On hit, returns the parameter t along segment AB /// (0 = a0, 1 = a1) via . /// private static bool TryIntersectSegments(Vector a0, Vector a1, Vector b0, Vector b1, out double tOnA) { tOnA = 0; var rx = a1.X - a0.X; var ry = a1.Y - a0.Y; var sx = b1.X - b0.X; var sy = b1.Y - b0.Y; var denom = rx * sy - ry * sx; if (System.Math.Abs(denom) < Math.Tolerance.Epsilon) return false; var dx = b0.X - a0.X; var dy = b0.Y - a0.Y; var t = (dx * sy - dy * sx) / denom; var u = (dx * ry - dy * rx) / denom; if (t < -Math.Tolerance.Epsilon || t > 1 + Math.Tolerance.Epsilon) return false; if (u < -Math.Tolerance.Epsilon || u > 1 + Math.Tolerance.Epsilon) return false; tOnA = t; return true; } private static bool IsCutoutInRegion(Shape cutout, Box region) { if (cutout.Entities.Count == 0) return false; var bb = cutout.BoundingBox; // Fully contained iff the cutout's bounding box fits inside the region. return bb.Left >= region.Left - Math.Tolerance.Epsilon && bb.Right <= region.Right + Math.Tolerance.Epsilon && bb.Bottom >= region.Bottom - Math.Tolerance.Epsilon && bb.Top <= region.Top + Math.Tolerance.Epsilon; } 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; } /// /// Clip a cutout shape to a region by walking entities and splitting at split-line /// crossings. Only returns the cutout-edge fragments that lie inside the region — /// it deliberately does NOT emit synthetic closing lines at the region boundary. /// /// Rationale: a closing line on the region boundary would overlap the split-line /// feature edge and reintroduce a cut through the cutout interior. The feature /// edge (trimmed against cutouts in ) and these /// cutout fragments are stitched together later by /// using endpoint connectivity, which produces the correct closed loops — one /// loop per physically-connected strip of material. /// private static List ClipCutoutToRegion(Shape cutout, Box region, List splitLines) { var entities = new List(); foreach (var entity in cutout.Entities) ProcessEntity(entity, region, entities); return entities; } /// /// Groups a region's entities into closed components and nests holes inside /// outer loops by point-in-polygon containment. Returns one entity list per /// output — outer loop first, then its contained holes. /// Each outer loop is normalized to CW winding and each hole to CCW. /// private static List> AssemblePieces(List entities) { var pieces = new List>(); if (entities.Count == 0) return pieces; var shapes = ShapeBuilder.GetShapes(entities); if (shapes.Count == 0) return pieces; // Polygonize every shape once so we can run containment tests. var polygons = new List(shapes.Count); foreach (var s in shapes) polygons.Add(s.ToPolygon()); // Classify each shape as outer or hole using nesting by containment. // Shape A is contained in shape B iff A's bounding box is strictly inside // B's bounding box AND a representative vertex of A lies inside B's polygon. // The bbox pre-check avoids the ambiguity of bbox-center tests when two // shapes share a center (e.g., an outer half and a centered cutout). var isHole = new bool[shapes.Count]; for (var i = 0; i < shapes.Count; i++) { var bbA = shapes[i].BoundingBox; var repA = FirstVertexOf(shapes[i]); for (var j = 0; j < shapes.Count; j++) { if (i == j) continue; if (polygons[j] == null) continue; if (polygons[j].Vertices.Count < 3) continue; var bbB = shapes[j].BoundingBox; if (!BoxContainsBox(bbB, bbA)) continue; if (!polygons[j].ContainsPoint(repA)) continue; isHole[i] = true; break; } } // For each outer, attach the holes that fall inside it. for (var i = 0; i < shapes.Count; i++) { if (isHole[i]) continue; var outer = shapes[i]; var outerPoly = polygons[i]; // Enforce perimeter winding = CW. if (outerPoly != null && outerPoly.Vertices.Count >= 3 && outerPoly.RotationDirection() != RotationType.CW) outer.Reverse(); var piece = new List(); piece.AddRange(outer.Entities); for (var j = 0; j < shapes.Count; j++) { if (!isHole[j]) continue; if (polygons[i] == null || polygons[i].Vertices.Count < 3) continue; var bbJ = shapes[j].BoundingBox; if (!BoxContainsBox(shapes[i].BoundingBox, bbJ)) continue; var rep = FirstVertexOf(shapes[j]); if (!polygons[i].ContainsPoint(rep)) continue; var hole = shapes[j]; var holePoly = polygons[j]; if (holePoly != null && holePoly.Vertices.Count >= 3 && holePoly.RotationDirection() != RotationType.CCW) hole.Reverse(); piece.AddRange(hole.Entities); } pieces.Add(piece); } return pieces; } /// /// Returns the first vertex of a shape (start point of its first entity). Used as /// a representative for containment testing: if bbox pre-check says the whole /// shape is inside another, testing one vertex is sufficient to confirm. /// private static Vector FirstVertexOf(Shape shape) { if (shape.Entities.Count == 0) return new Vector(0, 0); return GetStartPoint(shape.Entities[0]); } /// /// True iff box is entirely inside box /// (tolerant comparison). /// private static bool BoxContainsBox(Box outer, Box inner) { var eps = Math.Tolerance.Epsilon; return inner.Left >= outer.Left - eps && inner.Right <= outer.Right + eps && inner.Bottom >= outer.Bottom - eps && inner.Top <= outer.Top + eps; } 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) }; } private static ISplitFeature GetFeature(SplitType type) { return type switch { SplitType.Straight => new StraightSplit(), SplitType.WeldGapTabs => new WeldGapTabSplit(), SplitType.SpikeGroove => new SpikeGrooveSplit(), _ => new StraightSplit() }; } }