Files
OpenNest/OpenNest.Tests/Geometry/ClipperBridgeTests.cs
T
ajandClaude Opus 5.5 10fe00d8ab refactor(geometry): delete RemoveSelfIntersections
Its callers now take Clipper region offsets, which never produce the
self-intersections it patched over (and it only caught proper crossings,
so spikes survived it anyway). Polygon.OffsetEntity was its last caller;
the override is required by Entity but has no callers, so it becomes a
Clipper miter offset that keeps the Left/Right semantics and the input
winding. FindCrossing, SplitAtCrossing, SegmentsIntersect and the static
CalculateArea helper go with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 09:24:37 -04:00

307 lines
9.3 KiB
C#

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}).");
}
}
[Theory]
[InlineData(true, OffsetSide.Left, 12 * 12)]
[InlineData(true, OffsetSide.Right, 8 * 8)]
[InlineData(false, OffsetSide.Left, 8 * 8)]
[InlineData(false, OffsetSide.Right, 12 * 12)]
public void PolygonOffsetEntity_MitersToSideAndKeepsWinding(
bool ccw,
OffsetSide side,
double expectedArea
)
{
var square = new Polygon();
square.Vertices.AddRange(new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10) });
if (!ccw)
square.Vertices.Reverse();
square.Close();
var result = (Polygon)square.OffsetEntity(1, side);
Assert.Equal(expectedArea, result.Area(), 6);
Assert.Equal(square.RotationDirection(), result.RotationDirection());
}
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
""";
}