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 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-23 09:12:34 -04:00
co-authored by Claude Opus 5.5
parent 7964c87eb9
commit a6bc9d8be6
3 changed files with 497 additions and 50 deletions
+210
View File
@@ -0,0 +1,210 @@
using System.Collections.Generic;
using Clipper2Lib;
namespace OpenNest.Geometry
{
/// <summary>
/// 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 (<see cref="Collision"/>) stay hand-rolled so they can
/// be ported to a GPU kernel.
/// </summary>
public static class ClipperBridge
{
/// <summary>
/// Decimal places Clipper keeps (1e-4 in either inches or mm).
/// </summary>
public const int Precision = 4;
private const double MiterLimit = 2.0;
/// <summary>
/// Converts a polygon to a Clipper path, dropping the closing vertex and
/// orienting it positive (CCW) or negative (CW).
/// </summary>
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;
}
/// <summary>
/// Converts a polygon to a Clipper path with an optional offset, dropping the
/// closing vertex and keeping the polygon's own winding.
/// </summary>
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;
}
/// <summary>
/// Converts a Clipper path to a closed polygon with updated bounds.
/// </summary>
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;
}
/// <summary>
/// Flattens a profile into a Clipper region: perimeter positive, cutouts negative.
/// </summary>
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;
}
/// <summary>
/// Offsets a part region outward by <paramref name="distance"/>: 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 <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.
/// </param>
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);
}
/// <summary>
/// Offsets an already-flattened region (outers positive, holes negative).
/// </summary>
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<Polygon>(), new List<Polygon>());
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);
}
}
/// <summary>
/// Result of <see cref="ClipperBridge.Offset(ShapeProfile, double, double, bool)"/>:
/// outer boundaries (CCW) and holes (CW), as closed polygons.
/// </summary>
public sealed record OffsetRegion(List<Polygon> Outers, List<Polygon> Holes)
{
/// <summary>
/// The outer boundary with the largest area, or null when the region is empty.
/// </summary>
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;
}
}
}
@@ -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
""";
}
+6 -50
View File
@@ -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<PointF[]> _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<PointF[]>(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<PointF[]>(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();