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)