From 8cd9b2cbe3afc48000e38008ac8b41f6024bda79 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 22 Oct 2026 22:18:04 -0400 Subject: [PATCH] fix(planning): close hole cuts whose entry lands a hair off a vertex A closest-point entry within Tolerance.Epsilon of a line or arc vertex made ReindexAt start the contour at the vertex while the lead-in still ended at the off-vertex point, so the cut closed short of its start and replay refused the whole plate. Snap such entries onto the vertex before emitting the lead-in. Co-Authored-By: Claude Opus 5.5 --- .../CuttingStrategy/ContourCuttingStrategy.cs | 17 +++++++ .../CuttingPlanning/NearVertexEntryTests.cs | 50 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 OpenNest.Tests/CuttingPlanning/NearVertexEntryTests.cs 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); + } +}