diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
index 51296827..e59b4e58 100644
--- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
+++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
@@ -433,6 +433,22 @@ namespace OpenNest.CNC.CuttingStrategy
private static bool SameVector(Vector a, Vector b) => SameBits(a.X, b.X) && SameBits(a.Y, b.Y);
private static bool SameBits(double a, double b) => BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b);
+ // ReindexAt starts the contour at the vertex when the entry is within Tolerance.Epsilon of it
+ // (Line/Arc.SplitAt keep the entity whole), so the lead-in must end there too; otherwise the
+ // cut closes at the vertex, short of the entry by that sliver.
+ private static Vector SnapToSplitVertex(Vector point, Entity entity)
+ {
+ var (start, end) = entity switch
+ {
+ Line line => (line.StartPoint, line.EndPoint),
+ Arc arc => (arc.StartPoint(), arc.EndPoint()),
+ _ => (point, point),
+ };
+ if (point.DistanceTo(start) < Tolerance.Epsilon)
+ return start;
+ return point.DistanceTo(end) < Tolerance.Epsilon ? end : point;
+ }
+
private void EmitContour(
Program program,
Shape shape,
@@ -443,6 +459,7 @@ namespace OpenNest.CNC.CuttingStrategy
Vector? scrapPierce = null
)
{
+ point = SnapToSplitVertex(point, entity);
var contourType = forceType ?? DetectContourType(shape);
var winding = DetermineWinding(shape);
var normal = ComputeNormal(point, entity, contourType, winding);
diff --git a/OpenNest.Tests/CuttingPlanning/NearVertexEntryTests.cs b/OpenNest.Tests/CuttingPlanning/NearVertexEntryTests.cs
new file mode 100644
index 00000000..760b41c3
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/NearVertexEntryTests.cs
@@ -0,0 +1,50 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// A closest-point entry can land a hair off an entity's vertex. Emission starts the contour
+/// at that vertex (the split keeps the entity whole), so the lead-in must end there too, or
+/// the cut closes short of where it started and replay refuses the whole plate.
+///
+public class NearVertexEntryTests
+{
+ private static readonly Vector Center = new(5, 5);
+ private const double Radius = 0.3125;
+
+ // A hole drawn as two half arcs meeting at its top and bottom, inside a square perimeter.
+ private static Program HalfArcHole()
+ {
+ var p = ExplicitContourTests.Square(false);
+ p.MoveTo(Center.X, Center.Y + Radius);
+ p.ArcTo(Center.X, Center.Y - Radius, Center.X, Center.Y, RotationType.CCW);
+ p.ArcTo(Center.X, Center.Y + Radius, Center.X, Center.Y, RotationType.CCW);
+ return p;
+ }
+
+ [Theory]
+ [InlineData(4.1e-6)] // just before the top vertex, at the end of the second arc
+ [InlineData(-4.1e-6)] // just after it, at the start of the first arc
+ public void HoleCutClosesWhereItsLeadInEnds(double angleFromTop)
+ {
+ var prepared = PreparedContours.Capture(HalfArcHole(), ExplicitContourTests.Parameters());
+ var hole = Enumerable.Range(0, prepared.Count).Single(c => c != prepared.PerimeterOrdinal);
+ var approach = Center + new Vector(System.Math.Sin(angleFromTop), System.Math.Cos(angleFromTop)) * 10;
+ var entry = prepared.ClosestEntry(hole, approach);
+ var top = new Vector(Center.X, Center.Y + Radius);
+ Assert.InRange(entry.Point.DistanceTo(top), 1e-6, 1e-5);
+
+ var program = prepared.Emit(new[] { entry, prepared.ClosestEntry(prepared.PerimeterOrdinal, new Vector(0, 0)) });
+ program.Mode = Mode.Absolute;
+ var holeMotions = program.Codes.OfType().SkipWhile(m => m is RapidMove)
+ .TakeWhile(m => m is not RapidMove).ToArray();
+
+ // Line lead-in, then the two half arcs back to the lead-in's end.
+ Assert.Equal(3, holeMotions.Length);
+ Assert.True(holeMotions[^1].EndPoint.DistanceTo(holeMotions[0].EndPoint) <= 1e-9,
+ $"cut ends {holeMotions[^1].EndPoint.DistanceTo(holeMotions[0].EndPoint)} from its start");
+ Assert.True(holeMotions[0].EndPoint.DistanceTo(top) <= 1e-9);
+ }
+}