diff --git a/OpenNest.Core/Geometry/ClipperBridge.cs b/OpenNest.Core/Geometry/ClipperBridge.cs
new file mode 100644
index 0000000..8e635f6
--- /dev/null
+++ b/OpenNest.Core/Geometry/ClipperBridge.cs
@@ -0,0 +1,210 @@
+using System.Collections.Generic;
+using Clipper2Lib;
+
+namespace OpenNest.Geometry
+{
+ ///
+ /// Region offsetting through Clipper2, for CPU-side preparation only: work done
+ /// once per drawing, rotation or spacing whose output is cached and fed to hot
+ /// loops. Per-pair tests () stay hand-rolled so they can
+ /// be ported to a GPU kernel.
+ ///
+ public static class ClipperBridge
+ {
+ ///
+ /// Decimal places Clipper keeps (1e-4 in either inches or mm).
+ ///
+ public const int Precision = 4;
+
+ private const double MiterLimit = 2.0;
+
+ ///
+ /// Converts a polygon to a Clipper path, dropping the closing vertex and
+ /// orienting it positive (CCW) or negative (CW).
+ ///
+ public static PathD ToPath(Polygon polygon, bool positive)
+ {
+ var path = ToPath(polygon, new Vector());
+
+ if (path.Count >= 3 && Clipper.IsPositive(path) != positive)
+ path.Reverse();
+
+ return path;
+ }
+
+ ///
+ /// Converts a polygon to a Clipper path with an optional offset, dropping the
+ /// closing vertex and keeping the polygon's own winding.
+ ///
+ public static PathD ToPath(Polygon polygon, Vector offset)
+ {
+ var verts = polygon.Vertices;
+ var n = verts.Count;
+
+ if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y)
+ n--;
+
+ var path = new PathD(n);
+
+ for (var i = 0; i < n; i++)
+ path.Add(new PointD(verts[i].X + offset.X, verts[i].Y + offset.Y));
+
+ return path;
+ }
+
+ ///
+ /// Converts a Clipper path to a closed polygon with updated bounds.
+ ///
+ public static Polygon ToPolygon(PathD path)
+ {
+ var polygon = new Polygon();
+
+ foreach (var pt in path)
+ polygon.Vertices.Add(new Vector(pt.x, pt.y));
+
+ polygon.Close();
+ polygon.UpdateBounds();
+ return polygon;
+ }
+
+ ///
+ /// Flattens a profile into a Clipper region: perimeter positive, cutouts negative.
+ ///
+ public static PathsD ToRegion(ShapeProfile profile, double tolerance, bool circumscribe)
+ {
+ var region = new PathsD(profile.Cutouts.Count + 1);
+ AddShape(region, profile.Perimeter, tolerance, circumscribe, positive: true);
+
+ // A cutout is flattened the opposite way: circumscribing it would shrink the
+ // material around it, so inscribe instead to keep the region conservative.
+ foreach (var cutout in profile.Cutouts)
+ AddShape(region, cutout, tolerance, !circumscribe, positive: false);
+
+ return region;
+ }
+
+ ///
+ /// Offsets a part region outward by : the perimeter
+ /// grows and the cutouts shrink. Features narrower than twice the distance
+ /// collapse, and cutouts that close up disappear. Joins are round, with chords
+ /// no more than from the true arc.
+ ///
+ ///
+ /// When true, the result never under-estimates the offset: arcs are flattened
+ /// outside the true curve and the inflation is padded by the chord tolerance
+ /// and Clipper's rounding.
+ ///
+ public static OffsetRegion Offset(
+ ShapeProfile profile,
+ double distance,
+ double tolerance,
+ bool circumscribe = false
+ )
+ {
+ var region = ToRegion(profile, tolerance, circumscribe);
+ return Offset(region, distance, tolerance, circumscribe);
+ }
+
+ ///
+ /// Offsets an already-flattened region (outers positive, holes negative).
+ ///
+ public static OffsetRegion Offset(
+ PathsD region,
+ double distance,
+ double tolerance,
+ bool circumscribe = false
+ )
+ {
+ var delta = distance;
+
+ if (circumscribe)
+ delta += tolerance + 0.5 * System.Math.Pow(10, -Precision);
+
+ var inflated =
+ delta == 0
+ ? Union(region)
+ : Clipper.InflatePaths(
+ region,
+ delta,
+ JoinType.Round,
+ EndType.Polygon,
+ MiterLimit,
+ Precision,
+ tolerance
+ );
+
+ var result = new OffsetRegion(new List(), new List());
+
+ foreach (var path in inflated)
+ {
+ if (path.Count < 3)
+ continue;
+
+ if (Clipper.IsPositive(path))
+ result.Outers.Add(ToPolygon(path));
+ else
+ result.Holes.Add(ToPolygon(path));
+ }
+
+ return result;
+ }
+
+ private static PathsD Union(PathsD region)
+ {
+ var clipper = new ClipperD(Precision);
+ clipper.AddSubject(region);
+
+ var solution = new PathsD();
+ clipper.Execute(ClipType.Union, FillRule.NonZero, solution);
+ return solution;
+ }
+
+ private static void AddShape(
+ PathsD region,
+ Shape shape,
+ double tolerance,
+ bool circumscribe,
+ bool positive
+ )
+ {
+ var polygon = shape.ToPolygonWithTolerance(tolerance, circumscribe);
+
+ if (polygon.Vertices.Count < 4)
+ return;
+
+ var path = ToPath(polygon, positive);
+
+ if (path.Count >= 3)
+ region.Add(path);
+ }
+ }
+
+ ///
+ /// Result of :
+ /// outer boundaries (CCW) and holes (CW), as closed polygons.
+ ///
+ public sealed record OffsetRegion(List Outers, List Holes)
+ {
+ ///
+ /// The outer boundary with the largest area, or null when the region is empty.
+ ///
+ public Polygon LargestOuter()
+ {
+ Polygon best = null;
+ var bestArea = 0.0;
+
+ foreach (var outer in Outers)
+ {
+ var area = outer.Area();
+
+ if (best == null || area > bestArea)
+ {
+ best = outer;
+ bestArea = area;
+ }
+ }
+
+ return best;
+ }
+ }
+}
diff --git a/OpenNest.Tests/Geometry/ClipperBridgeTests.cs b/OpenNest.Tests/Geometry/ClipperBridgeTests.cs
new file mode 100644
index 0000000..55e2f22
--- /dev/null
+++ b/OpenNest.Tests/Geometry/ClipperBridgeTests.cs
@@ -0,0 +1,281 @@
+using System.IO;
+using System.Linq;
+using System.Text;
+using OpenNest.CNC;
+using OpenNest.Converters;
+using OpenNest.Geometry;
+using OpenNest.IO;
+
+namespace OpenNest.Tests.Geometry;
+
+public class ClipperBridgeTests
+{
+ [Fact]
+ public void Offset_NotchNarrowerThanTwiceSpacing_ClosesNotch()
+ {
+ // 10x10 square with a 0.3-wide, 3-deep slot down from the top edge.
+ var profile = Profile(
+ Poly(
+ (0, 0),
+ (10, 0),
+ (10, 10),
+ (5.15, 10),
+ (5.15, 7),
+ (4.85, 7),
+ (4.85, 10),
+ (0, 10)
+ )
+ );
+
+ var result = ClipperBridge.Offset(profile, 0.25, 0.001);
+
+ var outer = Assert.Single(result.Outers);
+ Assert.Empty(result.Holes);
+
+ // The slot fills in. Only a shallow dent is left where the round joins of the
+ // two mouth corners meet: 10 + sqrt(0.25^2 - 0.15^2) = 10.2.
+ Assert.DoesNotContain(outer.Vertices, v => v.X > 4.85 && v.X < 5.15 && v.Y < 10.199);
+
+ var fullSquare = 10 * 10 + 4 * 10 * 0.25 + System.Math.PI * 0.25 * 0.25;
+ Assert.InRange(outer.Area(), fullSquare - 0.01, fullSquare);
+ }
+
+ [Fact]
+ public void Offset_HoleSmallerThanTwiceSpacing_DropsHole()
+ {
+ var profile = Profile(Poly((0, 0), (10, 0), (10, 10), (0, 10)), Circle(5, 5, 0.2));
+
+ var result = ClipperBridge.Offset(profile, 0.25, 0.001);
+
+ Assert.Single(result.Outers);
+ Assert.Empty(result.Holes);
+ }
+
+ [Fact]
+ public void Offset_HoleWithThinNeck_SplitsIntoTwoHoles()
+ {
+ // Two 2x2 pockets joined by a 2-long, 0.3-wide channel.
+ var hole = Poly(
+ (2, 4),
+ (4, 4),
+ (4, 4.85),
+ (6, 4.85),
+ (6, 4),
+ (8, 4),
+ (8, 6),
+ (6, 6),
+ (6, 5.15),
+ (4, 5.15),
+ (4, 6),
+ (2, 6)
+ );
+ var profile = Profile(Poly((0, 0), (10, 0), (10, 10), (0, 10)), hole);
+
+ var result = ClipperBridge.Offset(profile, 0.25, 0.001);
+
+ Assert.Single(result.Outers);
+ Assert.Equal(2, result.Holes.Count);
+
+ // Each pocket shrinks to 1.5x1.5, plus a small lobe toward the channel mouth
+ // where the round joins of the channel corners meet.
+ Assert.All(result.Holes, h => Assert.InRange(h.Area(), 2.25, 2.26));
+ }
+
+ [Fact]
+ public void Offset_WindingOfInputDoesNotMatter()
+ {
+ var ccw = Poly((0, 0), (10, 0), (10, 10), (0, 10));
+ var cw = Poly((0, 0), (0, 10), (10, 10), (10, 0));
+ var hole = Circle(5, 5, 2);
+
+ var a = ClipperBridge.Offset(Profile(ccw, hole), 0.25, 0.001);
+ var b = ClipperBridge.Offset(Profile(cw, hole), 0.25, 0.001);
+
+ Assert.Equal(a.Outers.Count, b.Outers.Count);
+ Assert.Equal(a.Holes.Count, b.Holes.Count);
+ Assert.Equal(a.Outers[0].Area(), b.Outers[0].Area(), 6);
+ Assert.Equal(a.Holes[0].Area(), b.Holes[0].Area(), 6);
+ }
+
+ [Fact]
+ public void Offset_Circumscribe_NeverUnderestimatesDistance()
+ {
+ const double spacing = 0.25;
+ var profile = Profile(Circle(0, 0, 5), Circle(0, 0, 3));
+
+ var result = ClipperBridge.Offset(profile, spacing, 0.05, circumscribe: true);
+
+ var outer = Assert.Single(result.Outers);
+ var hole = Assert.Single(result.Holes);
+
+ for (var i = 0; i < 360; i++)
+ {
+ var a = i * System.Math.PI / 180;
+ var onPerimeter = new Vector(5 * System.Math.Cos(a), 5 * System.Math.Sin(a));
+ var onCutout = new Vector(3 * System.Math.Cos(a), 3 * System.Math.Sin(a));
+
+ Assert.True(outer.ContainsPoint(onPerimeter));
+ Assert.True(
+ outer.ClosestPointTo(onPerimeter).DistanceTo(onPerimeter) >= spacing,
+ $"Perimeter sample at {i} deg is closer than the spacing."
+ );
+
+ Assert.False(hole.ContainsPoint(onCutout));
+ Assert.True(
+ hole.ClosestPointTo(onCutout).DistanceTo(onCutout) >= spacing,
+ $"Cutout sample at {i} deg is closer than the spacing."
+ );
+ }
+ }
+
+ [Fact]
+ public void Offset_PepNotchedPart_HasNoSpikes()
+ {
+ // 1.nest (PEP P260417-06): rounded-square hole, perimeter with 0.0598-wide
+ // notches and 0.015 fillets, all narrower than twice the 0.25 spacing.
+ var program = ReadProgram(PepNotchedPart);
+ var entities = ConvertProgram.ToGeometry(program)
+ .Where(e => e.Layer != SpecialLayers.Rapid)
+ .ToList();
+
+ var result = ClipperBridge.Offset(new ShapeProfile(entities), 0.25, 0.001);
+
+ var outer = Assert.Single(result.Outers);
+ Assert.Single(result.Holes);
+
+ var verts = outer.Vertices;
+ var n = verts.Count - 1;
+
+ for (var i = 0; i < n; i++)
+ {
+ for (var j = i + 2; j < n; j++)
+ {
+ if (i == 0 && j == n - 1)
+ continue;
+
+ Assert.False(
+ SegmentsCross(verts[i], verts[i + 1], verts[j], verts[j + 1]),
+ $"Edges {i} and {j} cross."
+ );
+ }
+ }
+
+ for (var i = 0; i < n; i++)
+ {
+ var prev = verts[(i + n - 1) % n];
+ var cur = verts[i];
+ var next = verts[(i + 1) % n];
+
+ var inDir = Unit(cur - prev);
+ var outDir = Unit(next - cur);
+ var dot = inDir.X * outDir.X + inDir.Y * outDir.Y;
+
+ Assert.True(dot > -0.99, $"Spike at vertex {i} ({cur.X:F4}, {cur.Y:F4}).");
+ }
+ }
+
+ private static bool SegmentsCross(Vector a, Vector b, Vector c, Vector d)
+ {
+ static double Cross(Vector o, Vector p, Vector q) =>
+ (p.X - o.X) * (q.Y - o.Y) - (p.Y - o.Y) * (q.X - o.X);
+
+ return Cross(c, d, a) * Cross(c, d, b) < 0 && Cross(a, b, c) * Cross(a, b, d) < 0;
+ }
+
+ private static Vector Unit(Vector v)
+ {
+ var len = System.Math.Sqrt(v.X * v.X + v.Y * v.Y);
+ return new Vector(v.X / len, v.Y / len);
+ }
+
+ private static Shape Poly(params (double X, double Y)[] pts)
+ {
+ var shape = new Shape();
+
+ for (var i = 0; i < pts.Length; i++)
+ {
+ var a = pts[i];
+ var b = pts[(i + 1) % pts.Length];
+ shape.Entities.Add(new Line(a.X, a.Y, b.X, b.Y));
+ }
+
+ return shape;
+ }
+
+ private static Shape Circle(double x, double y, double r)
+ {
+ var shape = new Shape();
+ shape.Entities.Add(new Circle(x, y, r));
+ return shape;
+ }
+
+ private static ShapeProfile Profile(params Shape[] shapes) =>
+ new(shapes.SelectMany(s => s.Entities).ToList());
+
+ private static Program ReadProgram(string gcode)
+ {
+ using var stream = new MemoryStream(Encoding.UTF8.GetBytes(gcode));
+ return new ProgramReader(stream).Read();
+ }
+
+ private const string PepNotchedPart = """
+ G91
+ G00X-8.003411Y12.354904
+ G01X0Y5.03125
+ G03X-2.3125Y2.3125I-2.3125J0
+ G01X-10.0625Y0
+ G03X-2.3125Y-2.3125I0J-2.3125
+ G01X0Y-10.0625
+ G03X2.3125Y-2.3125I2.3125J0
+ G01X10.0625Y0
+ G03X2.3125Y2.3125I0J2.3125
+ G01X0Y5.03125
+ G00X10.200865Y-12.347161
+ G01X-2.182454Y0
+ G03X-0.015Y-0.015I0J-0.015
+ G01X0Y-1.457646
+ G02X-0.015Y-0.015I-0.015J0
+ G01X-30.664322Y0
+ G02X-0.015Y0.015I0J0.015
+ G01X0Y1.1725
+ G01X0.072967Y0.149903
+ G02X0.013487Y0.008435I0.013487J-0.006565
+ G01X0.620707Y0
+ G03X0.015Y0.015I0J0.015
+ G01X0Y0.396469
+ G03X-0.015Y0.015I-0.015J0
+ G01X-0.4225Y0
+ G01X0Y0.095339
+ G01X-2.419615Y0
+ G02X-0.0625Y0.0625I0J0.0625
+ G01X0Y23.809322
+ G02X0.0625Y0.0625I0.0625J0
+ G01X2.405015Y0
+ G03X0.015Y0.015I0J0.015
+ G01X0Y1.837647
+ G02X0.015Y0.015I0.015J0
+ G01X4.005139Y0
+ G02X0.015Y-0.015I0J-0.015
+ G01X0Y-0.3573
+ G03X0.0598Y0I0.03J0
+ G01X0Y0.974934
+ G02X0.0625Y0.0625I0.0625J0
+ G01X25.420246Y0
+ G02X0.0625Y-0.0625I0J-0.0625
+ G01X0Y-0.974934
+ G03X0.0598Y0I0.03J0
+ G01X0Y0.3573
+ G02X0.015Y0.015I0.015J0
+ G01X0.679276Y0
+ G02X0.015Y-0.015I0J-0.015
+ G01X0Y-1.457647
+ G03X0.015Y-0.015I0.015J0
+ G01X1.145147Y0
+ G02X0.015Y-0.015I0J-0.015
+ G01X0Y-0.709988
+ G03X0.015Y-0.015I0.015J0
+ G01X0.944807Y0
+ G02X0.0625Y-0.0625I0J-0.0625
+ G01X0Y-23.891834
+ """;
+}
diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs
index 51808f7..a5d9c09 100644
--- a/OpenNest/LayoutPart.cs
+++ b/OpenNest/LayoutPart.cs
@@ -3,7 +3,6 @@ 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;
@@ -22,8 +21,6 @@ namespace OpenNest
private Brush brush;
private Pen pen;
- private const int OffsetPrecision = 4;
-
private List _offsetPolygonPoints;
private double _cachedOffsetSpacing;
private double _cachedOffsetTolerance;
@@ -231,63 +228,22 @@ namespace OpenNest
entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()
);
- // 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);
+ var offset = ClipperBridge.Offset(profile, spacing, tolerance);
+ var result = new List(offset.Outers.Count + offset.Holes.Count);
- foreach (var cutout in profile.Cutouts)
- 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)
+ foreach (var polygon in offset.Outers.Concat(offset.Holes))
{
- if (path.Count < 3)
- continue;
+ var pts = new PointF[polygon.Vertices.Count];
- var pts = new PointF[path.Count + 1];
+ for (var j = 0; j < pts.Length; j++)
+ pts[j] = new PointF((float)polygon.Vertices[j].X, (float)polygon.Vertices[j].Y);
- 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 AddRegionPath(PathsD paths, Shape shape, double tolerance, bool positive)
- {
- var polygon = shape.ToPolygonWithTolerance(tolerance);
-
- if (polygon.Vertices.Count < 3)
- return;
-
- var path = new PathD(polygon.Vertices.Count);
-
- foreach (var v in polygon.Vertices)
- path.Add(new PointD(v.X, v.Y));
-
- if (Clipper.IsPositive(path) != positive)
- path.Reverse();
-
- paths.Add(path);
- }
-
private void RebuildOffsetPath(Matrix matrix)
{
OffsetPath?.Dispose();