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 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-23 09:21:48 -04:00
co-authored by Claude Opus 5.5
parent 12f97474b7
commit 9b9386b029
8 changed files with 180 additions and 254 deletions
+39 -12
View File
@@ -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<Entity> { 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<Vector>();
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)
/// <summary>
/// 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.
/// </summary>
private static List<Entity> 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<Entity>().ToList();
}
private Vector MakePoint(double cutCoord, double lineCoord) =>
+52 -9
View File
@@ -18,6 +18,8 @@ namespace OpenNest.Geometry
private const double MiterLimit = 2.0;
private const double ConservativeJoinFactor = 0.25;
/// <summary>
/// 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 <paramref name="tolerance"/> from the true arc.
/// </summary>
/// <param name="circumscribe">
/// 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.
/// </param>
public static OffsetRegion Offset(
ShapeProfile profile,
@@ -105,8 +107,39 @@ namespace OpenNest.Geometry
return Offset(region, distance, tolerance, circumscribe);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Offsets a closed polygon outward, whatever its winding.
/// </summary>
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);
}
/// <summary>
/// Offsets an already-flattened region (outers positive, holes negative).
/// A distance of zero only unions the region, with no conservative padding.
/// </summary>
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<Polygon>(), new List<Polygon>());
@@ -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);
+1 -154
View File
@@ -49,7 +49,7 @@ namespace OpenNest
/// <summary>
/// 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.
/// </summary>
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
{
@@ -149,75 +149,6 @@ namespace OpenNest
return result;
}
public static List<Line> 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<Line>();
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<Line> 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<Line>();
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<Line> GetPartLines(
Part part,
Vector facingDirection,
@@ -240,40 +171,6 @@ namespace OpenNest
return lines;
}
public static List<Line> 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<Line>();
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;
}
/// <summary>
/// Returns only polygon edges whose outward normal faces the specified direction vector.
/// </summary>
@@ -353,55 +250,5 @@ namespace OpenNest
return lines;
}
private static void AddOffsetLines(
List<Line> 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<Line> 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<Line> 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));
}
}
}