diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index d9205369..8689f24c 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -96,7 +96,9 @@ public sealed class PreparedContours
rejected.Add(pin);
continue;
}
- var choice = new ContourChoice(contour, pin.EntityOrdinal, pin.Point) { Owner = this };
+ var choice = new ContourChoice(contour, pin.EntityOrdinal,
+ SnapToVertex(shapes[contour], pin.EntityOrdinal, pin.Point))
+ { Owner = this };
ValidateChoice(choice);
pinned[contour] = choice;
pinNumbers[contour] = pin.PinIndex + 1;
@@ -401,8 +403,12 @@ public sealed class PreparedContours
void Add(int entityOrdinal, Vector point)
{
token.ThrowIfCancellationRequested();
- if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon))
- entries.Add(Entry(contourOrdinal, entityOrdinal, point));
+ if (entries.Count >= maxEntries)
+ return;
+ // Compare the entry as emitted: snapping can bring two raw points together.
+ var entry = Entry(contourOrdinal, entityOrdinal, point);
+ if (!entries.Any(e => e.Point.DistanceTo(entry.Point) <= PostVerificationGeometry.Epsilon))
+ entries.Add(entry);
}
}
@@ -427,6 +433,7 @@ public sealed class PreparedContours
public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point)
{
+ point = SnapToVertex(GetShape(contourOrdinal), entityOrdinal, point);
var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this };
ValidateChoice(choice);
return choice;
@@ -444,6 +451,7 @@ public sealed class PreparedContours
PostVerificationGeometry.Validate(pierce);
var shape = GetShape(PerimeterOrdinal);
var point = shape.ClosestPointTo(pierce, out var entity);
+ point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
{
Owner = this,
@@ -630,9 +638,10 @@ public sealed class PreparedContours
var point = inward ? line.StartPoint + offset : line.EndPoint - offset;
// Strictly inside the edge by projection parameter (distance alone loses the
// sign when a short edge is overshoot): never the corner, never the far
- // endpoint (where a reflex vertex might sit). Short edges omit the point.
+ // endpoint (where a reflex vertex might sit). Short edges omit the point. The
+ // margin is the vertex snap distance, so Entry cannot snap it onto either end.
var t = (point.X - line.StartPoint.X) * direction.X + (point.Y - line.StartPoint.Y) * direction.Y;
- if (t <= PostVerificationGeometry.Epsilon || t >= line.Length - PostVerificationGeometry.Epsilon)
+ if (t <= OpenNest.Math.Tolerance.Epsilon || t >= line.Length - OpenNest.Math.Tolerance.Epsilon)
return;
fallbacks.Add(Fallback(entityOrdinal, point, AutomaticEntryKind.NearCorner));
}
@@ -897,6 +906,33 @@ public sealed class PreparedContours
&& shape.ToPolygonWithTolerance(PlateOverlapAnalyzer.ChordTolerance).ContainsPoint(point);
}
+ ///
+ /// Emission splits a contour at its entry with /,
+ /// which treat a point within of an endpoint as that endpoint and
+ /// start the contour there. An entry that close to a vertex is that vertex, so the lead-in ends
+ /// where the contour starts: otherwise the span between them (5e-8 in on imported arcs whose
+ /// stored centre sits beside the chord's midpoint) is never cut.
+ ///
+ private static Vector SnapToVertex(Shape shape, int entityOrdinal, Vector point)
+ {
+ if (entityOrdinal < 0 || entityOrdinal >= shape.Entities.Count
+ || shape.Entities[entityOrdinal] is not (Line or Arc))
+ return point;
+ var entity = shape.Entities[entityOrdinal];
+ // Within the verifier's epsilon the entry already cuts the contour whole: leave it, so
+ // programs emitted before this snap (saved pinned entries) re-emit unchanged.
+ bool Snaps(Vector vertex)
+ {
+ var distance = point.DistanceTo(vertex);
+ return distance > PostVerificationGeometry.Epsilon && distance < OpenNest.Math.Tolerance.Epsilon;
+ }
+ var start = Start(entity);
+ if (point.DistanceTo(start) < OpenNest.Math.Tolerance.Epsilon)
+ return Snaps(start) ? start : point;
+ var end = End(entity);
+ return Snaps(end) ? end : point;
+ }
+
private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint();
private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint();
diff --git a/OpenNest.Tests/CuttingPlanning/ImportedArcHoleTests.cs b/OpenNest.Tests/CuttingPlanning/ImportedArcHoleTests.cs
new file mode 100644
index 00000000..6c52e351
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ImportedArcHoleTests.cs
@@ -0,0 +1,180 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+// A 0.277 in mounting hole imported from PEP (a panel door): two half circles whose stored
+// centre sits 5e-8 beside the chord's midpoint. The closest entry to an approach from straight
+// below lands on the hole's lowest point, 5e-8 along the first arc from its start. Emission
+// splits the contour there with the 1e-5 endpoint tolerance, so the contour starts at the vertex;
+// the entry must start there too, or the 5e-8 between them is never cut and the plan's own
+// verification refuses it.
+public class ImportedArcHoleTests
+{
+ private const double Offset = 4.94e-8;
+ private static readonly Vector Bottom = new(1, 0.5);
+ private static readonly Vector Top = new(1, 0.777);
+
+ internal static CuttingParameters SweepParameters()
+ {
+ var lead = new LineLeadIn { Length = 0.15, ApproachAngle = 90 };
+ return new CuttingParameters
+ {
+ ExternalLeadIn = lead,
+ InternalLeadIn = lead,
+ ArcCircleLeadIn = lead,
+ ExternalLeadOut = new NoLeadOut(),
+ InternalLeadOut = new NoLeadOut(),
+ ArcCircleLeadOut = new NoLeadOut(),
+ PierceClearance = 0.0625
+ };
+ }
+
+ private static Program PartWithHole(double offset, bool holeBelow = false)
+ {
+ var p = new Program();
+ p.MoveTo(0, 0); p.LineTo(2, 0); p.LineTo(2, 2); p.LineTo(0, 2); p.LineTo(0, 0);
+ if (holeBelow)
+ {
+ // A smaller hole straight below, cut first, so the mounting hole is approached from below.
+ p.MoveTo(1 + offset, 0.1);
+ p.ArcTo(new Vector(1 + offset, 0.3), new Vector(1 + offset, 0.2), RotationType.CCW);
+ p.ArcTo(new Vector(1 + offset, 0.1), new Vector(1 + offset, 0.2), RotationType.CCW);
+ }
+ var centre = new Vector(1 + offset, (Bottom.Y + Top.Y) / 2);
+ p.MoveTo(Bottom.X, Bottom.Y);
+ p.ArcTo(Top, centre, RotationType.CCW);
+ p.ArcTo(Bottom, centre, RotationType.CCW);
+ return p;
+ }
+
+ [Theory]
+ [InlineData(Offset)]
+ [InlineData(-Offset)]
+ public void EntryWithinTheSplitToleranceOfAVertex_StartsExactlyAtTheVertex(double offset)
+ {
+ var prepared = PreparedContours.Capture(PartWithHole(offset), SweepParameters());
+ var hole = Enumerable.Range(0, prepared.Count).Single(i => i != prepared.PerimeterOrdinal);
+
+ var choice = prepared.ClosestEntry(hole, new Vector(1 + offset, 0.2));
+
+ Assert.Equal(Bottom.X, choice.Point.X, 12);
+ Assert.Equal(Bottom.Y, choice.Point.Y, 12);
+ }
+
+ // The same holds for the other entries the planner does not pick from vertices: a drawing pin
+ // and a perimeter pierce placed in scrap whose closest contour point lies beside a corner.
+ [Fact]
+ public void PinBesideACorner_StartsExactlyAtTheCorner()
+ {
+ var drawing = PinnedContourTests.SquareWithPin(new Vector(3e-6, 0), PinnedContourTests.Corner());
+ var plain = PreparedContours.Capture(drawing.Program, PinnedContourTests.NestSettings());
+ var prepared = PreparedContours.Capture(drawing.Program, PinnedContourTests.NestSettings(),
+ CutPinResolver.Resolve(drawing, 0, plain));
+
+ var pinned = Assert.IsType(prepared.PinnedEntry(prepared.PerimeterOrdinal));
+
+ Assert.Equal(0, pinned.Point.DistanceTo(Vector.Zero), 12);
+ }
+
+ [Fact]
+ public void ScrapPierceBesideACorner_EntersExactlyAtTheCorner()
+ {
+ var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), PinnedContourTests.NestSettings());
+
+ var choice = prepared.ScrapPierceEntry(new Vector(-0.5, 3e-6));
+
+ Assert.Equal(0, choice.Point.DistanceTo(Vector.Zero), 12);
+ }
+
+ // Snapping must not hand automatic selection a forbidden vertex: a near-corner inset on an edge
+ // only just longer than twice the lead-in stays clear of the reflex corner at its far end.
+ [Fact]
+ public void NearCornerInset_NeverSnapsOntoAReflexVertex()
+ {
+ var reflex = new Vector(1.699995, 1);
+ var p = new Program();
+ p.MoveTo(0, 0); p.LineTo(2, 0); p.LineTo(2, 1); p.LineTo(reflex.X, reflex.Y);
+ p.LineTo(reflex.X, 2); p.LineTo(0, 2); p.LineTo(0, 0);
+ var prepared = PreparedContours.Capture(p, SweepParameters());
+
+ var candidates = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal);
+
+ Assert.DoesNotContain(candidates, c => c.Choice.Point.DistanceTo(reflex) <= 1e-9);
+ }
+
+ // De-duplication compares the entries as emitted (snapped), so an edge shorter than the snap
+ // distance does not offer its start twice.
+ [Fact]
+ public void Entries_OfferEachSnappedPointOnce()
+ {
+ var p = new Program();
+ p.MoveTo(0, 0); p.LineTo(1, 0); p.LineTo(1, 1); p.LineTo(0, 1); p.LineTo(0, 5e-6); p.LineTo(0, 0);
+ var prepared = PreparedContours.Capture(p, SweepParameters());
+
+ var points = prepared.Entries(prepared.PerimeterOrdinal, new Vector(-1, -1)).Select(e => e.Point).ToList();
+
+ for (var i = 0; i < points.Count; i++)
+ for (var j = i + 1; j < points.Count; j++)
+ Assert.True(points[i].DistanceTo(points[j]) > 1e-8, $"{points[i]} offered twice");
+ }
+
+ // An entry already within the verifier's epsilon of a vertex was cut whole before; it is left
+ // exactly where it was, so saved pinned programs re-emit unchanged.
+ [Fact]
+ public void EntryWithinVerifierEpsilonOfAVertex_IsUnchanged()
+ {
+ var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), PinnedContourTests.NestSettings());
+ var point = new Vector(-0.5, 5e-9);
+
+ var choice = prepared.ScrapPierceEntry(point);
+
+ Assert.Equal(5e-9, choice.Point.Y, 15);
+ }
+
+ // The emitted hole is whole: its cut, from the lead-in's end, covers the drawn contour's full length.
+ [Theory]
+ [InlineData(Offset)]
+ [InlineData(-Offset)]
+ public void EntryNearAVertex_EmitsTheWholeHole(double offset)
+ {
+ var prepared = PreparedContours.Capture(PartWithHole(offset), SweepParameters());
+ var hole = Enumerable.Range(0, prepared.Count).Single(i => i != prepared.PerimeterOrdinal);
+ var choice = prepared.ClosestEntry(hole, new Vector(1 + offset, 0.2));
+
+ var motions = ExecutionMotionReader.Read(prepared.EmitCandidateForValidation(choice), Vector.Zero, null, default).Motions;
+
+ // Emission marks contour motion Display; replay counts it as cut, as the verifier does.
+ var cuts = motions.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).ToList();
+ var lead = motions.Last(m => m.Layer == LayerType.Leadin);
+ var nominal = OpenNest.Converters.ConvertProgram.ToGeometry(PartWithHole(offset))
+ .OfType().Sum(a => a.Length);
+ Assert.Equal(nominal, cuts.Sum(m => m.Length), 12);
+ Assert.Equal(0, lead.End.DistanceTo(cuts[0].Curve.Start), 12);
+ Assert.Equal(0, cuts[^1].End.DistanceTo(cuts[0].Curve.Start), 12);
+ }
+
+ [Theory]
+ [InlineData(Offset)]
+ [InlineData(-Offset)]
+ [InlineData(0)]
+ public void ImportedMountingHole_PlansAndApplies(double offset)
+ {
+ foreach (var below in new[] { false, true })
+ PlansAndApplies(PartWithHole(offset, below));
+ }
+
+ private static void PlansAndApplies(Program program)
+ {
+ var plate = new Plate(new Size(10, 10));
+ plate.Parts.Add(new Part(new Drawing("hole", program), new Vector(1, 1)));
+
+ var proposal = CuttingPlanBatch.Capture([plate], SweepParameters(), false).Plan();
+
+ Assert.True(proposal.CanApply, string.Join("\n", proposal.Describe("in")));
+ Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status);
+ }
+}