diff --git a/OpenNest.Core/Converters/ConvertProgram.cs b/OpenNest.Core/Converters/ConvertProgram.cs index 76f805a..e59dde1 100644 --- a/OpenNest.Core/Converters/ConvertProgram.cs +++ b/OpenNest.Core/Converters/ConvertProgram.cs @@ -121,6 +121,8 @@ namespace OpenNest.Converters center += curpos; } + center = FitCenterToEndpoints(center, curpos, endpt); + var startAngle = center.AngleTo(curpos); var endAngle = center.AngleTo(endpt); @@ -157,6 +159,35 @@ namespace OpenNest.Converters curpos = endpt; } + /// + /// Programs can carry arc centers that are not quite equidistant from the + /// start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from + /// the end radius alone then leaves its start point off the previous move's + /// end, which breaks contour chaining. Project the center onto the chord's + /// perpendicular bisector so the arc passes through both endpoints exactly. + /// + private static Vector FitCenterToEndpoints(Vector center, Vector start, Vector end) + { + var startRadius = center.DistanceTo(start); + var endRadius = center.DistanceTo(end); + + if (startRadius.IsEqualTo(endRadius)) + return center; + + var chord = end - start; + var chordLengthSq = chord.X * chord.X + chord.Y * chord.Y; + + // Full circle (start == end): no chord to fit against. + if (chordLengthSq < Tolerance.Epsilon * Tolerance.Epsilon) + return center; + + var mid = new Vector((start.X + end.X) * 0.5, (start.Y + end.Y) * 0.5); + var normal = new Vector(-chord.Y, chord.X); + var t = ((center.X - mid.X) * normal.X + (center.Y - mid.Y) * normal.Y) / chordLengthSq; + + return new Vector(mid.X + normal.X * t, mid.Y + normal.Y * t); + } + private static Layer ConvertLayer(LayerType layer) { switch (layer) diff --git a/OpenNest.Tests/Converters/ConvertProgramArcTests.cs b/OpenNest.Tests/Converters/ConvertProgramArcTests.cs new file mode 100644 index 0000000..1b5259c --- /dev/null +++ b/OpenNest.Tests/Converters/ConvertProgramArcTests.cs @@ -0,0 +1,64 @@ +using System.Linq; +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Converters; + +public class ConvertProgramArcTests +{ + [Fact] + public void ArcWithCenterNotEquidistant_StartsAtPreviousEndpoint() + { + // PEP-exported notch: I0.03 on a 0.0598 chord puts the center 0.0300 from + // the start but 0.0298 from the end. + var pgm = new Program(Mode.Incremental); + pgm.Codes.Add(new RapidMove(0, 0)); + pgm.Codes.Add(new LinearMove(0, -0.3573)); + pgm.Codes.Add(new ArcMove(0.0598, 0, 0.03, 0, RotationType.CCW)); + pgm.Codes.Add(new LinearMove(0, 0.3573)); + + var arc = ConvertProgram.ToGeometry(pgm).OfType().Single(); + + Assert.True(arc.StartPoint().DistanceTo(new Vector(0, -0.3573)) < 1e-9); + Assert.True(arc.EndPoint().DistanceTo(new Vector(0.0598, -0.3573)) < 1e-9); + Assert.Equal(0.0299, arc.Radius, 9); + } + + [Fact] + public void ClosedContourWithInconsistentArc_ChainsIntoSinglePerimeter() + { + var pgm = new Program(Mode.Incremental); + pgm.Codes.Add(new RapidMove(0, 0)); + pgm.Codes.Add(new LinearMove(4, 0)); + pgm.Codes.Add(new LinearMove(0, 2)); + pgm.Codes.Add(new LinearMove(-1.9701, 0)); + pgm.Codes.Add(new LinearMove(0, -0.5)); + pgm.Codes.Add(new ArcMove(-0.0598, 0, -0.03, 0, RotationType.CW)); + pgm.Codes.Add(new LinearMove(0, 0.5)); + pgm.Codes.Add(new LinearMove(-1.9701, 0)); + pgm.Codes.Add(new LinearMove(0, -2)); + + var entities = ConvertProgram.ToGeometry(pgm) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + var profile = new ShapeProfile(entities); + + Assert.Empty(profile.Cutouts); + Assert.Equal(entities.Count, profile.Perimeter.Entities.Count); + } + + [Fact] + public void ConsistentArc_IsUnchanged() + { + var pgm = new Program(Mode.Incremental); + pgm.Codes.Add(new RapidMove(0, 0)); + pgm.Codes.Add(new ArcMove(2, 0, 1, 0, RotationType.CCW)); + + var arc = ConvertProgram.ToGeometry(pgm).OfType().Single(); + + Assert.Equal(1.0, arc.Center.X, 12); + Assert.Equal(0.0, arc.Center.Y, 12); + Assert.Equal(1.0, arc.Radius, 12); + } +} diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index f5571fa..51808f7 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -3,6 +3,7 @@ using System.Drawing; using System.Drawing.Drawing2D; using System.Linq; using System.Windows.Forms; +using Clipper2Lib; using OpenNest.Controls; using OpenNest.Converters; using OpenNest.Geometry; @@ -21,6 +22,8 @@ namespace OpenNest private Brush brush; private Pen pen; + private const int OffsetPrecision = 4; + private List _offsetPolygonPoints; private double _cachedOffsetSpacing; private double _cachedOffsetTolerance; @@ -223,41 +226,66 @@ namespace OpenNest private List ComputeOffsetPolygons(double spacing, double tolerance) { - var result = new List(); var entities = ConvertProgram.ToGeometry(BasePart.Program); var profile = new ShapeProfile( entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() ); - AddOffsetPolygon(result, profile.Perimeter.OffsetOutward(spacing), tolerance); + // Inflate the flattened part region (perimeter positive, holes negative) in + // one Clipper pass. Offsetting entity-by-entity leaves spikes and inverted + // loops wherever a feature is narrower than the spacing; Clipper collapses + // those features and drops holes that close up entirely. + var paths = new PathsD(); + AddRegionPath(paths, profile.Perimeter, tolerance, positive: true); foreach (var cutout in profile.Cutouts) - AddOffsetPolygon(result, cutout.OffsetInward(spacing), tolerance); + AddRegionPath(paths, cutout, tolerance, positive: false); + + var inflated = Clipper.InflatePaths( + paths, + spacing, + JoinType.Round, + EndType.Polygon, + 2.0, + OffsetPrecision, + tolerance + ); + + var result = new List(inflated.Count); + + foreach (var path in inflated) + { + if (path.Count < 3) + continue; + + var pts = new PointF[path.Count + 1]; + + for (var j = 0; j < path.Count; j++) + pts[j] = new PointF((float)path[j].x, (float)path[j].y); + + pts[path.Count] = pts[0]; + result.Add(pts); + } return result; } - private static void AddOffsetPolygon( - List result, - Shape offsetEntity, - double tolerance - ) + private static void AddRegionPath(PathsD paths, Shape shape, double tolerance, bool positive) { - if (offsetEntity == null) + var polygon = shape.ToPolygonWithTolerance(tolerance); + + if (polygon.Vertices.Count < 3) return; - var polygon = offsetEntity.ToPolygonWithTolerance(tolerance); - polygon.RemoveSelfIntersections(); + var path = new PathD(polygon.Vertices.Count); - if (polygon.Vertices.Count < 2) - return; + foreach (var v in polygon.Vertices) + path.Add(new PointD(v.X, v.Y)); - var pts = new PointF[polygon.Vertices.Count]; + if (Clipper.IsPositive(path) != positive) + path.Reverse(); - for (var j = 0; j < pts.Length; j++) - pts[j] = new PointF((float)polygon.Vertices[j].X, (float)polygon.Vertices[j].Y); - - result.Add(pts); + paths.Add(path); } private void RebuildOffsetPath(Matrix matrix)