diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index a96009c2..94b99819 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -528,6 +528,7 @@ namespace OpenNest.CNC.CuttingStrategy var turn = LeadTurn(contourType, winding); double leadInNormal; + var configuredLeadIn = leadIn; // A planner-placed scrap pierce replaces the configured lead-in with a straight one. if (scrapPierce is { } pierce) leadIn = StraightLeadIn(point, pierce, out leadInNormal); @@ -542,7 +543,6 @@ namespace OpenNest.CNC.CuttingStrategy leadIn = PierceZone(memoContour, shape, point, leadIn, leadInNormal, turn); } } - program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn)); var reindexedShape = shape.ReindexAt(point, entity); @@ -555,6 +555,7 @@ namespace OpenNest.CNC.CuttingStrategy var leadOutNormal = normal; if (tabbed) { + var first = reindexedShape.Entities.Count > 0 ? reindexedShape.Entities[0] : null; reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size); // Leave from where the trimmed cut actually ends, on that entity's normal: @@ -564,6 +565,18 @@ namespace OpenNest.CNC.CuttingStrategy var last = reindexedShape.Entities[^1]; leadOutPoint = EntityEndPoint(last); leadOutNormal = ComputeNormal(leadOutPoint, last, contourType, winding); + + // Straight leads either side of a tab that wraps a corner share its bisector, + // as they share the edge normal on a straight edge. + if (ReferenceEquals(leadIn, configuredLeadIn) && leadIn is LineLeadIn lineIn + && leadOut is LineLeadOut lineOut && first is Line or Arc + && TabCornerNormal(shape, point, TravelTangent(first, point), leadInNormal, lineIn, + leadOutPoint, TravelTangent(last, leadOutPoint), leadOutNormal, lineOut, winding, + turn, Parameters.GetEffectivePierceClearance()) is { } tabNormal) + { + leadInNormal = tabNormal; + leadOutNormal = tabNormal; + } } } else @@ -572,6 +585,7 @@ namespace OpenNest.CNC.CuttingStrategy Parameters.GetEffectivePierceClearance(), out leadOutNormal); } + program.Codes.AddRange(leadIn.Generate(point, leadInNormal, turn)); program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point)); program.Codes.AddRange(leadOut.Generate(leadOutPoint, leadOutNormal, turn)); } @@ -923,6 +937,46 @@ namespace OpenNest.CNC.CuttingStrategy return Angle.NormalizeRad(System.Math.Atan2(y, x)); } + /// + /// The normal straight leads either side of a perimeter tab share when the tab wraps a + /// convex corner: the bisector of the normals where the cut starts and where it stops, so + /// the leads form the V they form on a straight edge, centered on the corner. Null when the + /// tab lies on one edge or wraps a reflex turn, or when either lead would not keep + /// from the part or would cross it. + /// + private static double? TabCornerNormal( + Shape shape, + Vector start, + Vector tangentStart, + double startNormal, + LineLeadIn leadIn, + Vector end, + Vector tangentEnd, + double endNormal, + LineLeadOut leadOut, + RotationType winding, + RotationType turn, + double clearance + ) + { + if (!IsFinite(tangentStart) || !IsFinite(tangentEnd) + || ClassifyTurn(tangentEnd, tangentStart, winding) != CornerKind.Convex) + return null; + + var x = System.Math.Cos(startNormal) + System.Math.Cos(endNormal); + var y = System.Math.Sin(startNormal) + System.Math.Sin(endNormal); + if (x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon) + return null; + + var normal = Angle.NormalizeRad(System.Math.Atan2(y, x)); + var pierce = leadIn.GetPiercePoint(start, normal, turn); + var leadOutEnd = ((LinearMove)leadOut.Generate(end, normal, turn)[0]).EndPoint; + return IsClearStraightLead(shape, start, pierce, clearance) + && IsClearStraightLead(shape, end, leadOutEnd, clearance) + ? normal + : null; + } + /// /// A straight lead from to the corner stays in the scrap: /// its free end keeps from the contour and the lead diff --git a/OpenNest.Tests/CuttingStrategy/TabbedLeadOutTests.cs b/OpenNest.Tests/CuttingStrategy/TabbedLeadOutTests.cs index ea308127..ca0cce90 100644 --- a/OpenNest.Tests/CuttingStrategy/TabbedLeadOutTests.cs +++ b/OpenNest.Tests/CuttingStrategy/TabbedLeadOutTests.cs @@ -84,6 +84,58 @@ public class TabbedLeadOutTests Assert.Equal(5, arc.Center.Y, 9); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void LineLeads_TabWrappingAFilletedCorner_ShareTheCornerBisector(bool reversed) + { + // Entered at the fillet joint the tab trims back around: on its own edge's normal the + // lead-in and lead-out would sit 150 degrees apart, not the 60 degree V of a straight edge. + const double fillet = 0.015; + const double tab = 0.03; + var clean = new Program(Mode.Absolute); + if (reversed) + { + clean.MoveTo(fillet, 10); clean.ArcTo(0, 10 - fillet, fillet, 10 - fillet, RotationType.CCW); + clean.LineTo(0, 0); clean.LineTo(10, 0); clean.LineTo(10, 10); clean.LineTo(fillet, 10); + } + else + { + clean.MoveTo(0, 0); clean.LineTo(0, 10 - fillet); + clean.ArcTo(fillet, 10, fillet, 10 - fillet, RotationType.CW); + clean.LineTo(10, 10); clean.LineTo(10, 0); clean.LineTo(0, 0); + } + var part = new Part(new Drawing("tabbed-fillet", clean)); + var perimeter = LeadInTestHelpers.Profile(part).Perimeter; + var entry = reversed ? new Vector(0, 10 - fillet) : new Vector(fillet, 10); + var entity = perimeter.Entities.OfType().First(l => l.StartPoint.DistanceTo(entry) < 1e-9); + var parameters = new CuttingParameters + { + ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 60 }, + ExternalLeadOut = new LineLeadOut { Length = 0.1, ApproachAngle = 60 }, + TabsEnabled = true, + TabConfig = new NormalTab { Size = tab }, + }; + + part.ApplySingleLeadIn(parameters, entry, entity, ContourType.External); + + var geometry = part.Program.ToGeometry(); + var leadIn = Assert.IsType(Assert.Single(geometry.Where(e => e.Layer == SpecialLayers.Leadin))); + var leadOut = Assert.IsType(Assert.Single(geometry.Where(e => e.Layer == SpecialLayers.Leadout))); + LeadInTestHelpers.AssertPoint(entry, leadIn.EndPoint); + Assert.Equal(tab, leadOut.StartPoint.DistanceTo(entry), 9); + // The cut stops on the other edge, past the corner. + Assert.Equal(0, reversed ? 10 - leadOut.StartPoint.Y : leadOut.StartPoint.X, 9); + + const double bisector = 3 * System.Math.PI / 4; + var spread = OpenNest.Math.Angle.ToRadians(30); + LeadInTestHelpers.AssertPoint(entry + Polar(0.15, bisector - spread), leadIn.StartPoint); + LeadInTestHelpers.AssertPoint(leadOut.StartPoint + Polar(0.1, bisector + spread), leadOut.EndPoint); + } + + private static Vector Polar(double length, double angle) => + new(length * System.Math.Cos(angle), length * System.Math.Sin(angle)); + private static OwnedExecution Emit(Program clean, Vector approach, LeadOut leadOut, bool tabs) { var parameters = ExplicitContourTests.Parameters(); diff --git a/docs/geometry/lead-in-placement.md b/docs/geometry/lead-in-placement.md index 8ba50c9a..f018c996 100644 --- a/docs/geometry/lead-in-placement.md +++ b/docs/geometry/lead-in-placement.md @@ -46,6 +46,12 @@ the corner along the last-cut edge, with the same clearance fallback to the last edge's normal and a bisector at reflex corners. A tabbed perimeter gets no run-out: every lead-out style leaves from where the trimmed cut actually ends, on that end's normal, so an arc lead-out starts on its own radius and the tab gap stays uncut. +When the tab wraps a convex corner (the cut starts on one edge and stops on the other, as at a +fillet joint), a straight lead-in and straight lead-out both take the bisector of those two +ends' normals instead, so they form the same V centered on the corner that they form on a +straight edge. Each lead must keep the pierce clearance and cross the part only where it meets +the cut; otherwise both keep their own edge's normal. The straight in-line corner lead-in and +curved leads are unchanged. `ContourCuttingStrategy.ResolveLeadIn`/`ResolveLeadOut` own these rules; program generation and the manual preview share them. Other lead-in styles are unchanged. @@ -63,7 +69,8 @@ edges at both ends, turn convexly through less than a half circle, and have a re a convex corner entry (`PreparedContours.FilletReach`). Ranking counts the joint on both sides of the corner it rounds, as it would a sharp corner. Longer curves, half-circle ends and inside (reflex) fillets keep their tangent joints, which rank level with edge midpoints. The lead-in at -a fillet's joint is the normal one for its edge, since emission still sees a smooth joint there. +a fillet's joint is the normal one for its edge, since emission still sees a smooth joint there, +unless a tab wraps the fillet (see above). ## Curved leads