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>
307 lines
9.3 KiB
C#
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
|
|
""";
|
|
}
|