From 7964c87eb98c19478c697f6a7c19d5fd71912e74 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 08:56:15 -0400 Subject: [PATCH 01/10] fix(geometry): fit arc centers to endpoints; Clipper offset for spacing display PEP-exported programs carry arc centers that are not equidistant from the start and end points (e.g. I0.03 on a 0.0598 chord). Building the arc from the end radius left its start off the previous move's end, so contours failed to chain. Project the center onto the chord's perpendicular bisector. The Draw Offset display offset each entity separately, which left spikes and inverted loops wherever a feature is narrower than the spacing (1.nest, P260417-06). Inflate the flattened region with Clipper instead, which collapses narrow features and drops holes that close up. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Converters/ConvertProgram.cs | 31 +++++++++ .../Converters/ConvertProgramArcTests.cs | 64 +++++++++++++++++++ OpenNest/LayoutPart.cs | 64 +++++++++++++------ 3 files changed, 141 insertions(+), 18 deletions(-) create mode 100644 OpenNest.Tests/Converters/ConvertProgramArcTests.cs 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) From a6bc9d8be6d31514e422d48294fb3110f9014671 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 09:12:34 -0400 Subject: [PATCH 02/10] feat(geometry): add ClipperBridge for region offsetting Offsetting entity by entity leaves spikes and inverted loops wherever a feature is narrower than the spacing, and RemoveSelfIntersections only catches proper crossings. ClipperBridge flattens a ShapeProfile into one region (perimeter positive, cutouts negative) and inflates it in a single Clipper pass with round joins, so narrow features collapse and holes that close up disappear. Conservative mode circumscribes perimeter arcs, inscribes cutout arcs and pads the inflation by the chord tolerance, so the result never under-estimates the spacing. It replaces the circumscribed-polygon guarantee the BestFit/PartBoundary callers rely on. Clipper stays confined to CPU preparation whose output is cached; the per-pair Collision path remains hand-rolled for GPU portability. LayoutPart's display offset now goes through the bridge. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/ClipperBridge.cs | 210 +++++++++++++ OpenNest.Tests/Geometry/ClipperBridgeTests.cs | 281 ++++++++++++++++++ OpenNest/LayoutPart.cs | 56 +--- 3 files changed, 497 insertions(+), 50 deletions(-) create mode 100644 OpenNest.Core/Geometry/ClipperBridge.cs create mode 100644 OpenNest.Tests/Geometry/ClipperBridgeTests.cs 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(); From 12f97474b7b54205deddadde131f53ea879548fe Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 09:13:59 -0400 Subject: [PATCH 03/10] refactor(geometry): remove dead concave NFP path NoFitPolygon.Compute, its triangulate-and-union MinkowskiSum branch and UnionPolygons had no callers; only ComputeConvex (NfpSlideStrategy) is used. The Clipper path helpers they relied on now live in ClipperBridge. ConvexDecomposition.Triangulate stays because Collision uses it. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/NoFitPolygon.cs | 129 +------------------------ 1 file changed, 2 insertions(+), 127 deletions(-) diff --git a/OpenNest.Core/Geometry/NoFitPolygon.cs b/OpenNest.Core/Geometry/NoFitPolygon.cs index d519f12..aebdad8 100644 --- a/OpenNest.Core/Geometry/NoFitPolygon.cs +++ b/OpenNest.Core/Geometry/NoFitPolygon.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using Clipper2Lib; using OpenNest.Math; namespace OpenNest.Geometry @@ -11,21 +10,9 @@ namespace OpenNest.Geometry /// public static class NoFitPolygon { - private const double ClipperScale = 1000.0; - /// - /// Computes the NFP between a stationary polygon A and an orbiting polygon B. - /// NFP(A, B) = Minkowski sum of A and -B (B reflected through its reference point). - /// - public static Polygon Compute(Polygon stationary, Polygon orbiting) - { - var reflected = Reflect(orbiting); - return MinkowskiSum(stationary, reflected); - } - - /// - /// Optimized version of Compute for polygons known to be convex. - /// Bypasses expensive triangulation and Clipper unions. + /// Computes the NFP between a convex stationary polygon A and a convex orbiting + /// polygon B: the Minkowski sum of A and -B (B reflected through its reference point). /// public static Polygon ComputeConvex(Polygon stationary, Polygon orbiting) { @@ -48,42 +35,6 @@ namespace OpenNest.Geometry return result; } - /// - /// Computes the Minkowski sum of two polygons using convex decomposition. - /// For convex polygons, uses the direct O(n+m) merge-sort of edge vectors. - /// For concave polygons, decomposes into triangles, computes pairwise - /// convex Minkowski sums, and unions the results with Clipper2. - /// - private static Polygon MinkowskiSum(Polygon a, Polygon b) - { - var trisA = ConvexDecomposition.Triangulate(a); - var trisB = ConvexDecomposition.Triangulate(b); - - if (trisA.Count == 0 || trisB.Count == 0) - return new Polygon(); - - var partialSums = new List(); - - foreach (var ta in trisA) - { - foreach (var tb in trisB) - { - var sum = ConvexMinkowskiSum(ta, tb); - - if (sum.Vertices.Count >= 3) - partialSums.Add(sum); - } - } - - if (partialSums.Count == 0) - return new Polygon(); - - if (partialSums.Count == 1) - return partialSums[0]; - - return UnionPolygons(partialSums); - } - /// /// Computes the Minkowski sum of two convex polygons by merging their /// edge vectors sorted by angle. O(n+m) where n and m are vertex counts. @@ -230,81 +181,5 @@ namespace OpenNest.Geometry return result; } - - /// - /// Unions multiple polygons using Clipper2. - /// Returns the outer boundary of the union as a single polygon. - /// - internal static Polygon UnionPolygons(List polygons) - { - var paths = new PathsD(); - - foreach (var poly in polygons) - { - var path = ToClipperPath(poly); - - if (path.Count >= 3) - paths.Add(path); - } - - if (paths.Count == 0) - return new Polygon(); - - var result = Clipper.Union(paths, FillRule.NonZero); - - if (result.Count == 0) - return new Polygon(); - - // Find the largest polygon (by area) as the outer boundary. - var largest = result[0]; - var largestArea = System.Math.Abs(Clipper.Area(largest)); - - for (var i = 1; i < result.Count; i++) - { - var area = System.Math.Abs(Clipper.Area(result[i])); - - if (area > largestArea) - { - largest = result[i]; - largestArea = area; - } - } - - return FromClipperPath(largest); - } - - /// - /// Converts an OpenNest Polygon to a Clipper2 PathD, with an optional offset. - /// - public static PathD ToClipperPath(Polygon polygon, Vector offset = default) - { - var path = new PathD(); - var verts = polygon.Vertices; - var n = verts.Count; - - // Skip closing vertex if present. - if (n > 1 && verts[0].X == verts[n - 1].X && verts[0].Y == verts[n - 1].Y) - 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 Clipper2 PathD to an OpenNest Polygon. - /// - public static Polygon FromClipperPath(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; - } } } From 9b9386b0298d916186a2208a24ccc8e450127990 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 09:21:48 -0400 Subject: [PATCH 04/10] refactor(geometry): move polygon offset callers onto ClipperBridge Per-entity offsetting left spikes and inverted loops wherever a feature is narrower than the spacing (1.nest), and RemoveSelfIntersections only caught proper crossings. The callers that already flatten to polygons now take a single Clipper region offset instead: - PolygonHelper (BestFit) and PartBoundary use the conservative mode, which keeps their never-under-estimate guarantee. PartBoundary also keeps holes that appear when a perimeter curls back on itself. - NestValidator offsets perimeter and cutouts in one region; Clipper drops collapsed cutouts, so the collapsed-or-flipped heuristic goes away. - CutOff.IntersectPerimeter offsets through the bridge. The old OffsetEntity(Left) grew CW perimeters but shrank CCW ones, so with the plate's perimeter cache a cut-off ran through the part; slots narrower than twice the clearance now close up instead of leaving a gap. - GetOffsetPartLines (3 overloads) and the AddOffset* helpers had no callers and are removed, as is EntityView's never-defined DRAW_OFFSET block. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Benchmark/NestValidator.cs | 73 ++++------- OpenNest.Core/CutOff.cs | 51 ++++++-- OpenNest.Core/Geometry/ClipperBridge.cs | 61 +++++++-- OpenNest.Core/PartGeometry.cs | 155 +---------------------- OpenNest.Engine/BestFit/PolygonHelper.cs | 17 ++- OpenNest.Engine/Fill/PartBoundary.cs | 23 ++-- OpenNest.Tests/CutOffs/CutOffTests.cs | 43 +++++++ OpenNest/Controls/EntityView.cs | 11 -- 8 files changed, 180 insertions(+), 254 deletions(-) diff --git a/OpenNest.Benchmark/NestValidator.cs b/OpenNest.Benchmark/NestValidator.cs index bb1dea3..d8f4276 100644 --- a/OpenNest.Benchmark/NestValidator.cs +++ b/OpenNest.Benchmark/NestValidator.cs @@ -315,6 +315,8 @@ namespace OpenNest.Benchmark ? requirement.Name : part.BaseDrawing.Name; + private const double OutlineTolerance = 0.01; + private sealed class PartOutline { public Polygon Perimeter { get; init; } @@ -324,9 +326,12 @@ namespace OpenNest.Benchmark /// /// Extracts a part's material as world-space polygons - the perimeter and /// its cutouts - grown by (perimeter offset - /// outward, cutouts offset inward). A cutout that closes up under the - /// offset is dropped, which treats it as solid: conservative, since it - /// has no room for another part at the required spacing anyway. + /// outward, cutouts offset inward, in one Clipper region offset). A cutout + /// that closes up under the offset is dropped, which treats it as solid: + /// conservative, since it has no room for another part at the required + /// spacing anyway. The flattening is conservative too (perimeter arcs + /// circumscribed, cutout arcs inscribed), so the check never passes a + /// layout that is closer than the spacing. /// part.Program is already rotated; only a Location offset is needed. /// private static PartOutline Outline(Part part, double inflateBy) @@ -344,57 +349,33 @@ namespace OpenNest.Benchmark if (profile.Perimeter == null) return null; - var perimeter = profile.Perimeter; - - if (inflateBy > Tolerance.Epsilon) - perimeter = perimeter.OffsetOutward(inflateBy) ?? perimeter; - - var polygon = ToWorldPolygon(perimeter, part.Location); - - if (polygon == null) - return null; - - var holes = new List(); - - foreach (var cutout in profile.Cutouts) - { - var hole = cutout; - - if (inflateBy > Tolerance.Epsilon) - { - hole = cutout.OffsetInward(inflateBy); - - // An offset that collapsed or flipped inside-out leaves no usable room. - if ( - hole == null - || hole.Area() <= Tolerance.Epsilon - || hole.Area() >= cutout.Area() - ) - continue; - } - - var holePolygon = ToWorldPolygon(hole, part.Location); - - if (holePolygon != null) - holes.Add(holePolygon); - } - - return new PartOutline { Perimeter = polygon, Holes = holes }; - } - - private static Polygon ToWorldPolygon(Shape shape, Vector location) - { // Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 // segments per arc) - arc-heavy real parts otherwise produce thousands // of vertices, which is needlessly slow for a spacing check. - var polygon = shape.ToPolygonWithTolerance(0.01, circumscribe: true); + var region = ClipperBridge.Offset( + profile, + inflateBy > Tolerance.Epsilon ? inflateBy : 0, + OutlineTolerance, + circumscribe: true + ); - if (polygon == null) + var perimeter = region.LargestOuter(); + + if (perimeter == null) return null; + ToWorld(perimeter, part.Location); + + foreach (var hole in region.Holes) + ToWorld(hole, part.Location); + + return new PartOutline { Perimeter = perimeter, Holes = region.Holes }; + } + + private static void ToWorld(Polygon polygon, Vector location) + { polygon.Offset(location); polygon.UpdateBounds(); - return polygon; } } } diff --git a/OpenNest.Core/CutOff.cs b/OpenNest.Core/CutOff.cs index e7f513f..db9c5a9 100644 --- a/OpenNest.Core/CutOff.cs +++ b/OpenNest.Core/CutOff.cs @@ -13,6 +13,8 @@ namespace OpenNest public class CutOff { + private const double OffsetTolerance = 0.001; + public Vector Position { get; set; } public CutOffAxis Axis { get; set; } public double? StartLimit { get; set; } @@ -163,14 +165,23 @@ namespace OpenNest double clearance ) { - var target = OffsetOutward(perimeter, clearance) ?? perimeter; - var usedOffset = target != perimeter; + var offset = OffsetOutward(perimeter, clearance); + var usedOffset = offset != null; + var targets = offset ?? new List { perimeter }; var cutLine = new Line( MakePoint(cutPosition, lineStart), MakePoint(cutPosition, lineEnd) ); - if (!target.Intersects(cutLine, out var pts) || pts.Count < 2) + var pts = new List(); + + foreach (var target in targets) + { + if (target.Intersects(cutLine, out var targetPts)) + pts.AddRange(targetPts); + } + + if (pts.Count < 2) return null; var coords = pts.Select(pt => Axis == CutOffAxis.Vertical ? pt.Y : pt.X) @@ -188,21 +199,37 @@ namespace OpenNest return result; } - private static Entity OffsetOutward(Entity perimeter, double clearance) + /// + /// Grows the perimeter by the clearance as one Clipper region offset, so slots + /// narrower than twice the clearance close up instead of leaving a gap the cut + /// could run into. Holes appear only where the perimeter curls back on itself. + /// + private static List OffsetOutward(Entity perimeter, double clearance) { if (clearance <= 0) return null; - try - { - var offset = perimeter.OffsetEntity(clearance, OffsetSide.Left); - offset?.UpdateBounds(); - return offset; - } - catch + var offset = perimeter switch { + Shape shape => ClipperBridge.OffsetPerimeter( + shape, + clearance, + OffsetTolerance, + circumscribe: true + ), + Polygon polygon => ClipperBridge.OffsetPerimeter( + polygon, + clearance, + OffsetTolerance, + circumscribe: true + ), + _ => null, + }; + + if (offset == null || offset.Outers.Count == 0) return null; - } + + return offset.Outers.Concat(offset.Holes).Cast().ToList(); } private Vector MakePoint(double cutCoord, double lineCoord) => diff --git a/OpenNest.Core/Geometry/ClipperBridge.cs b/OpenNest.Core/Geometry/ClipperBridge.cs index 8e635f6..a02e1d4 100644 --- a/OpenNest.Core/Geometry/ClipperBridge.cs +++ b/OpenNest.Core/Geometry/ClipperBridge.cs @@ -18,6 +18,8 @@ namespace OpenNest.Geometry private const double MiterLimit = 2.0; + private const double ConservativeJoinFactor = 0.25; + /// /// Converts a polygon to a Clipper path, dropping the closing vertex and /// orienting it positive (CCW) or negative (CW). @@ -90,9 +92,9 @@ namespace OpenNest.Geometry /// 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. + /// When true, the result never under-estimates the offset: perimeter arcs are + /// flattened outside the true curve, cutout arcs inside it, and the inflation is + /// padded by the round-join chord error and Clipper's rounding. /// public static OffsetRegion Offset( ShapeProfile profile, @@ -105,8 +107,39 @@ namespace OpenNest.Geometry return Offset(region, distance, tolerance, circumscribe); } + /// + /// Offsets a single closed shape outward, ignoring any cutouts. A perimeter that + /// curls back on itself (a C shape with a narrow mouth) can gain holes. + /// + public static OffsetRegion OffsetPerimeter( + Shape perimeter, + double distance, + double tolerance, + bool circumscribe = false + ) + { + var polygon = perimeter.ToPolygonWithTolerance(tolerance, circumscribe); + return OffsetPerimeter(polygon, distance, tolerance, circumscribe); + } + + /// + /// Offsets a closed polygon outward, whatever its winding. + /// + public static OffsetRegion OffsetPerimeter( + Polygon perimeter, + double distance, + double tolerance, + bool circumscribe = false + ) + { + var region = new PathsD(1); + AddPolygon(region, perimeter, positive: true); + return Offset(region, distance, tolerance, circumscribe); + } + /// /// Offsets an already-flattened region (outers positive, holes negative). + /// A distance of zero only unions the region, with no conservative padding. /// public static OffsetRegion Offset( PathsD region, @@ -115,13 +148,20 @@ namespace OpenNest.Geometry bool circumscribe = false ) { + // Round joins put their vertices on the true arc, so each chord sits inside + // it by up to the join tolerance. In conservative mode, joins use a finer + // tolerance and the inflation is padded by it (plus Clipper's rounding). var delta = distance; + var joinTolerance = tolerance; - if (circumscribe) - delta += tolerance + 0.5 * System.Math.Pow(10, -Precision); + if (circumscribe && distance > 0) + { + joinTolerance = tolerance * ConservativeJoinFactor; + delta += joinTolerance + 0.5 * System.Math.Pow(10, -Precision); + } var inflated = - delta == 0 + delta <= 0 ? Union(region) : Clipper.InflatePaths( region, @@ -130,7 +170,7 @@ namespace OpenNest.Geometry EndType.Polygon, MiterLimit, Precision, - tolerance + joinTolerance ); var result = new OffsetRegion(new List(), new List()); @@ -167,9 +207,12 @@ namespace OpenNest.Geometry bool positive ) { - var polygon = shape.ToPolygonWithTolerance(tolerance, circumscribe); + AddPolygon(region, shape.ToPolygonWithTolerance(tolerance, circumscribe), positive); + } - if (polygon.Vertices.Count < 4) + private static void AddPolygon(PathsD region, Polygon polygon, bool positive) + { + if (polygon.Vertices.Count < 3) return; var path = ToPath(polygon, positive); diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs index a0b0a69..a91c4ca 100644 --- a/OpenNest.Core/PartGeometry.cs +++ b/OpenNest.Core/PartGeometry.cs @@ -49,7 +49,7 @@ namespace OpenNest /// /// Returns the perimeter entities (Line, Arc, Circle) with spacing offset applied, - /// without tessellation. Much faster than GetOffsetPartLines for parts with many arcs. + /// without tessellation, which keeps arc-heavy parts fast in directional-distance loops. /// public static List GetOffsetPerimeterEntities(Part part, double spacing) { @@ -149,75 +149,6 @@ namespace OpenNest return result; } - public static List GetOffsetPartLines( - Part part, - double spacing, - double chordTolerance = 0.001, - bool perimeterOnly = false - ) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var profile = new ShapeProfile( - entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() - ); - var lines = new List(); - var totalSpacing = spacing; - - AddOffsetLines( - lines, - profile.Perimeter.OffsetOutward(totalSpacing), - chordTolerance, - part.Location - ); - - if (!perimeterOnly) - { - foreach (var cutout in profile.Cutouts) - AddOffsetLines( - lines, - cutout.OffsetInward(totalSpacing), - chordTolerance, - part.Location - ); - } - - return lines; - } - - public static List GetOffsetPartLines( - Part part, - double spacing, - PushDirection facingDirection, - double chordTolerance = 0.001 - ) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var profile = new ShapeProfile( - entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() - ); - var lines = new List(); - var totalSpacing = spacing; - - AddOffsetDirectionalLines( - lines, - profile.Perimeter.OffsetOutward(totalSpacing), - chordTolerance, - part.Location, - facingDirection - ); - - foreach (var cutout in profile.Cutouts) - AddOffsetDirectionalLines( - lines, - cutout.OffsetInward(totalSpacing), - chordTolerance, - part.Location, - facingDirection - ); - - return lines; - } - public static List GetPartLines( Part part, Vector facingDirection, @@ -240,40 +171,6 @@ namespace OpenNest return lines; } - public static List GetOffsetPartLines( - Part part, - double spacing, - Vector facingDirection, - double chordTolerance = 0.001 - ) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var profile = new ShapeProfile( - entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList() - ); - var lines = new List(); - var totalSpacing = spacing; - - AddOffsetDirectionalLines( - lines, - profile.Perimeter.OffsetOutward(totalSpacing), - chordTolerance, - part.Location, - facingDirection - ); - - foreach (var cutout in profile.Cutouts) - AddOffsetDirectionalLines( - lines, - cutout.OffsetInward(totalSpacing), - chordTolerance, - part.Location, - facingDirection - ); - - return lines; - } - /// /// Returns only polygon edges whose outward normal faces the specified direction vector. /// @@ -353,55 +250,5 @@ namespace OpenNest return lines; } - - private static void AddOffsetLines( - List lines, - Shape offsetEntity, - double chordTolerance, - Vector location - ) - { - if (offsetEntity == null) - return; - - var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance); - polygon.RemoveSelfIntersections(); - polygon.Offset(location); - lines.AddRange(polygon.ToLines()); - } - - private static void AddOffsetDirectionalLines( - List lines, - Shape offsetEntity, - double chordTolerance, - Vector location, - PushDirection facingDirection - ) - { - if (offsetEntity == null) - return; - - var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance); - polygon.RemoveSelfIntersections(); - polygon.Offset(location); - lines.AddRange(GetDirectionalLines(polygon, facingDirection)); - } - - private static void AddOffsetDirectionalLines( - List lines, - Shape offsetEntity, - double chordTolerance, - Vector location, - Vector facingDirection - ) - { - if (offsetEntity == null) - return; - - var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance); - polygon.RemoveSelfIntersections(); - polygon.Offset(location); - lines.AddRange(GetDirectionalLines(polygon, facingDirection)); - } } } diff --git a/OpenNest.Engine/BestFit/PolygonHelper.cs b/OpenNest.Engine/BestFit/PolygonHelper.cs index 386b6ea..865dadd 100644 --- a/OpenNest.Engine/BestFit/PolygonHelper.cs +++ b/OpenNest.Engine/BestFit/PolygonHelper.cs @@ -26,16 +26,15 @@ namespace OpenNest.Engine.BestFit if (perimeter == null) return new PolygonExtractionResult(null, Vector.Zero); - // Ensure CW winding for correct outward offset direction. - definedShape.NormalizeWinding(); + // Circumscribe so the polygon never under-estimates the part (or its offset). + var polygon = + halfSpacing > 0 + ? ClipperBridge + .OffsetPerimeter(perimeter, halfSpacing, 0.01, circumscribe: true) + .LargestOuter() + : perimeter.ToPolygonWithTolerance(0.01, circumscribe: true); - var inflated = - halfSpacing > 0 ? (perimeter.OffsetOutward(halfSpacing) ?? perimeter) : perimeter; - - // Convert to polygon with circumscribed arcs for tight nesting. - var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true); - - if (polygon.Vertices.Count < 3) + if (polygon == null || polygon.Vertices.Count < 3) return new PolygonExtractionResult(null, Vector.Zero); // Normalize: move polygon to origin. diff --git a/OpenNest.Engine/Fill/PartBoundary.cs b/OpenNest.Engine/Fill/PartBoundary.cs index 3a6952e..b510f75 100644 --- a/OpenNest.Engine/Fill/PartBoundary.cs +++ b/OpenNest.Engine/Fill/PartBoundary.cs @@ -34,19 +34,16 @@ namespace OpenNest.Engine.Fill if (perimeter != null) { - var offsetEntity = perimeter.OffsetOutward(spacing); - - if (offsetEntity != null) - { - // Circumscribe arcs so polygon vertices are always outside - // the true arc — guarantees the boundary never under-estimates. - var polygon = offsetEntity.ToPolygonWithTolerance( - PolygonTolerance, - circumscribe: true - ); - polygon.RemoveSelfIntersections(); - _polygons.Add(polygon); - } + // Conservative offset: the boundary never under-estimates the spacing. + // Holes appear only where the perimeter curls back on itself. + var offset = ClipperBridge.OffsetPerimeter( + perimeter, + spacing, + PolygonTolerance, + circumscribe: true + ); + _polygons.AddRange(offset.Outers); + _polygons.AddRange(offset.Holes); } PrecomputeDirectionalEdges( diff --git a/OpenNest.Tests/CutOffs/CutOffTests.cs b/OpenNest.Tests/CutOffs/CutOffTests.cs index 35f8f9a..7bfd590 100644 --- a/OpenNest.Tests/CutOffs/CutOffTests.cs +++ b/OpenNest.Tests/CutOffs/CutOffTests.cs @@ -121,6 +121,49 @@ public class CutOffTests Assert.Equal(4, codes.Count); } + [Theory] + [InlineData(0.3, 19.0)] // Narrower than twice the clearance: the slot closes up. + [InlineData(4.0, 24.0)] // Wide slot: the cut runs in to 1 short of the slot's end. + public void CutOff_UpASlot_KeepsClearanceFromPart(double slotWidth, double firstEnd) + { + // 10x10 part at (20,20) with a 5-deep slot up from the bottom edge, centered + // on the cut line. + var h = slotWidth / 2; + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(5 - h, 0))); + pgm.Codes.Add(new LinearMove(new Vector(5 - h, 5))); + pgm.Codes.Add(new LinearMove(new Vector(5 + h, 5))); + pgm.Codes.Add(new LinearMove(new Vector(5 + h, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + + var plate = new Plate(50, 50); + var part = Part.CreateAtOrigin(new Drawing("slot", pgm)); + part.Location = new Vector(20, 20); + plate.Parts.Add(part); + + var settings = new CutOffSettings { PartClearance = 1.0 }; + var cutoff = new CutOff(new Vector(25, 10), CutOffAxis.Vertical); + cutoff.Regenerate(plate, settings, Plate.BuildPerimeterCache(plate)); + + var ys = cutoff + .Drawing.Program.Codes.OfType() + .Select(m => m.EndPoint.Y) + .OrderBy(y => y) + .ToList(); + + Assert.Equal(4, ys.Count); + Assert.Equal(0, ys[0], 6); + // A closed slot leaves a shallow dent at its mouth: the cut stops 1 from the + // mouth corners, at 20 - sqrt(1 - 0.15^2) = 19.011. + Assert.InRange(ys[1], firstEnd - 0.02, firstEnd + 0.02); + Assert.InRange(ys[2], 30.999, 31.02); + Assert.Equal(50, ys[3], 6); + } + [Fact] public void CutOff_ShortSegment_FilteredByMinLength() { diff --git a/OpenNest/Controls/EntityView.cs b/OpenNest/Controls/EntityView.cs index 4870a0e..8163e4c 100644 --- a/OpenNest/Controls/EntityView.cs +++ b/OpenNest/Controls/EntityView.cs @@ -165,17 +165,6 @@ namespace OpenNest.Controls DrawArc(e.Graphics, SimplifierPreview, previewPen); } -#if DRAW_OFFSET - - var offsetShape = new Shape(); - offsetShape.Entities.AddRange(Entities); - - foreach ( - var entity in ((Shape)offsetShape.OffsetEntity(0.25, OffsetSide.Left)).Entities - ) - DrawEntity(e.Graphics, entity, Pens.RoyalBlue); -#endif - PaintOverlay?.Invoke(e.Graphics); } From 10fe00d8ab7b5e369cdd9acf44f7bf86f83fcece Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 09:24:37 -0400 Subject: [PATCH 05/10] refactor(geometry): delete RemoveSelfIntersections Its callers now take Clipper region offsets, which never produce the self-intersections it patched over (and it only caught proper crossings, so spikes survived it anyway). Polygon.OffsetEntity was its last caller; the override is required by Entity but has no callers, so it becomes a Clipper miter offset that keeps the Left/Right semantics and the input winding. FindCrossing, SplitAtCrossing, SegmentsIntersect and the static CalculateArea helper go with it. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/ClipperBridge.cs | 38 ++++ OpenNest.Core/Geometry/Polygon.cs | 210 +----------------- OpenNest.Tests/Geometry/ClipperBridgeTests.cs | 25 +++ 3 files changed, 75 insertions(+), 198 deletions(-) diff --git a/OpenNest.Core/Geometry/ClipperBridge.cs b/OpenNest.Core/Geometry/ClipperBridge.cs index a02e1d4..37e470b 100644 --- a/OpenNest.Core/Geometry/ClipperBridge.cs +++ b/OpenNest.Core/Geometry/ClipperBridge.cs @@ -189,6 +189,44 @@ namespace OpenNest.Geometry return result; } + /// + /// Miter-offsets a closed polygon by (positive grows it, + /// negative shrinks it). Returns the largest resulting polygon (CCW), or null + /// when the polygon collapses. + /// + public static Polygon OffsetMiter(Polygon polygon, double delta) + { + var path = ToPath(polygon, positive: true); + + if (path.Count < 3) + return null; + + var inflated = Clipper.InflatePaths( + new PathsD { path }, + delta, + JoinType.Miter, + EndType.Polygon, + MiterLimit, + Precision + ); + + PathD largest = null; + var largestArea = 0.0; + + foreach (var candidate in inflated) + { + var area = Clipper.Area(candidate); + + if (area > largestArea) + { + largest = candidate; + largestArea = area; + } + } + + return largest == null ? null : ToPolygon(largest); + } + private static PathsD Union(PathsD region) { var clipper = new ClipperD(Precision); diff --git a/OpenNest.Core/Geometry/Polygon.cs b/OpenNest.Core/Geometry/Polygon.cs index dec134e..a8e66dd 100644 --- a/OpenNest.Core/Geometry/Polygon.cs +++ b/OpenNest.Core/Geometry/Polygon.cs @@ -328,66 +328,29 @@ namespace OpenNest.Geometry boundingBox.Width = maxY - minY; } + /// + /// Miter-offsets the closed polygon to the given side, keeping its winding. + /// Corners sharper than the miter limit are squared off, and features that + /// collapse under the offset are dropped. When the offset splits the polygon, + /// the largest piece is returned. + /// public override Entity OffsetEntity(double distance, OffsetSide side) { if (Vertices.Count < 3) return null; - var isClosed = IsClosed(); - var count = isClosed ? Vertices.Count - 1 : Vertices.Count; - if (count < 3) - return null; - var ccw = CalculateArea() > 0; var outward = ccw ? OffsetSide.Left : OffsetSide.Right; - var sign = side == outward ? 1.0 : -1.0; - var d = distance * sign; + var delta = side == outward ? distance : -distance; - var normals = new Vector[count]; - for (var i = 0; i < count; i++) - { - var next = (i + 1) % count; - var dx = Vertices[next].X - Vertices[i].X; - var dy = Vertices[next].Y - Vertices[i].Y; - var len = System.Math.Sqrt(dx * dx + dy * dy); - if (len < Tolerance.Epsilon) - return null; - normals[i] = new Vector(-dy / len * d, dx / len * d); - } + var result = ClipperBridge.OffsetMiter(this, delta); - var result = new Polygon(); - for (var i = 0; i < count; i++) - { - var prev = (i - 1 + count) % count; + if (result == null) + return null; - var a1 = new Vector( - Vertices[prev].X + normals[prev].X, - Vertices[prev].Y + normals[prev].Y - ); - var a2 = new Vector( - Vertices[i].X + normals[prev].X, - Vertices[i].Y + normals[prev].Y - ); - var b1 = new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y); - var b2 = new Vector( - Vertices[(i + 1) % count].X + normals[i].X, - Vertices[(i + 1) % count].Y + normals[i].Y - ); + if (!ccw) + result.Reverse(); - var edgeA = new Line(a1, a2); - var edgeB = new Line(b1, b2); - - if (edgeA.Intersects(edgeB, out var pt) && pt.IsValid()) - result.Vertices.Add(pt); - else - result.Vertices.Add( - new Vector(Vertices[i].X + normals[i].X, Vertices[i].Y + normals[i].Y) - ); - } - - result.Close(); - result.RemoveSelfIntersections(); - result.UpdateBounds(); return result; } @@ -556,155 +519,6 @@ namespace OpenNest.Geometry get { return EntityType.Polygon; } } - /// - /// Removes self-intersecting loops from the polygon by finding non-adjacent - /// edge crossings and keeping the larger contour at each crossing. - /// - public void RemoveSelfIntersections() - { - if (!IsClosed() || Vertices.Count < 5) - return; - - while (FindCrossing(out var edgeI, out var edgeJ, out var pt)) - { - Vertices = SplitAtCrossing(edgeI, edgeJ, pt); - } - } - - private bool FindCrossing(out int edgeI, out int edgeJ, out Vector pt) - { - var n = Vertices.Count - 1; - - // Pre-calculate edge bounding boxes to speed up intersection checks. - var edgeBounds = new (double minX, double maxX, double minY, double maxY)[n]; - for (var i = 0; i < n; i++) - { - var v1 = Vertices[i]; - var v2 = Vertices[i + 1]; - edgeBounds[i] = ( - System.Math.Min(v1.X, v2.X) - Tolerance.Epsilon, - System.Math.Max(v1.X, v2.X) + Tolerance.Epsilon, - System.Math.Min(v1.Y, v2.Y) - Tolerance.Epsilon, - System.Math.Max(v1.Y, v2.Y) + Tolerance.Epsilon - ); - } - - for (var i = 0; i < n; i++) - { - var bi = edgeBounds[i]; - for (var j = i + 2; j < n; j++) - { - if (i == 0 && j == n - 1) - continue; - - var bj = edgeBounds[j]; - - // Prune with bounding box check. - if ( - bi.maxX < bj.minX - || bj.maxX < bi.minX - || bi.maxY < bj.minY - || bj.maxY < bi.minY - ) - { - continue; - } - - if ( - SegmentsIntersect( - Vertices[i], - Vertices[i + 1], - Vertices[j], - Vertices[j + 1], - out pt - ) - ) - { - edgeI = i; - edgeJ = j; - return true; - } - } - } - - edgeI = edgeJ = -1; - pt = Vector.Zero; - return false; - } - - private List SplitAtCrossing(int edgeI, int edgeJ, Vector pt) - { - var n = Vertices.Count - 1; - - var loopA = Vertices.GetRange(0, edgeI + 1); - loopA.Add(pt); - loopA.AddRange(Vertices.GetRange(edgeJ + 1, n - edgeJ - 1)); - loopA.Add(loopA[0]); - - var loopB = new List { pt }; - loopB.AddRange(Vertices.GetRange(edgeI + 1, edgeJ - edgeI)); - loopB.Add(pt); - - var areaA = System.Math.Abs(CalculateArea(loopA)); - var areaB = System.Math.Abs(CalculateArea(loopB)); - - return areaA >= areaB ? loopA : loopB; - } - - private static bool SegmentsIntersect( - Vector a1, - Vector a2, - Vector b1, - Vector b2, - out Vector pt - ) - { - var da = a2 - a1; - var db = b2 - b1; - var cross = da.X * db.Y - da.Y * db.X; - - if (cross.IsEqualTo(0.0)) - { - pt = Vector.Zero; - return false; - } - - var dc = b1 - a1; - var t = (dc.X * db.Y - dc.Y * db.X) / cross; - var u = (dc.X * da.Y - dc.Y * da.X) / cross; - - if ( - t > Tolerance.Epsilon - && t < 1.0 - Tolerance.Epsilon - && u > Tolerance.Epsilon - && u < 1.0 - Tolerance.Epsilon - ) - { - pt = new Vector(a1.X + t * da.X, a1.Y + t * da.Y); - return true; - } - - pt = Vector.Zero; - return false; - } - - private static double CalculateArea(List vertices) - { - double xsum = 0; - double ysum = 0; - - for (int i = 0; i < vertices.Count - 1; i++) - { - var current = vertices[i]; - var next = vertices[i + 1]; - - xsum += current.X * next.Y; - ysum += current.Y * next.X; - } - - return (xsum - ysum) * 0.5; - } - internal void Cleanup() { for (int i = Vertices.Count - 1; i > 0; i--) diff --git a/OpenNest.Tests/Geometry/ClipperBridgeTests.cs b/OpenNest.Tests/Geometry/ClipperBridgeTests.cs index 55e2f22..0aeaa71 100644 --- a/OpenNest.Tests/Geometry/ClipperBridgeTests.cs +++ b/OpenNest.Tests/Geometry/ClipperBridgeTests.cs @@ -174,6 +174,31 @@ public class ClipperBridgeTests } } + [Theory] + [InlineData(true, OffsetSide.Left, 12 * 12)] + [InlineData(true, OffsetSide.Right, 8 * 8)] + [InlineData(false, OffsetSide.Left, 8 * 8)] + [InlineData(false, OffsetSide.Right, 12 * 12)] + public void PolygonOffsetEntity_MitersToSideAndKeepsWinding( + bool ccw, + OffsetSide side, + double expectedArea + ) + { + var square = new Polygon(); + square.Vertices.AddRange(new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10) }); + + if (!ccw) + square.Vertices.Reverse(); + + square.Close(); + + var result = (Polygon)square.OffsetEntity(1, side); + + Assert.Equal(expectedArea, result.Area(), 6); + Assert.Equal(square.RotationDirection(), result.RotationDirection()); + } + private static bool SegmentsCross(Vector a, Vector b, Vector c, Vector d) { static double Cross(Vector o, Vector p, Vector q) => From dceb5f7d18dd9e8a277345b73ecca4e718784424 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 09:25:26 -0400 Subject: [PATCH 06/10] test(geometry): cover Collision with ClipperBridge inputs; document GPU contract Collision stays hand-rolled because it is the reference for a future GPU kernel, but its inputs now come from Clipper region offsets. Pin down that lines-only, round-join, 1e-4-precision polygons keep the contact and part-in-part semantics: a neighbor inside a collapsed slot, a part inside a hole that shrank by the spacing, and zero-spacing edge contact. Document which steps are per-polygon preparation to cache and upload once, and which are per-pair kernel-shaped work. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/Collision.cs | 16 +++++ OpenNest.Tests/Geometry/CollisionTests.cs | 79 +++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/OpenNest.Core/Geometry/Collision.cs b/OpenNest.Core/Geometry/Collision.cs index 331f04d..4e639b4 100644 --- a/OpenNest.Core/Geometry/Collision.cs +++ b/OpenNest.Core/Geometry/Collision.cs @@ -3,6 +3,22 @@ using OpenNest.Math; namespace OpenNest.Geometry { + /// + /// Polygon overlap test with hole subtraction. This is the reference implementation + /// for a future GPU kernel, so it deliberately stays hand-rolled instead of using + /// Clipper (which is CPU-only and allocation-heavy; see + /// for the CPU preparation that feeds it). + /// + /// GPU-port contract. Per-polygon preparation, done once per drawing and rotation, + /// then cached and uploaded: the spacing offset (), + /// triangulation () of the outline and + /// each hole, and the bounding box of every polygon and triangle. Per-pair work, + /// kernel-shaped (fixed-size, loop-only, no recursion): the bounding-box rejects, + /// Sutherland-Hodgman clipping of convex triangle pairs (ClipConvex), and + /// subtraction of hole triangles from the clipped regions (SubtractTriangles). + /// Inputs are closed, lines-only polygons; winding is normalized by triangulation. + /// + /// public static class Collision { public static CollisionResult Check( diff --git a/OpenNest.Tests/Geometry/CollisionTests.cs b/OpenNest.Tests/Geometry/CollisionTests.cs index 488107c..a716184 100644 --- a/OpenNest.Tests/Geometry/CollisionTests.cs +++ b/OpenNest.Tests/Geometry/CollisionTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using OpenNest.Geometry; using OpenNest.Math; @@ -204,6 +205,84 @@ public class CollisionTests Assert.False(Collision.HasAnyOverlap(new List())); } + // The cases below feed Collision with ClipperBridge offsets, the way the spacing + // checks prepare their inputs: lines only, round joins, 1e-4 precision. + + [Theory] + [InlineData(4.9, 5.1, true)] // Inside the collapsed slot: 0.05 from its walls. + [InlineData(10.3, 12, false)] // Beside the part, 0.3 away. + public void HasOverlap_NeighborOfPartWithCollapsedSlot( + double left, + double right, + bool expected + ) + { + // 10x10 part with a 0.3-wide slot down from the top, inflated by 0.25. + var part = MakeProfile( + MakePolygon((0, 0), (10, 0), (10, 10), (5.15, 10), (5.15, 7), (4.85, 7), (4.85, 10), (0, 10)) + ); + var inflated = ClipperBridge.Offset(part, 0.25, 0.001); + var neighbor = MakeSquare(left, 8, right, 10); + + Assert.Equal( + expected, + Collision.HasOverlap(inflated.LargestOuter(), neighbor, inflated.Holes) + ); + } + + [Theory] + [InlineData(5.5, 14.5, false)] // 0.5 from the hole's edges. + [InlineData(5.1, 14.9, true)] // 0.1 from the hole's edges. + public void HasOverlap_PartInsideHoleShrunkBySpacing(double min, double max, bool expected) + { + var part = MakeProfile( + MakePolygon((0, 0), (20, 0), (20, 20), (0, 20)), + MakePolygon((5, 5), (15, 5), (15, 15), (5, 15)) + ); + var inflated = ClipperBridge.Offset(part, 0.25, 0.001); + var inner = MakeSquare(min, min, max, max); + + Assert.Single(inflated.Holes); + Assert.Equal( + expected, + Collision.HasOverlap(inflated.LargestOuter(), inner, inflated.Holes) + ); + } + + [Fact] + public void HasOverlap_ZeroSpacingEdgeContact_ReturnsFalse() + { + var a = ClipperBridge.Offset( + MakeProfile(MakePolygon((0, 0), (10, 0), (10, 10), (0, 10))), + 0, + 0.001 + ); + var b = ClipperBridge.Offset( + MakeProfile(MakePolygon((10, 0), (20, 0), (20, 10), (10, 10))), + 0, + 0.001 + ); + + Assert.False(Collision.HasOverlap(a.LargestOuter(), b.LargestOuter())); + } + + private static Shape MakePolygon(params (double X, double Y)[] pts) + { + var shape = new Shape(); + + for (var i = 0; i < pts.Length; i++) + { + var from = pts[i]; + var to = pts[(i + 1) % pts.Length]; + shape.Entities.Add(new Line(from.X, from.Y, to.X, to.Y)); + } + + return shape; + } + + private static ShapeProfile MakeProfile(params Shape[] shapes) => + new(shapes.SelectMany(s => s.Entities).ToList()); + private static Polygon MakeSquare(double left, double bottom, double right, double top) { var p = new Polygon(); From 01789c592967ba082bfef2a750889b503374afe5 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 09:43:02 -0400 Subject: [PATCH 07/10] fix(geometry): harden the arc-preserving per-entity offset GetOffsetPerimeterEntities/GetOffsetPartEntities feed directional-distance loops (FillLinear, Compactor, RotationSlideStrategy) that handle arcs natively. Switching them to Clipper line output (plan option B) made OpenNest.Tests run 48s -> 8m19s, Fill tests ~3x slower, and broke 20 exact-fit tests through tessellation and conservative padding, so they keep the per-entity offset (option A), hardened: - Arc, Circle and Line offsets are now side-symmetric. Right on a CCW arc shrank instead of growing, Right on a CW circle grew, and Right on a line offset to the left and reversed it. Only Left was used on hot paths, so this was latent (SimplifierViewer drew both tolerance bands on one side). - Shape.OffsetEntity closes every gap between consecutive offset pieces: convex non-tangent line/arc corners get a round join about the original corner, lines across a collapsed fillet are mitered, and any other gap (concave arc corner, collapsed entity) is bridged with a line. Before, only line-line corners were joined, so a vertex could slip through. - Zero-area spikes are left in place and documented: they lie inside the offset envelope, which is harmless for directional distance. - OffsetOutward/OffsetInward become internal; PartGeometry is their only caller. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/Geometry/Arc.cs | 24 +- OpenNest.Core/Geometry/Circle.cs | 7 +- OpenNest.Core/Geometry/Line.cs | 6 +- OpenNest.Core/Geometry/Shape.cs | 243 +++++++++++++++----- OpenNest.Tests/Geometry/ShapeOffsetTests.cs | 155 +++++++++++++ 5 files changed, 360 insertions(+), 75 deletions(-) create mode 100644 OpenNest.Tests/Geometry/ShapeOffsetTests.cs diff --git a/OpenNest.Core/Geometry/Arc.cs b/OpenNest.Core/Geometry/Arc.cs index b361763..5a3c4a7 100644 --- a/OpenNest.Core/Geometry/Arc.cs +++ b/OpenNest.Core/Geometry/Arc.cs @@ -443,19 +443,23 @@ namespace OpenNest.Geometry boundingBox.Width = maxY - minY; } + /// + /// Offsets the arc to the given side of its travel direction. The center lies to + /// the left of a CCW arc and to the right of a CW (reversed) one, so the arc grows + /// on the other side and shrinks toward its center. Returns null when it shrinks + /// to nothing. + /// public override Entity OffsetEntity(double distance, OffsetSide side) { - if (side == OffsetSide.Left && reversed) - { - return new Arc(center, radius + distance, startAngle, endAngle, reversed); - } - else - { - if (distance >= radius) - return null; + var grows = (side == OffsetSide.Left) == reversed; - return new Arc(center, radius - distance, startAngle, endAngle, reversed); - } + if (grows) + return new Arc(center, radius + distance, startAngle, endAngle, reversed); + + if (distance >= radius) + return null; + + return new Arc(center, radius - distance, startAngle, endAngle, reversed); } public override Entity OffsetEntity(double distance, Vector pt) diff --git a/OpenNest.Core/Geometry/Circle.cs b/OpenNest.Core/Geometry/Circle.cs index e89a62b..7c890f0 100644 --- a/OpenNest.Core/Geometry/Circle.cs +++ b/OpenNest.Core/Geometry/Circle.cs @@ -273,7 +273,10 @@ namespace OpenNest.Geometry public override Entity OffsetEntity(double distance, OffsetSide side) { - if (side == OffsetSide.Left && Rotation == RotationType.CCW) + // The center lies to the left of a CCW circle and to the right of a CW one. + var shrinks = (side == OffsetSide.Left) == (Rotation == RotationType.CCW); + + if (shrinks) { return Radius <= distance ? null @@ -281,7 +284,7 @@ namespace OpenNest.Geometry } else { - return new Circle(center, Radius + distance) { Layer = Layer }; + return new Circle(center, Radius + distance) { Layer = Layer, Rotation = Rotation }; } } diff --git a/OpenNest.Core/Geometry/Line.cs b/OpenNest.Core/Geometry/Line.cs index 5477cec..1fb4ef8 100644 --- a/OpenNest.Core/Geometry/Line.cs +++ b/OpenNest.Core/Geometry/Line.cs @@ -398,11 +398,9 @@ namespace OpenNest.Geometry var x = System.Math.Cos(angle) * distance; var y = System.Math.Sin(angle) * distance; - var pt = new Vector(x, y); + var pt = side == OffsetSide.Left ? new Vector(x, y) : new Vector(-x, -y); - return side == OffsetSide.Left - ? new Line(StartPoint + pt, EndPoint + pt) - : new Line(EndPoint + pt, StartPoint + pt); + return new Line(StartPoint + pt, EndPoint + pt); } public override Entity OffsetEntity(double distance, Vector pt) diff --git a/OpenNest.Core/Geometry/Shape.cs b/OpenNest.Core/Geometry/Shape.cs index ab32d66..2146351 100644 --- a/OpenNest.Core/Geometry/Shape.cs +++ b/OpenNest.Core/Geometry/Shape.cs @@ -463,80 +463,60 @@ namespace OpenNest.Geometry boundingBox = Entities.Select(geo => geo.BoundingBox).ToList().GetBoundingBox(); } + /// + /// Offsets each perimeter entity to the given side and joins the pieces into a + /// closed chain: line-line corners get a round join (convex) or a miter (concave), + /// other convex corners get a round join, and any remaining gap (a concave corner + /// involving an arc, or an entity that collapsed under the offset) is bridged + /// with a line. Cutouts are offset the same way. + /// + /// Where a feature is narrower than twice the distance, the result keeps zero-area + /// spikes and inverted loops. They lie inside the true offset envelope, so they are + /// harmless to directional-distance queries, which only need a closed boundary + /// that never falls inside the envelope. Use when a + /// clean region is needed. + /// + /// public override Entity OffsetEntity(double distance, OffsetSide side) { var offsetShape = new Shape(); var definedShape = new ShapeProfile(this); - Entity firstEntity = null; - Entity firstOffsetEntity = null; - Entity lastEntity = null; - Entity lastOffsetEntity = null; + var pieces = new List(); + var collapsed = false; foreach (var entity in definedShape.Perimeter.Entities) { var offsetEntity = entity.OffsetEntity(distance, side); if (offsetEntity == null) + { + collapsed = true; continue; - - if (firstEntity == null) - { - firstEntity = entity; - firstOffsetEntity = offsetEntity; } - switch (entity.Type) - { - case EntityType.Line: - { - var line = (Line)entity; - var offsetLine = (Line)offsetEntity; - - if (lastOffsetEntity != null && lastOffsetEntity.Type == EntityType.Line) - { - JoinOffsetLines( - (Line)lastEntity, - (Line)lastOffsetEntity, - line, - offsetLine, - distance, - side, - offsetShape - ); - } - - offsetShape.Entities.Add(offsetLine); - break; - } - - default: - offsetShape.Entities.Add(offsetEntity); - break; - } - - lastOffsetEntity = offsetEntity; - lastEntity = entity; + pieces.Add(new OffsetPiece(entity, offsetEntity, collapsed)); + collapsed = false; } - // Close the shape: join last offset entity back to first - if ( - lastOffsetEntity != null - && firstOffsetEntity != null - && lastOffsetEntity != firstOffsetEntity - && lastOffsetEntity.Type == EntityType.Line - && firstOffsetEntity.Type == EntityType.Line - ) + // Entities that collapsed at the end of the loop sit before the first piece. + if (collapsed && pieces.Count > 0) + pieces[0] = pieces[0] with { CollapsedBefore = true }; + + for (var i = 0; i < pieces.Count; i++) { - JoinOffsetLines( - (Line)lastEntity, - (Line)lastOffsetEntity, - (Line)firstEntity, - (Line)firstOffsetEntity, - distance, - side, - offsetShape - ); + offsetShape.Entities.Add(pieces[i].Offset); + + if (pieces.Count > 1) + { + JoinOffsetPieces( + pieces[i], + pieces[(i + 1) % pieces.Count], + distance, + side, + offsetShape + ); + } } foreach (var cutout in definedShape.Cutouts) @@ -547,6 +527,151 @@ namespace OpenNest.Geometry return offsetShape; } + private readonly record struct OffsetPiece( + Entity Source, + Entity Offset, + bool CollapsedBefore + ); + + private static void JoinOffsetPieces( + OffsetPiece last, + OffsetPiece next, + double distance, + OffsetSide side, + Shape offsetShape + ) + { + // Lines meeting across a collapsed fillet are concave, so a miter trims both at + // their intersection. Parallel ones (a round-bottomed slot) fall through to + // the bridge below. + if ( + next.CollapsedBefore + && last.Offset is Line lastOffsetLine + && next.Offset is Line nextOffsetLine + && Intersect.IntersectsUnbounded(nextOffsetLine, lastOffsetLine, out var miter) + ) + { + lastOffsetLine.EndPoint = miter; + nextOffsetLine.StartPoint = miter; + return; + } + + if (!next.CollapsedBefore && last.Source is Line lastLine && next.Source is Line nextLine) + { + JoinOffsetLines( + lastLine, + (Line)last.Offset, + nextLine, + (Line)next.Offset, + distance, + side, + offsetShape + ); + return; + } + + if ( + !TryGetEnds(last.Offset, out _, out var gapStart) + || !TryGetEnds(next.Offset, out var gapEnd, out _) + ) + return; + + if (gapStart.DistanceTo(gapEnd) <= OpenNest.Math.Tolerance.Epsilon) + return; + + if ( + !next.CollapsedBefore + && IsConvexCorner(last.Source, next.Source, side, out var corner) + ) + { + offsetShape.Entities.Add( + new Arc( + corner, + distance, + corner.AngleTo(gapStart), + corner.AngleTo(gapEnd), + side == OffsetSide.Left + ) + ); + return; + } + + // Concave corner or collapsed entity: the neighbors' offsets overlap, so a + // straight bridge stays inside the offset envelope and closes the chain. + offsetShape.Entities.Add(new Line(gapStart, gapEnd)); + } + + private static bool IsConvexCorner( + Entity last, + Entity next, + OffsetSide side, + out Vector corner + ) + { + corner = default; + + if ( + !TryGetEnds(last, out _, out corner) + || !TryGetTangents(last, out _, out var d1) + || !TryGetTangents(next, out var d2, out _) + ) + return false; + + var cross = d1.X * d2.Y - d1.Y * d2.X; + + return (side == OffsetSide.Left && cross < -OpenNest.Math.Tolerance.Epsilon) + || (side == OffsetSide.Right && cross > OpenNest.Math.Tolerance.Epsilon); + } + + private static bool TryGetEnds(Entity entity, out Vector start, out Vector end) + { + switch (entity) + { + case Line line: + start = line.StartPoint; + end = line.EndPoint; + return true; + + case Arc arc: + start = arc.StartPoint(); + end = arc.EndPoint(); + return true; + + default: + start = end = default; + return false; + } + } + + /// + /// Direction of travel at the start and end of a line or arc. + /// + private static bool TryGetTangents(Entity entity, out Vector start, out Vector end) + { + switch (entity) + { + case Line line: + start = end = line.EndPoint - line.StartPoint; + return true; + + case Arc arc: + start = ArcTangent(arc, arc.StartAngle); + end = ArcTangent(arc, arc.EndAngle); + return true; + + default: + start = end = default; + return false; + } + } + + private static Vector ArcTangent(Arc arc, double angle) + { + var sin = System.Math.Sin(angle); + var cos = System.Math.Cos(angle); + return arc.IsReversed ? new Vector(sin, -cos) : new Vector(-sin, cos); + } + private static void JoinOffsetLines( Line lastLine, Line lastOffsetLine, @@ -611,7 +736,7 @@ namespace OpenNest.Geometry /// Normalizes to CW winding before offsetting Left (which is outward for CW), /// making the method independent of the original contour winding direction. /// - public Shape OffsetOutward(double distance) + internal Shape OffsetOutward(double distance) { var poly = ToPolygon(); @@ -660,7 +785,7 @@ namespace OpenNest.Geometry /// Normalizes to CCW winding before offsetting Left (which is inward for CCW), /// making the method independent of the original contour winding direction. /// - public Shape OffsetInward(double distance) + internal Shape OffsetInward(double distance) { var poly = ToPolygon(); diff --git a/OpenNest.Tests/Geometry/ShapeOffsetTests.cs b/OpenNest.Tests/Geometry/ShapeOffsetTests.cs new file mode 100644 index 0000000..ce5ce53 --- /dev/null +++ b/OpenNest.Tests/Geometry/ShapeOffsetTests.cs @@ -0,0 +1,155 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.Geometry; + +public class ShapeOffsetTests +{ + [Theory] + [InlineData(false, OffsetSide.Left, 4)] // CCW: center on the left, shrinks. + [InlineData(false, OffsetSide.Right, 6)] + [InlineData(true, OffsetSide.Left, 6)] // CW: center on the right, grows. + [InlineData(true, OffsetSide.Right, 4)] + public void ArcOffset_GrowsAwayFromCenter(bool reversed, OffsetSide side, double radius) + { + var arc = new Arc(0, 0, 5, 0, Angle.HalfPI, reversed); + + var offset = (Arc)arc.OffsetEntity(1, side); + + Assert.Equal(radius, offset.Radius, 9); + Assert.Equal(reversed, offset.IsReversed); + } + + [Theory] + [InlineData(RotationType.CCW, OffsetSide.Left, 4)] + [InlineData(RotationType.CCW, OffsetSide.Right, 6)] + [InlineData(RotationType.CW, OffsetSide.Left, 6)] + [InlineData(RotationType.CW, OffsetSide.Right, 4)] + public void CircleOffset_GrowsAwayFromCenter(RotationType rotation, OffsetSide side, double radius) + { + var circle = new Circle(0, 0, 5) { Rotation = rotation }; + + var offset = (Circle)circle.OffsetEntity(1, side); + + Assert.Equal(radius, offset.Radius, 9); + Assert.Equal(rotation, offset.Rotation); + } + + [Theory] + [InlineData(OffsetSide.Left, 1)] + [InlineData(OffsetSide.Right, -1)] + public void LineOffset_MovesToSideAndKeepsDirection(OffsetSide side, double y) + { + var line = new Line(0, 0, 10, 0); + + var offset = (Line)line.OffsetEntity(1, side); + + Assert.True(offset.StartPoint.DistanceTo(new Vector(0, y)) < 1e-9); + Assert.True(offset.EndPoint.DistanceTo(new Vector(10, y)) < 1e-9); + } + + [Fact] + public void OffsetOutward_NonTangentLineArcCorners_GetRoundJoins() + { + // D shape: right half of an r=5 circle closed by the Y axis. Both corners are + // convex and not tangent, so the offset needs a round join at each. + var shape = new Shape(); + shape.Entities.Add(new Arc(0, 0, 5, -Angle.HalfPI, Angle.HalfPI)); + shape.Entities.Add(new Line(0, 5, 0, -5)); + + var offset = shape.OffsetOutward(1); + + AssertClosedChain(offset.Entities); + Assert.Equal(2, offset.Entities.OfType().Count(a => a.Radius.IsEqualTo(1))); + Assert.All(Samples(offset.Entities), p => Assert.True(DistanceTo(shape, p) > 1 - 1e-6)); + } + + [Fact] + public void OffsetOutward_CollapsedFillet_ClosesTheChain() + { + // 10x4 part with a 0.2-wide slot down from the top, with a round (r=0.1) bottom. + // Offsetting outward by 0.25 collapses the slot's end arc. + var shape = new Shape(); + shape.Entities.Add(new Line(0, 0, 10, 0)); + shape.Entities.Add(new Line(10, 0, 10, 4)); + shape.Entities.Add(new Line(10, 4, 5.1, 4)); + shape.Entities.Add(new Line(5.1, 4, 5.1, 2)); + shape.Entities.Add(new Arc(5, 2, 0.1, 0, System.Math.PI, reversed: true)); + shape.Entities.Add(new Line(4.9, 2, 4.9, 4)); + shape.Entities.Add(new Line(4.9, 4, 0, 4)); + shape.Entities.Add(new Line(0, 4, 0, 0)); + + var offset = shape.OffsetOutward(0.25); + + AssertClosedChain(offset.Entities); + Assert.All( + Samples(offset.Entities), + p => Assert.True(DistanceTo(shape, p) > 0.25 - 1e-6 || IsInsideSlot(p)) + ); + } + + // The collapsed slot leaves a line bridging its walls' offsets, which lies inside + // the offset envelope (closer than the spacing) by design. + private static bool IsInsideSlot(Vector p) => p.X > 4.8 && p.X < 5.2 && p.Y > 1.7; + + private static void AssertClosedChain(List entities) + { + for (var i = 0; i < entities.Count; i++) + { + var end = End(entities[i]); + var start = Start(entities[(i + 1) % entities.Count]); + + Assert.True( + end.DistanceTo(start) < 1e-6, + $"Gap of {end.DistanceTo(start)} after entity {i} ({entities[i].Type})." + ); + } + } + + private static IEnumerable Samples(List entities) + { + foreach (var entity in entities) + { + for (var t = 0.0; t <= 1.0; t += 0.1) + { + yield return entity switch + { + Line l => l.StartPoint + (l.EndPoint - l.StartPoint) * t, + Arc a => ArcPoint(a, t), + _ => Start(entity), + }; + } + } + } + + private static Vector ArcPoint(Arc arc, double t) + { + var sweep = arc.SweepAngle(); + var angle = arc.StartAngle + (arc.IsReversed ? -sweep : sweep) * t; + return new Vector( + arc.Center.X + arc.Radius * System.Math.Cos(angle), + arc.Center.Y + arc.Radius * System.Math.Sin(angle) + ); + } + + private static double DistanceTo(Shape shape, Vector p) => + shape.Entities.Min(e => e.ClosestPointTo(p).DistanceTo(p)); + + private static Vector Start(Entity e) => + e switch + { + Line l => l.StartPoint, + Arc a => a.StartPoint(), + _ => default, + }; + + private static Vector End(Entity e) => + e switch + { + Line l => l.EndPoint, + Arc a => a.EndPoint(), + _ => default, + }; +} From 9af97c70b0310a057e0cf3944772ee2276c494c7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 09:43:42 -0400 Subject: [PATCH 08/10] docs: document ClipperBridge, Clipper2 dependency and GPU-portable Collision Spacing offsets now go through Clipper for CPU preparation while the per-pair Collision test stays hand-rolled for a future GPU port; record that split, which offset path each caller uses, and the missing Clipper2 entry in the NuGet list. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +++-- README.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7565efa..5118387 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows witho Cross-platform CAD import tests: `dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj`. These synthetic-DXF and bend-repair tests target `net8.0`, require no external fixtures, and are included in the solution. Build the headless console independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`. -NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training). +NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `Clipper2` 2.0.0 (region offsetting, in OpenNest.Core), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training). ## Architecture @@ -31,7 +31,7 @@ Domain model, geometry, and CNC primitives organized into namespaces: - **Root** (`namespace OpenNest`): Domain model — `Nest` → `Plate[]` → `Part[]` → `Drawing` → `Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`. - **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`) and an optional `Variables` dictionary of `VariableDefinition` entries. Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. `VariableDefinition` stores a named variable's expression, resolved value, and flags (`Inline`, `Global`). `ProgramVariableManager` manages numbered machine variables for post-processor output. -- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction). +- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon` (convex NFP only), `ConvexHull`, `ConvexDecomposition`, `RotatingCalipers`, `ClipperBridge` (Clipper2 region offsetting for CPU preparation only; see Key Patterns), and `Collision` (overlap detection with Sutherland-Hodgman polygon clipping and hole subtraction; deliberately hand-rolled as the reference for a future GPU kernel, with the port contract in its class summary). - **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental). - **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding), `ExpressionEvaluator` (arithmetic expression parser for G-code variable expressions with `$name` references). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed. - **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration. @@ -134,6 +134,7 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe - Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion). - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. - Nesting uses async progress/cancellation: `IProgress` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`. +- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. Clipper is allowed only for cached CPU preparation, never in per-pair hot loops. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. - **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load). diff --git a/README.md b/README.md index 35c627e..520a105 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,7 @@ OpenNest.sln |---------|-------------| | **OpenNest** | The app you run. WinForms MDI interface with plate viewer, drawing list, CAD converter, and dialogs. | | **OpenNest.Console** | Command-line interface for batch nesting, scripting, and automation. | -| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation, bend lines, cut-offs, and drawing splitting. | +| **OpenNest.Core** | The building blocks — parts, plates, drawings, geometry, G-code representation, bend lines, cut-offs, and drawing splitting. Spacing offsets use Clipper2 (`ClipperBridge`) for CPU-side preparation; the per-pair `Collision` test stays hand-rolled so it can move to the GPU. | | **OpenNest.Engine** | The brains — fill strategies (linear, pairs, rect best-fit, extents), NFP-based pair evaluation, gravity compaction, and a pluggable engine registry. | | **OpenNest.IO** | Reads and writes files — DXF/DWG (via ACadSharp), G-code, the `.nest` ZIP format, BOM spreadsheets (via ClosedXML), and bend detection from CAD files. | | **OpenNest.Api** | High-level API for running the full nesting pipeline programmatically (import, nest, export). | From 062c7fa08f5538979d6b40dcafe10236fba88078 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 10:40:59 -0400 Subject: [PATCH 09/10] fix(geometry): tighten circumscribed flattening; stop padding the validator The Clipper validator flagged valid Opus55 and PEP layouts (P260805-03, P260626-03). Two causes: - Arc.ToPoints(circumscribe) scales every vertex out by 1/cos(step/2), endpoints included, so a 0.03125 corner fillet flattened at 0.01 poked 0.013 past the straight edges it meets. ClipperBridge now flattens itself: circumscribed arcs keep their endpoints on the arc and put interior vertices on tangent intersections, with the segment count chosen so the outward error stays within the tolerance. Arc.ToPoints is unchanged for its other callers. - The conservative padding made a layout exactly at the spacing fail. NestValidator now uses OffsetForValidation: the same conservative flattening, round joins at a tenth of the tolerance, no padding. Its only leniency is that join chord error at convex corners. With both, Opus55 is valid on all 26 benchmark jobs (25 before the Clipper migration; the old failure was a spike artifact). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- OpenNest.Benchmark/NestValidator.cs | 12 +- OpenNest.Core/Geometry/ClipperBridge.cs | 136 +++++++++++++++++- .../Geometry/ClipperBridgeFlattenTests.cs | 90 ++++++++++++ 4 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 OpenNest.Tests/Geometry/ClipperBridgeFlattenTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 5118387..435956f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,7 +134,7 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe - Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion). - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. - Nesting uses async progress/cancellation: `IProgress` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`. -- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. Clipper is allowed only for cached CPU preparation, never in per-pair hot loops. +- **Spacing offsets**: polygon consumers (`PolygonHelper`, `PartBoundary`, `NestValidator`, `CutOff`, the `LayoutPart` Draw Offset display) use `ClipperBridge.Offset`/`OffsetPerimeter`: one Clipper pass over the flattened region (perimeter positive, cutouts negative) with round joins at 1e-4 precision, so features narrower than twice the spacing collapse and closed-up holes disappear. `circumscribe: true` is the conservative mode (perimeter arcs circumscribed with endpoints kept on the arc, cutout arcs inscribed, inflation padded by the join chord error) and never under-estimates the spacing. `NestValidator` uses `OffsetForValidation` instead: the same flattening with fine joins and no padding, so a layout exactly at the spacing passes. `PartGeometry.GetOffsetPerimeterEntities`/`GetOffsetPartEntities` stay on the arc-preserving per-entity `Shape.OffsetOutward`/`OffsetInward` (internal) because directional-distance loops are much faster on native arcs; their chains are closed but may keep zero-area spikes inside the envelope. Clipper is allowed only for cached CPU preparation, never in per-pair hot loops. - `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes. - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. - **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load). diff --git a/OpenNest.Benchmark/NestValidator.cs b/OpenNest.Benchmark/NestValidator.cs index d8f4276..486fe5b 100644 --- a/OpenNest.Benchmark/NestValidator.cs +++ b/OpenNest.Benchmark/NestValidator.cs @@ -329,9 +329,10 @@ namespace OpenNest.Benchmark /// outward, cutouts offset inward, in one Clipper region offset). A cutout /// that closes up under the offset is dropped, which treats it as solid: /// conservative, since it has no room for another part at the required - /// spacing anyway. The flattening is conservative too (perimeter arcs - /// circumscribed, cutout arcs inscribed), so the check never passes a - /// layout that is closer than the spacing. + /// spacing anyway. Arcs are flattened conservatively (perimeter arcs + /// circumscribed, cutout arcs inscribed) but nothing is padded, so a layout + /// exactly at the spacing passes; the only leniency is the round-join chord + /// error at convex corners (OutlineTolerance / 10). /// part.Program is already rotated; only a Location offset is needed. /// private static PartOutline Outline(Part part, double inflateBy) @@ -352,11 +353,10 @@ namespace OpenNest.Benchmark // Adaptive tolerance instead of Shape.ToPolygon()'s default (up to 1000 // segments per arc) - arc-heavy real parts otherwise produce thousands // of vertices, which is needlessly slow for a spacing check. - var region = ClipperBridge.Offset( + var region = ClipperBridge.OffsetForValidation( profile, inflateBy > Tolerance.Epsilon ? inflateBy : 0, - OutlineTolerance, - circumscribe: true + OutlineTolerance ); var perimeter = region.LargestOuter(); diff --git a/OpenNest.Core/Geometry/ClipperBridge.cs b/OpenNest.Core/Geometry/ClipperBridge.cs index 37e470b..137f9cb 100644 --- a/OpenNest.Core/Geometry/ClipperBridge.cs +++ b/OpenNest.Core/Geometry/ClipperBridge.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Clipper2Lib; +using OpenNest.Math; namespace OpenNest.Geometry { @@ -20,6 +21,8 @@ namespace OpenNest.Geometry private const double ConservativeJoinFactor = 0.25; + private const double ValidationJoinFactor = 0.1; + /// /// Converts a polygon to a Clipper path, dropping the closing vertex and /// orienting it positive (CCW) or negative (CW). @@ -118,7 +121,7 @@ namespace OpenNest.Geometry bool circumscribe = false ) { - var polygon = perimeter.ToPolygonWithTolerance(tolerance, circumscribe); + var polygon = Flatten(perimeter, tolerance, circumscribe); return OffsetPerimeter(polygon, distance, tolerance, circumscribe); } @@ -160,6 +163,28 @@ namespace OpenNest.Geometry delta += joinTolerance + 0.5 * System.Math.Pow(10, -Precision); } + return Inflate(region, delta, joinTolerance); + } + + /// + /// Offset for checking a finished layout against its spacing. Arcs are flattened + /// as in conservative mode (perimeter arcs circumscribed, cutout arcs inscribed), + /// but round joins use a tenth of the tolerance and nothing is padded, so a layout + /// exactly at the spacing passes. The only under-estimate is the join chord error + /// at convex corners, at most a tenth of . + /// + public static OffsetRegion OffsetForValidation( + ShapeProfile profile, + double distance, + double tolerance + ) + { + var region = ToRegion(profile, tolerance, circumscribe: true); + return Inflate(region, distance, tolerance * ValidationJoinFactor); + } + + private static OffsetRegion Inflate(PathsD region, double delta, double joinTolerance) + { var inflated = delta <= 0 ? Union(region) @@ -227,6 +252,113 @@ namespace OpenNest.Geometry return largest == null ? null : ToPolygon(largest); } + /// + /// Flattens a closed shape to a polygon whose chords stay within + /// of every arc. Inscribed, the vertices lie on the + /// arcs. Circumscribed, arc endpoints stay on the arc and the interior vertices sit + /// on tangent intersections, so the polygon never falls inside the curve and never + /// pokes past the straight edges an arc meets. + /// + public static Polygon Flatten(Shape shape, double tolerance, bool circumscribe) + { + var polygon = new Polygon(); + + foreach (var entity in shape.Entities) + { + switch (entity) + { + case Line line: + polygon.Vertices.Add(line.StartPoint); + polygon.Vertices.Add(line.EndPoint); + break; + + case Arc arc: + AddArc(polygon.Vertices, arc, tolerance, circumscribe); + break; + + case Circle circle: + AddCircle(polygon.Vertices, circle, tolerance, circumscribe); + break; + } + } + + polygon.Close(); + polygon.Cleanup(); + polygon.UpdateBounds(); + return polygon; + } + + private static void AddArc(List points, Arc arc, double tolerance, bool circumscribe) + { + if (!circumscribe) + { + points.AddRange(arc.ToPoints(arc.SegmentsForTolerance(tolerance))); + return; + } + + var sweep = arc.SweepAngle(); + var segments = CircumscribedSegments(arc.Radius, sweep, tolerance); + var step = (arc.IsReversed ? -sweep : sweep) / segments; + var r = arc.Radius / System.Math.Cos(System.Math.Abs(step) / 2); + + points.Add(arc.StartPoint()); + + for (var i = 0; i < segments; i++) + { + var angle = arc.StartAngle + step * (i + 0.5); + points.Add( + new Vector( + arc.Center.X + r * System.Math.Cos(angle), + arc.Center.Y + r * System.Math.Sin(angle) + ) + ); + } + + points.Add(arc.EndPoint()); + } + + private static void AddCircle( + List points, + Circle circle, + double tolerance, + bool circumscribe + ) + { + if (!circumscribe) + { + points.AddRange(circle.ToPoints(circle.SegmentsForTolerance(tolerance))); + return; + } + + var segments = CircumscribedSegments(circle.Radius, Angle.TwoPI, tolerance); + var step = Angle.TwoPI / segments; + var r = circle.Radius / System.Math.Cos(step / 2); + + for (var i = 0; i < segments; i++) + { + points.Add( + new Vector( + circle.Center.X + r * System.Math.Cos(step * i), + circle.Center.Y + r * System.Math.Sin(step * i) + ) + ); + } + } + + /// + /// Segments for a circumscribed arc: a tangent-intersection vertex sits + /// radius / cos(step / 2) from the center, so keep that within the tolerance, and + /// keep each step at 90 degrees or less so the tangents meet close to the arc. + /// + private static int CircumscribedSegments(double radius, double sweep, double tolerance) + { + var maxHalfStep = System.Math.Acos(radius / (radius + tolerance)); + var segments = (int)System.Math.Ceiling(System.Math.Abs(sweep) / (2 * maxHalfStep)); + var quarters = (int)System.Math.Ceiling(System.Math.Abs(sweep) / Angle.HalfPI); + + return System.Math.Max(1, System.Math.Max(segments, quarters)); + } + private static PathsD Union(PathsD region) { var clipper = new ClipperD(Precision); @@ -245,7 +377,7 @@ namespace OpenNest.Geometry bool positive ) { - AddPolygon(region, shape.ToPolygonWithTolerance(tolerance, circumscribe), positive); + AddPolygon(region, Flatten(shape, tolerance, circumscribe), positive); } private static void AddPolygon(PathsD region, Polygon polygon, bool positive) diff --git a/OpenNest.Tests/Geometry/ClipperBridgeFlattenTests.cs b/OpenNest.Tests/Geometry/ClipperBridgeFlattenTests.cs new file mode 100644 index 0000000..64885e2 --- /dev/null +++ b/OpenNest.Tests/Geometry/ClipperBridgeFlattenTests.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Benchmark; +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.Geometry; + +public class ClipperBridgeFlattenTests +{ + private const double Fillet = 0.03125; + + [Theory] + [InlineData(0.01)] + [InlineData(0.001)] + public void Flatten_Circumscribed_StaysWithinStraightEdgesAndTolerance(double tolerance) + { + // Circumscribing used to push arc endpoints outward too, so a small corner fillet + // poked 0.013 past the straight edges it meets. + var polygon = ClipperBridge.Flatten(FilletedRectangle(), tolerance, circumscribe: true); + + Assert.Equal(0, polygon.BoundingBox.Left, 9); + Assert.Equal(0, polygon.BoundingBox.Bottom, 9); + Assert.Equal(2, polygon.BoundingBox.Right, 9); + Assert.Equal(4, polygon.BoundingBox.Top, 9); + + // Every vertex is on or outside the true outline, by no more than the tolerance. + var shape = FilletedRectangle(); + + foreach (var v in polygon.Vertices) + { + var d = shape.Entities.Min(e => e.ClosestPointTo(v).DistanceTo(v)); + Assert.True(d <= tolerance + 1e-9, $"Vertex {v.X},{v.Y} is {d} from the outline."); + } + } + + [Theory] + [InlineData(0.25, true)] // Exactly at the spacing. + [InlineData(0.2505, true)] + [InlineData(0.245, false)] + public void NestValidator_FilletedPartsAtSpacing(double gap, bool valid) + { + var drawing = new Drawing("filleted", FilletedRectangleProgram()); + var plate = new Plate(100, 100) { PartSpacing = 0.25 }; + + // Off-grid locations, so Clipper's 1e-4 rounding cannot line things up exactly. + var a = new Part(drawing) { Location = new Vector(10.123456, 10.654321) }; + var b = new Part(drawing) { Location = new Vector(10.123456 + 2 + gap, 10.654321) }; + + var result = NestValidator.Validate( + new List<(Plate, List)> { (plate, new List { a, b }) }, + new Dictionary { [drawing] = ("filleted", 2) } + ); + + Assert.True(valid == result.Valid, string.Join("; ", result.Violations)); + } + + /// 2 x 4 rectangle with 0.03125 corner fillets, CCW from the origin. + private static Shape FilletedRectangle() + { + var f = Fillet; + var shape = new Shape(); + shape.Entities.Add(new Line(f, 0, 2 - f, 0)); + shape.Entities.Add(new Arc(2 - f, f, f, -Angle.HalfPI, 0)); + shape.Entities.Add(new Line(2, f, 2, 4 - f)); + shape.Entities.Add(new Arc(2 - f, 4 - f, f, 0, Angle.HalfPI)); + shape.Entities.Add(new Line(2 - f, 4, f, 4)); + shape.Entities.Add(new Arc(f, 4 - f, f, Angle.HalfPI, System.Math.PI)); + shape.Entities.Add(new Line(0, 4 - f, 0, f)); + shape.Entities.Add(new Arc(f, f, f, System.Math.PI, 3 * Angle.HalfPI)); + return shape; + } + + private static Program FilletedRectangleProgram() + { + var f = Fillet; + var pgm = new Program(); + pgm.Codes.Add(new RapidMove(new Vector(f, 0))); + pgm.Codes.Add(new LinearMove(new Vector(2 - f, 0))); + pgm.Codes.Add(new ArcMove(new Vector(2, f), new Vector(2 - f, f))); + pgm.Codes.Add(new LinearMove(new Vector(2, 4 - f))); + pgm.Codes.Add(new ArcMove(new Vector(2 - f, 4), new Vector(2 - f, 4 - f))); + pgm.Codes.Add(new LinearMove(new Vector(f, 4))); + pgm.Codes.Add(new ArcMove(new Vector(0, 4 - f), new Vector(f, 4 - f))); + pgm.Codes.Add(new LinearMove(new Vector(0, f))); + pgm.Codes.Add(new ArcMove(new Vector(f, 0), new Vector(f, f))); + return pgm; + } +} From 5064340eb3f283f1a537b90e47765b729c56624b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 23 Sep 2026 11:08:58 -0400 Subject: [PATCH 10/10] fix(benchmark): validate spacing at a 0.001 flattening tolerance Conservative flattening circumscribes arcs, so at 0.01 the validator was up to 0.01 too strict along curves: PEP's P260626-03 layout, exactly 0.25 apart along an arc, failed with a 0.004 sliver. At 0.001 the worst error on either side is 0.001. Validating the 26-job benchmark set with Opus55 went from about 2 s to about 5 s. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Benchmark/NestValidator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenNest.Benchmark/NestValidator.cs b/OpenNest.Benchmark/NestValidator.cs index 486fe5b..5771059 100644 --- a/OpenNest.Benchmark/NestValidator.cs +++ b/OpenNest.Benchmark/NestValidator.cs @@ -315,7 +315,7 @@ namespace OpenNest.Benchmark ? requirement.Name : part.BaseDrawing.Name; - private const double OutlineTolerance = 0.01; + private const double OutlineTolerance = 0.001; private sealed class PartOutline {