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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-02 22:08:18 -05:00
1 parent 6ee81a3501
commit 52e421dcbe
3 files changed
+115 -2

No files matched your search

@@ -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));
}
/// <summary>
/// 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
/// <paramref name="clearance"/> from the part or would cross it.
/// </summary>
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;
}
/// <summary>
/// A straight lead from <paramref name="end"/> to the corner stays in the scrap:
/// its free end keeps <paramref name="clearance"/> from the contour and the lead
@@ -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<Line>().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<Line>(Assert.Single(geometry.Where(e => e.Layer == SpecialLayers.Leadin)));
var leadOut = Assert.IsType<Line>(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();
+8 -1
View File
@@ -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