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
@@ -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
""";
}