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