diff --git a/OpenNest.Core/Splitting/DrawingSplitter.cs b/OpenNest.Core/Splitting/DrawingSplitter.cs index 0a9785b..0488b53 100644 --- a/OpenNest.Core/Splitting/DrawingSplitter.cs +++ b/OpenNest.Core/Splitting/DrawingSplitter.cs @@ -15,20 +15,12 @@ public static class DrawingSplitter 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() + var profile = BuildProfile(drawing); 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) @@ -37,64 +29,25 @@ public static class DrawingSplitter 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); - } - } + var cutoutEntities = CollectCutouts(profile.Cutouts, region, sortedLines); - // 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; - + var piece = BuildPieceDrawing(drawing, allEntities, pieceIndex); results.Add(piece); pieceIndex++; } @@ -102,6 +55,52 @@ public static class DrawingSplitter 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) + { + 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; + return piece; + } + private static void DecomposeCircles(ShapeProfile profile) { DecomposeCirclesInShape(profile.Perimeter); @@ -155,138 +154,248 @@ public static class DrawingSplitter } /// - /// Clip perimeter to a region using Clipper2, then recover original arcs and stitch in feature edges. + /// 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) { - var perimPoly = perimeter.ToPolygonWithTolerance(0.01); + var boundarySplitLines = GetBoundarySplitLines(region, splitLines); + var entities = new List(); + var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>(); - 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(); + foreach (var entity in perimeter.Entities) + { + ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints); + } - // 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) + if (entities.Count == 0) return new List(); - var clippedPoly = NoFitPolygon.FromClipperPath(result[0]); - var clippedEntities = new List(); + InsertFeatureEdges(entities, splitPoints, region, boundarySplitLines, feature, parameters); + EnsurePerimeterWinding(entities); + return entities; + } - var verts = clippedPoly.Vertices; - for (var i = 0; i < verts.Count - 1; i++) + private static void ProcessEntity(Entity entity, Box region, + List boundarySplitLines, List entities, + List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints) + { + // Find the first boundary split line this entity crosses + SplitLine crossedLine = null; + Vector? intersectionPt = null; + + foreach (var sl in boundarySplitLines) { - 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) + if (SplitLineIntersect.CrossesSplitLine(entity, sl)) { - 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 pt = SplitLineIntersect.FindIntersection(entity, sl); + if (pt != null) { - 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(); - } + crossedLine = sl; + intersectionPt = pt; + break; } - - 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(); + if (crossedLine != null) + { + // Entity crosses a split line — split it and keep the half inside the region + var regionSide = RegionSideOf(region, crossedLine); + var startPt = GetStartPoint(entity); + var startSide = SplitLineIntersect.SideOf(startPt, crossedLine); + var startInRegion = startSide == regionSide || startSide == 0; - return shape.Entities; + SplitEntityAtPoint(entity, intersectionPt.Value, startInRegion, crossedLine, entities, splitPoints); + } + else + { + // Entity doesn't cross any boundary split line — check if it's inside the region + var mid = MidPoint(entity); + if (region.Contains(mid)) + entities.Add(entity); + } } - private static SplitLine FindSplitLineForEdge(Vector start, Vector end, List splitLines) + private static void SplitEntityAtPoint(Entity entity, Vector point, bool startInRegion, + SplitLine crossedLine, List entities, + List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints) { + if (entity is Line line) + { + var (first, second) = line.SplitAt(point); + if (startInRegion) + { + if (first != null) entities.Add(first); + splitPoints.Add((point, crossedLine, true)); + } + else + { + splitPoints.Add((point, crossedLine, false)); + if (second != null) entities.Add(second); + } + } + else if (entity is Arc arc) + { + var (first, second) = arc.SplitAt(point); + if (startInRegion) + { + if (first != null) entities.Add(first); + splitPoints.Add((point, crossedLine, true)); + } + else + { + splitPoints.Add((point, crossedLine, false)); + if (second != null) entities.Add(second); + } + } + } + + /// + /// 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(start.X - sl.Position) < 0.1 && System.Math.Abs(end.X - sl.Position) < 0.1) - return sl; + 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(start.Y - sl.Position) < 0.1 && System.Math.Abs(end.Y - sl.Position) < 0.1) - return sl; + 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 null; + return result; } /// - /// Search original perimeter for an arc whose circle matches this polygon chord edge. - /// Returns a new arc segment between the chord endpoints if found. + /// Returns -1 or +1 indicating which side of the split line the region center is on. /// - private static Arc FindMatchingArc(Vector start, Vector end, Shape perimeter) + private static int RegionSideOf(Box region, SplitLine sl) { - 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; + return SplitLineIntersect.SideOf(region.Center, sl); + } - 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); - } + /// + /// 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); + } + + /// + /// Groups split points by split line, pairs exits with entries, and generates feature edges. + /// + private static void InsertFeatureEdges(List entities, + List<(Vector Point, SplitLine Line, bool IsExit)> splitPoints, + Box region, List boundarySplitLines, + ISplitFeature feature, SplitParameters parameters) + { + // Group split points by their split line + var groups = new Dictionary>(); + foreach (var sp in splitPoints) + { + if (!groups.ContainsKey(sp.Line)) + groups[sp.Line] = new List<(Vector, bool)>(); + groups[sp.Line].Add((sp.Point, sp.IsExit)); + } + + foreach (var kvp in groups) + { + var sl = kvp.Key; + var points = kvp.Value; + + // Pair each exit with the next entry + var exits = points.Where(p => p.IsExit).Select(p => p.Point).ToList(); + var entries = points.Where(p => !p.IsExit).Select(p => p.Point).ToList(); + + if (exits.Count == 0 || entries.Count == 0) + continue; + + // For each exit, find the matching entry to form the feature edge span + // Sort exits and entries by their position along the split line + var isVertical = sl.Axis == CutOffAxis.Vertical; + exits = exits.OrderBy(p => isVertical ? p.Y : p.X).ToList(); + entries = entries.OrderBy(p => isVertical ? p.Y : p.X).ToList(); + + // Pair them up: each exit with the next entry (or vice versa) + var pairCount = System.Math.Min(exits.Count, entries.Count); + for (var i = 0; i < pairCount; i++) + { + var exitPt = exits[i]; + var entryPt = entries[i]; + + var extentStart = isVertical + ? System.Math.Min(exitPt.Y, entryPt.Y) + : System.Math.Min(exitPt.X, entryPt.X); + var extentEnd = isVertical + ? System.Math.Max(exitPt.Y, entryPt.Y) + : System.Math.Max(exitPt.X, entryPt.X); + + var featureResult = feature.GenerateFeatures(sl, extentStart, extentEnd, parameters); + + var isNegativeSide = RegionSideOf(region, sl) < 0; + var featureEdge = isNegativeSide ? featureResult.NegativeSideEdge : featureResult.PositiveSideEdge; + + if (featureEdge.Count > 0) + featureEdge = AlignFeatureDirection(featureEdge, exitPt, entryPt, sl.Axis); + + entities.AddRange(featureEdge); } } - return null; + } + + private static List AlignFeatureDirection(List featureEdge, Vector start, Vector end, CutOffAxis axis) + { + var featureStart = GetStartPoint(featureEdge[0]); + var featureEnd = GetEndPoint(featureEdge[^1]); + var isVertical = axis == CutOffAxis.Vertical; + + var edgeGoesForward = isVertical ? start.Y < end.Y : start.X < end.X; + var featureGoesForward = isVertical ? featureStart.Y < featureEnd.Y : featureStart.X < featureEnd.X; + + if (edgeGoesForward != featureGoesForward) + { + featureEdge = new List(featureEdge); + featureEdge.Reverse(); + foreach (var e in featureEdge) + e.Reverse(); + } + + return featureEdge; + } + + private static void EnsurePerimeterWinding(List entities) + { + var shape = new Shape(); + shape.Entities.AddRange(entities); + var poly = shape.ToPolygon(); + if (poly != null && poly.RotationDirection() != RotationType.CW) + shape.Reverse(); + + entities.Clear(); + entities.AddRange(shape.Entities); } private static bool IsCutoutInRegion(Shape cutout, Box region) @@ -309,32 +418,53 @@ public static class DrawingSplitter return false; } - private static List ClipCutoutToRegion(Shape cutout, Box region) + /// + /// Clip a cutout shape to a region by walking entities, splitting at split line + /// intersections, keeping portions inside the region, and closing gaps with + /// straight lines. No polygon clipping library needed. + /// + private static List ClipCutoutToRegion(Shape cutout, Box region, List splitLines) { - 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 boundarySplitLines = GetBoundarySplitLines(region, splitLines); + var entities = new List(); + var splitPoints = new List<(Vector Point, SplitLine Line, bool IsExit)>(); - 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); + foreach (var entity in cutout.Entities) + { + ProcessEntity(entity, region, boundarySplitLines, entities, splitPoints); + } - if (result.Count == 0) + if (entities.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])); + // Close gaps with straight lines (connect exit→entry pairs) + var groups = new Dictionary>(); + foreach (var sp in splitPoints) + { + if (!groups.ContainsKey(sp.Line)) + groups[sp.Line] = new List<(Vector, bool)>(); + groups[sp.Line].Add((sp.Point, sp.IsExit)); + } + + foreach (var kvp in groups) + { + var sl = kvp.Key; + var points = kvp.Value; + var isVertical = sl.Axis == CutOffAxis.Vertical; + + var exits = points.Where(p => p.IsExit).Select(p => p.Point) + .OrderBy(p => isVertical ? p.Y : p.X).ToList(); + var entries = points.Where(p => !p.IsExit).Select(p => p.Point) + .OrderBy(p => isVertical ? p.Y : p.X).ToList(); + + var pairCount = System.Math.Min(exits.Count, entries.Count); + for (var i = 0; i < pairCount; i++) + entities.Add(new Line(exits[i], entries[i])); + } // Ensure CCW winding for cutouts var shape = new Shape(); - shape.Entities.AddRange(lineEntities); + shape.Entities.AddRange(entities); var poly = shape.ToPolygon(); if (poly != null && poly.RotationDirection() != RotationType.CCW) shape.Reverse(); @@ -347,7 +477,7 @@ public static class DrawingSplitter return entity switch { Line l => l.StartPoint, - Arc a => a.StartPoint(), // Arc.StartPoint() is a method + Arc a => a.StartPoint(), _ => new Vector(0, 0) }; } @@ -357,7 +487,7 @@ public static class DrawingSplitter return entity switch { Line l => l.EndPoint, - Arc a => a.EndPoint(), // Arc.EndPoint() is a method + Arc a => a.EndPoint(), _ => new Vector(0, 0) }; } diff --git a/OpenNest.Core/Splitting/SpikeGrooveSplit.cs b/OpenNest.Core/Splitting/SpikeGrooveSplit.cs index 47d58c8..ba173fe 100644 --- a/OpenNest.Core/Splitting/SpikeGrooveSplit.cs +++ b/OpenNest.Core/Splitting/SpikeGrooveSplit.cs @@ -7,6 +7,7 @@ namespace OpenNest; /// Generates interlocking spike/V-groove pairs along the split edge. /// Spikes protrude from the positive side into the negative side. /// V-grooves on the negative side receive the spikes for self-alignment during welding. +/// The weld gap (grooveDepth - spikeDepth) is the clearance at the tip when assembled. /// public class SpikeGrooveSplit : ISplitFeature { @@ -18,7 +19,6 @@ public class SpikeGrooveSplit : ISplitFeature var pairCount = parameters.SpikePairCount; var spikeDepth = parameters.SpikeDepth; var grooveDepth = parameters.GrooveDepth; - var weldGap = parameters.SpikeWeldGap; var angleRad = OpenNest.Math.Angle.ToRadians(parameters.SpikeAngle / 2); var spikeHalfWidth = spikeDepth * System.Math.Tan(angleRad); var grooveHalfWidth = grooveDepth * System.Math.Tan(angleRad); @@ -44,10 +44,8 @@ public class SpikeGrooveSplit : ISplitFeature pairPositions.Add(extentStart + margin + usable * i / (pairCount - 1)); } - // Groove side: V-groove cut deeper than the spike so the spike fits inside var negEntities = BuildGrooveSide(pairPositions, grooveHalfWidth, grooveDepth, extentStart, extentEnd, pos, isVertical); - // Spike side: spike protrudes but stops short of the split line by weldGap - var posEntities = BuildSpikeSide(pairPositions, spikeHalfWidth, spikeDepth, weldGap, extentStart, extentEnd, pos, isVertical); + var posEntities = BuildSpikeSide(pairPositions, spikeHalfWidth, spikeDepth, extentStart, extentEnd, pos, isVertical); return new SplitFeatureResult(negEntities, posEntities); } @@ -79,10 +77,8 @@ public class SpikeGrooveSplit : ISplitFeature } private static List BuildSpikeSide(List pairPositions, double halfWidth, double depth, - double weldGap, double extentStart, double extentEnd, double pos, bool isVertical) + double extentStart, double extentEnd, double pos, bool isVertical) { - // Spike tip stops short of the split line by weldGap - var tipDepth = depth - weldGap; var entities = new List(); var cursor = extentEnd; @@ -95,8 +91,8 @@ public class SpikeGrooveSplit : ISplitFeature if (cursor > spikeEnd + OpenNest.Math.Tolerance.Epsilon) entities.Add(MakeLine(pos, cursor, pos, spikeEnd, isVertical)); - entities.Add(MakeLine(pos, spikeEnd, pos - tipDepth, center, isVertical)); - entities.Add(MakeLine(pos - tipDepth, center, pos, spikeStart, isVertical)); + entities.Add(MakeLine(pos, spikeEnd, pos - depth, center, isVertical)); + entities.Add(MakeLine(pos - depth, center, pos, spikeStart, isVertical)); cursor = spikeStart; } diff --git a/OpenNest.Tests/Splitting/SplitIntegrationTest.cs b/OpenNest.Tests/Splitting/SplitIntegrationTest.cs new file mode 100644 index 0000000..55632af --- /dev/null +++ b/OpenNest.Tests/Splitting/SplitIntegrationTest.cs @@ -0,0 +1,127 @@ +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Splitting; + +public class SplitIntegrationTest +{ + [Fact] + public void Split_SpikeGroove_NoContinuityGaps() + { + // Create a rectangle + var entities = 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 pgm = ConvertGeometry.ToProgram(entities); + var drawing = new Drawing("TEST", pgm); + + var sl = new SplitLine(50.0, CutOffAxis.Vertical); + sl.FeaturePositions.Add(12.5); + sl.FeaturePositions.Add(37.5); + + var parameters = new SplitParameters + { + Type = SplitType.SpikeGroove, + GrooveDepth = 0.625, + SpikeDepth = 0.75, + SpikeWeldGap = 0.125, + SpikeAngle = 45, + SpikePairCount = 2 + }; + + var results = DrawingSplitter.Split(drawing, new List { sl }, parameters); + Assert.Equal(2, results.Count); + + foreach (var piece in results) + { + // Get cut entities only (no rapids) + var pieceEntities = ConvertProgram.ToGeometry(piece.Program) + .Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + + // Check that consecutive entity endpoints connect (no gaps) + for (var i = 0; i < pieceEntities.Count - 1; i++) + { + var end = GetEndPoint(pieceEntities[i]); + var start = GetStartPoint(pieceEntities[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}"); + } + + // Area should be non-zero + Assert.True(piece.Area > 0, $"{piece.Name} has zero area"); + } + } + + [Fact] + public void Split_SpikeGroove_Horizontal_NoContinuityGaps() + { + var entities = 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 pgm = ConvertGeometry.ToProgram(entities); + var drawing = new Drawing("TEST", pgm); + + var sl = new SplitLine(25.0, CutOffAxis.Horizontal); + sl.FeaturePositions.Add(25.0); + sl.FeaturePositions.Add(75.0); + + var parameters = new SplitParameters + { + Type = SplitType.SpikeGroove, + GrooveDepth = 0.625, + SpikeDepth = 0.75, + SpikeWeldGap = 0.125, + SpikeAngle = 45, + SpikePairCount = 2 + }; + + var results = DrawingSplitter.Split(drawing, new List { sl }, parameters); + Assert.Equal(2, results.Count); + + foreach (var piece in results) + { + var pieceEntities = ConvertProgram.ToGeometry(piece.Program) + .Where(e => e.Layer != SpecialLayers.Rapid).ToList(); + + for (var i = 0; i < pieceEntities.Count - 1; i++) + { + var end = GetEndPoint(pieceEntities[i]); + var start = GetStartPoint(pieceEntities[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}"); + } + + Assert.True(piece.Area > 0, $"{piece.Name} has zero area"); + } + } + + 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) + }; + } +} diff --git a/OpenNest/Forms/SplitDrawingForm.Designer.cs b/OpenNest/Forms/SplitDrawingForm.Designer.cs index 856e8cf..5e9110c 100644 --- a/OpenNest/Forms/SplitDrawingForm.Designer.cs +++ b/OpenNest/Forms/SplitDrawingForm.Designer.cs @@ -41,8 +41,6 @@ namespace OpenNest.Forms lblGrooveDepth = new System.Windows.Forms.Label(); nudSpikeAngle = new System.Windows.Forms.NumericUpDown(); lblSpikeAngle = new System.Windows.Forms.Label(); - nudSpikeDepth = new System.Windows.Forms.NumericUpDown(); - lblSpikeDepth = new System.Windows.Forms.Label(); grpTabParams = new System.Windows.Forms.GroupBox(); nudTabCount = new System.Windows.Forms.NumericUpDown(); lblTabCount = new System.Windows.Forms.Label(); @@ -85,7 +83,6 @@ namespace OpenNest.Forms ((System.ComponentModel.ISupportInitialize)nudSpikeWeldGap).BeginInit(); ((System.ComponentModel.ISupportInitialize)nudGrooveDepth).BeginInit(); ((System.ComponentModel.ISupportInitialize)nudSpikeAngle).BeginInit(); - ((System.ComponentModel.ISupportInitialize)nudSpikeDepth).BeginInit(); grpTabParams.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)nudTabCount).BeginInit(); ((System.ComponentModel.ISupportInitialize)nudTabHeight).BeginInit(); @@ -162,12 +159,10 @@ namespace OpenNest.Forms grpSpikeParams.Controls.Add(lblGrooveDepth); grpSpikeParams.Controls.Add(nudSpikeAngle); grpSpikeParams.Controls.Add(lblSpikeAngle); - grpSpikeParams.Controls.Add(nudSpikeDepth); - grpSpikeParams.Controls.Add(lblSpikeDepth); grpSpikeParams.Dock = System.Windows.Forms.DockStyle.Top; grpSpikeParams.Location = new System.Drawing.Point(6, 511); grpSpikeParams.Name = "grpSpikeParams"; - grpSpikeParams.Size = new System.Drawing.Size(191, 159); + grpSpikeParams.Size = new System.Drawing.Size(191, 132); grpSpikeParams.TabIndex = 5; grpSpikeParams.TabStop = false; grpSpikeParams.Text = "Spike Parameters"; @@ -175,7 +170,7 @@ namespace OpenNest.Forms // // nudSpikePairCount // - nudSpikePairCount.Location = new System.Drawing.Point(110, 128); + nudSpikePairCount.Location = new System.Drawing.Point(110, 101); nudSpikePairCount.Maximum = new decimal(new int[] { 50, 0, 0, 0 }); nudSpikePairCount.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); nudSpikePairCount.Name = "nudSpikePairCount"; @@ -187,7 +182,7 @@ namespace OpenNest.Forms // lblSpikePairCount // lblSpikePairCount.AutoSize = true; - lblSpikePairCount.Location = new System.Drawing.Point(10, 130); + lblSpikePairCount.Location = new System.Drawing.Point(10, 103); lblSpikePairCount.Name = "lblSpikePairCount"; lblSpikePairCount.Size = new System.Drawing.Size(66, 15); lblSpikePairCount.TabIndex = 5; @@ -196,7 +191,7 @@ namespace OpenNest.Forms // nudSpikeWeldGap // nudSpikeWeldGap.DecimalPlaces = 3; - nudSpikeWeldGap.Location = new System.Drawing.Point(110, 74); + nudSpikeWeldGap.Location = new System.Drawing.Point(110, 47); nudSpikeWeldGap.Maximum = new decimal(new int[] { 10, 0, 0, 0 }); nudSpikeWeldGap.Name = "nudSpikeWeldGap"; nudSpikeWeldGap.Size = new System.Drawing.Size(88, 23); @@ -207,7 +202,7 @@ namespace OpenNest.Forms // lblSpikeWeldGap // lblSpikeWeldGap.AutoSize = true; - lblSpikeWeldGap.Location = new System.Drawing.Point(10, 76); + lblSpikeWeldGap.Location = new System.Drawing.Point(10, 49); lblSpikeWeldGap.Name = "lblSpikeWeldGap"; lblSpikeWeldGap.Size = new System.Drawing.Size(61, 15); lblSpikeWeldGap.TabIndex = 6; @@ -216,18 +211,18 @@ namespace OpenNest.Forms // nudGrooveDepth // nudGrooveDepth.DecimalPlaces = 3; - nudGrooveDepth.Location = new System.Drawing.Point(110, 47); + nudGrooveDepth.Location = new System.Drawing.Point(110, 20); nudGrooveDepth.Minimum = new decimal(new int[] { 1, 0, 0, 131072 }); nudGrooveDepth.Name = "nudGrooveDepth"; nudGrooveDepth.Size = new System.Drawing.Size(88, 23); nudGrooveDepth.TabIndex = 1; - nudGrooveDepth.Value = new decimal(new int[] { 125, 0, 0, 196608 }); + nudGrooveDepth.Value = new decimal(new int[] { 625, 0, 0, 196608 }); nudGrooveDepth.ValueChanged += OnSpikeParamChanged; // // lblGrooveDepth // lblGrooveDepth.AutoSize = true; - lblGrooveDepth.Location = new System.Drawing.Point(10, 49); + lblGrooveDepth.Location = new System.Drawing.Point(10, 22); lblGrooveDepth.Name = "lblGrooveDepth"; lblGrooveDepth.Size = new System.Drawing.Size(83, 15); lblGrooveDepth.TabIndex = 7; @@ -236,7 +231,7 @@ namespace OpenNest.Forms // nudSpikeAngle // nudSpikeAngle.DecimalPlaces = 1; - nudSpikeAngle.Location = new System.Drawing.Point(110, 101); + nudSpikeAngle.Location = new System.Drawing.Point(110, 74); nudSpikeAngle.Maximum = new decimal(new int[] { 89, 0, 0, 0 }); nudSpikeAngle.Minimum = new decimal(new int[] { 10, 0, 0, 0 }); nudSpikeAngle.Name = "nudSpikeAngle"; @@ -247,32 +242,12 @@ namespace OpenNest.Forms // lblSpikeAngle // lblSpikeAngle.AutoSize = true; - lblSpikeAngle.Location = new System.Drawing.Point(10, 103); + lblSpikeAngle.Location = new System.Drawing.Point(10, 76); lblSpikeAngle.Name = "lblSpikeAngle"; lblSpikeAngle.Size = new System.Drawing.Size(72, 15); lblSpikeAngle.TabIndex = 8; lblSpikeAngle.Text = "Spike Angle:"; // - // nudSpikeDepth - // - nudSpikeDepth.DecimalPlaces = 2; - nudSpikeDepth.Enabled = false; - nudSpikeDepth.Location = new System.Drawing.Point(110, 20); - nudSpikeDepth.Minimum = new decimal(new int[] { 1, 0, 0, 131072 }); - nudSpikeDepth.Name = "nudSpikeDepth"; - nudSpikeDepth.Size = new System.Drawing.Size(88, 23); - nudSpikeDepth.TabIndex = 0; - nudSpikeDepth.Value = new decimal(new int[] { 25, 0, 0, 131072 }); - // - // lblSpikeDepth - // - lblSpikeDepth.AutoSize = true; - lblSpikeDepth.Location = new System.Drawing.Point(10, 22); - lblSpikeDepth.Name = "lblSpikeDepth"; - lblSpikeDepth.Size = new System.Drawing.Size(73, 15); - lblSpikeDepth.TabIndex = 9; - lblSpikeDepth.Text = "Spike Depth:"; - // // grpTabParams // grpTabParams.Controls.Add(nudTabCount); @@ -674,7 +649,6 @@ namespace OpenNest.Forms ((System.ComponentModel.ISupportInitialize)nudSpikeWeldGap).EndInit(); ((System.ComponentModel.ISupportInitialize)nudGrooveDepth).EndInit(); ((System.ComponentModel.ISupportInitialize)nudSpikeAngle).EndInit(); - ((System.ComponentModel.ISupportInitialize)nudSpikeDepth).EndInit(); grpTabParams.ResumeLayout(false); grpTabParams.PerformLayout(); ((System.ComponentModel.ISupportInitialize)nudTabCount).EndInit(); @@ -747,8 +721,6 @@ namespace OpenNest.Forms private System.Windows.Forms.NumericUpDown nudTabCount; private System.Windows.Forms.GroupBox grpSpikeParams; - private System.Windows.Forms.Label lblSpikeDepth; - private System.Windows.Forms.NumericUpDown nudSpikeDepth; private System.Windows.Forms.Label lblSpikeAngle; private System.Windows.Forms.NumericUpDown nudSpikeAngle; private System.Windows.Forms.Label lblSpikePairCount; diff --git a/OpenNest/Forms/SplitDrawingForm.cs b/OpenNest/Forms/SplitDrawingForm.cs index 51c8b6c..6f77aef 100644 --- a/OpenNest/Forms/SplitDrawingForm.cs +++ b/OpenNest/Forms/SplitDrawingForm.cs @@ -141,16 +141,8 @@ public partial class SplitDrawingForm : Form pnlPreview.Invalidate(); } - private void UpdateSpikeDepth() - { - var grooveDepth = (double)nudGrooveDepth.Value; - var weldGap = (double)nudSpikeWeldGap.Value; - nudSpikeDepth.Value = (decimal)(grooveDepth + weldGap); - } - private void OnSpikeParamChanged(object sender, EventArgs e) { - UpdateSpikeDepth(); if (radFitToPlate.Checked) RecalculateAutoSplitLines(); pnlPreview.Invalidate(); @@ -175,9 +167,9 @@ public partial class SplitDrawingForm : Form else if (radSpike.Checked) { p.Type = SplitType.SpikeGroove; - p.SpikeDepth = (double)nudSpikeDepth.Value; - p.GrooveDepth = p.SpikeDepth + (double)nudGrooveDepth.Value; + p.GrooveDepth = (double)nudGrooveDepth.Value; p.SpikeWeldGap = (double)nudSpikeWeldGap.Value; + p.SpikeDepth = p.GrooveDepth + p.SpikeWeldGap; p.SpikeAngle = (double)nudSpikeAngle.Value; p.SpikePairCount = (int)nudSpikePairCount.Value; } @@ -389,23 +381,72 @@ public partial class SplitDrawingForm : Form System.Math.Abs(br.X - tl.X), System.Math.Abs(br.Y - tl.Y)); } - // Split lines + // Split lines — trimmed at feature positions with feature contours + var parameters = GetCurrentParameters(); + var feature = GetSplitFeature(parameters.Type); using var splitPen = new Pen(Color.FromArgb(255, 82, 82)); splitPen.DashStyle = DashStyle.Dash; + using var featurePen = new Pen(Color.FromArgb(200, 255, 82, 82), 1.5f); + foreach (var sl in _splitLines) { - PointF p1, p2; - if (sl.Axis == CutOffAxis.Vertical) + GetExtent(sl, out var extStart, out var extEnd); + var isVert = sl.Axis == CutOffAxis.Vertical; + var margin = 10.0; + + if (sl.FeaturePositions.Count == 0 || radStraight.Checked) { - p1 = pnlPreview.PointWorldToGraph(sl.Position, _drawingBounds.Bottom - 10); - p2 = pnlPreview.PointWorldToGraph(sl.Position, _drawingBounds.Top + 10); + // No features — draw one continuous line + var p1 = isVert + ? pnlPreview.PointWorldToGraph(sl.Position, extStart - margin) + : pnlPreview.PointWorldToGraph(extStart - margin, sl.Position); + var p2 = isVert + ? pnlPreview.PointWorldToGraph(sl.Position, extEnd + margin) + : pnlPreview.PointWorldToGraph(extEnd + margin, sl.Position); + g.DrawLine(splitPen, p1, p2); } else { - p1 = pnlPreview.PointWorldToGraph(_drawingBounds.Left - 10, sl.Position); - p2 = pnlPreview.PointWorldToGraph(_drawingBounds.Right + 10, sl.Position); + // Generate feature geometry and draw contours + var featureResult = feature.GenerateFeatures(sl, extStart, extEnd, parameters); + DrawFeatureEdge(g, featurePen, featureResult.NegativeSideEdge, isVert); + DrawFeatureEdge(g, featurePen, featureResult.PositiveSideEdge, isVert); + + // Draw split line in segments between features + var halfExt = GetFeatureHalfExtent(parameters); + var sorted = new List(sl.FeaturePositions); + sorted.Sort(); + + var cursor = extStart - margin; + foreach (var fc in sorted) + { + var gapStart = fc - halfExt; + if (gapStart > cursor) + { + var p1 = isVert + ? pnlPreview.PointWorldToGraph(sl.Position, cursor) + : pnlPreview.PointWorldToGraph(cursor, sl.Position); + var p2 = isVert + ? pnlPreview.PointWorldToGraph(sl.Position, gapStart) + : pnlPreview.PointWorldToGraph(gapStart, sl.Position); + g.DrawLine(splitPen, p1, p2); + } + cursor = fc + halfExt; + } + + // Final segment after last feature + var end = extEnd + margin; + if (end > cursor) + { + var p1 = isVert + ? pnlPreview.PointWorldToGraph(sl.Position, cursor) + : pnlPreview.PointWorldToGraph(cursor, sl.Position); + var p2 = isVert + ? pnlPreview.PointWorldToGraph(sl.Position, end) + : pnlPreview.PointWorldToGraph(end, sl.Position); + g.DrawLine(splitPen, p1, p2); + } } - g.DrawLine(splitPen, p1, p2); } // Feature position handles @@ -509,6 +550,41 @@ public partial class SplitDrawingForm : Form lblStatus.Text = $"Part: {_drawingBounds.Width:F2} x {_drawingBounds.Length:F2} | {_splitLines.Count} split lines | {pieceCount} pieces"; } + // --- Feature rendering helpers --- + + private static ISplitFeature GetSplitFeature(SplitType type) + { + return type switch + { + SplitType.WeldGapTabs => new WeldGapTabSplit(), + SplitType.SpikeGroove => new SpikeGrooveSplit(), + _ => new StraightSplit() + }; + } + + private static double GetFeatureHalfExtent(SplitParameters p) + { + return p.Type switch + { + SplitType.WeldGapTabs => p.TabWidth / 2, + SplitType.SpikeGroove => p.GrooveDepth * System.Math.Tan(OpenNest.Math.Angle.ToRadians(p.SpikeAngle / 2)), + _ => 0 + }; + } + + private void DrawFeatureEdge(Graphics g, Pen pen, List entities, bool isVertical) + { + foreach (var entity in entities) + { + if (entity is Geometry.Line line) + { + var p1 = pnlPreview.PointWorldToGraph(line.StartPoint.X, line.StartPoint.Y); + var p2 = pnlPreview.PointWorldToGraph(line.EndPoint.X, line.EndPoint.Y); + g.DrawLine(pen, p1, p2); + } + } + } + // --- SplitPreview control --- private class SplitPreview : EntityView