From 52e421dcbee78f46b11f92e4dc9163b9e1d36c96 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 2 Nov 2026 20:56:39 -0500 Subject: [PATCH] fix(cutting): center straight leads on a corner a tab wraps A tab entered at a fillet joint trims back around the corner, so the cut stops on one edge and starts on the other. Each straight lead took its own edge's normal and the pair sat 150 degrees apart, reading as one bent line through the corner. Both now take the bisector of the two ends' normals and form the same V they form on a straight edge, falling back to their own normals when either lead would not clear the part. Co-Authored-By: Claude Opus 5.5 --- .../CuttingStrategy/ContourCuttingStrategy.cs | 56 ++++++++++++++++++- .../CuttingStrategy/TabbedLeadOutTests.cs | 52 +++++++++++++++++ docs/geometry/lead-in-placement.md | 9 ++- 3 files changed, 115 insertions(+), 2 deletions(-) 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