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();