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); + } +}