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