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