diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
index 87560b04..68e00051 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
@@ -70,9 +70,11 @@ internal enum SupportEntries
///
/// One automatic start candidate: the owned contour choice plus its preference kind and a
/// stable geometry tie key for deterministic ranking. The choice keeps this preparation as
-/// owner; nothing here exposes or mutates the underlying shape.
+/// owner; nothing here exposes or mutates the underlying shape.
+/// is how far a corner fillet's joint lies from the sharp corner it rounds (0 otherwise), so
+/// ranking can count it on both of that corner's sides.
///
-internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
+internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
{
///
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs
index 56e7a288..30dde691 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryRanking.cs
@@ -107,16 +107,18 @@ internal static class ContourEntryRanking
if (target != null)
{
// Nearest side(s) of the candidate bounding rectangle (a corner belongs to two
- // sides within tolerance); count how many of them are facing sides.
+ // sides within tolerance, a fillet's joint within its reach of the corner it
+ // rounds); count how many of them are facing sides.
var left = p.X - minX;
var right = maxX - p.X;
var bottom = p.Y - minY;
var top = maxY - p.Y;
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
- if (facingRight && right <= min + PostVerificationGeometry.Epsilon) facing++;
- if (!facingRight && left <= min + PostVerificationGeometry.Epsilon) facing++;
- if (facingTop && top <= min + PostVerificationGeometry.Epsilon) facing++;
- if (!facingTop && bottom <= min + PostVerificationGeometry.Epsilon) facing++;
+ var near = min + PostVerificationGeometry.Epsilon + candidate.CornerReach;
+ if (facingRight && right <= near) facing++;
+ if (!facingRight && left <= near) facing++;
+ if (facingTop && top <= near) facing++;
+ if (!facingTop && bottom <= near) facing++;
}
var travel = (arrival != null ? arrival.Value.DistanceTo(p) : 0.0)
+ (target != null ? p.DistanceTo(target.Value) : 0.0);
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index 54fd0d8b..83a98797 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -602,7 +602,7 @@ public sealed class PreparedContours
/// Preferred tier: convex corners, straight midpoints, tangent joints.
private List PreferredCandidates(Shape shape, int contourOrdinal, CancellationToken token)
=> CataloguePoints(shape, token)
- .Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind))
+ .Select(p => new ContourEntryCandidate(Entry(contourOrdinal, p.EntityOrdinal, p.Point), p.Kind, p.CornerReach))
.ToList();
///
@@ -763,12 +763,14 @@ public sealed class PreparedContours
///
/// The preferred catalogue points for one contour: for each entity, its convex corner
/// (end vertex, classified from the contour's own winding) and its straight-edge midpoint,
- /// plus tangent line/arc joints. Reflex, cusp and collinear-split vertices contribute
+ /// plus tangent line/arc joints. The joints of a corner fillet (see )
+ /// are that corner's convex corners. Reflex, cusp and collinear-split vertices contribute
/// nothing; a single whole circle yields no points at all.
///
- private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind)> CataloguePoints(Shape shape, CancellationToken token)
+ private List<(int EntityOrdinal, Vector Point, AutomaticEntryKind Kind, double CornerReach)> CataloguePoints(
+ Shape shape, CancellationToken token)
{
- var found = new List<(int, Vector, AutomaticEntryKind)>();
+ var found = new List<(int, Vector, AutomaticEntryKind, double)>();
var count = shape.Entities.Count;
if (count == 1 && shape.Entities[0] is Circle)
{
@@ -792,29 +794,65 @@ public sealed class PreparedContours
switch (corner.Kind)
{
case ContourCuttingStrategy.CornerKind.Convex:
- found.Add((i, vertex, AutomaticEntryKind.ConvexCorner));
+ found.Add((i, vertex, AutomaticEntryKind.ConvexCorner, 0));
break;
case ContourCuttingStrategy.CornerKind.Smooth
when entity is Line && Next(i) is Arc:
// A line leaving into an arc: the tangent joint. The reverse travel
// order classifies the same joint from the arc, matched below.
- found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
+ found.Add(Joint(i, vertex, (i + 1) % count));
break;
// Reflex, cusp, collinear splits (smooth line→line) and arc→line joins
// of a plain straight edge are not preferred automatic starts here.
case ContourCuttingStrategy.CornerKind.Smooth
when entity is Arc && Next(i) is Line:
- found.Add((i, vertex, AutomaticEntryKind.TangentJoint));
+ found.Add(Joint(i, vertex, i));
break;
}
}
if (entity is Line line)
- found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint));
+ found.Add((i, line.MidPoint, AutomaticEntryKind.StraightMidpoint, 0));
}
return found;
Entity Next(int index) => shape.Entities[(index + 1) % count];
+
+ (int, Vector, AutomaticEntryKind, double) Joint(int entityOrdinal, Vector vertex, int arcOrdinal) =>
+ FilletReach(shape, arcOrdinal) is { } reach
+ ? (entityOrdinal, vertex, AutomaticEntryKind.ConvexCorner, reach)
+ : (entityOrdinal, vertex, AutomaticEntryKind.TangentJoint, 0);
+ }
+
+ ///
+ /// How far the joints of the arc at lie from the sharp corner it
+ /// rounds, when it is a corner fillet: tangent to straight edges at both ends, turning less
+ /// than a half circle the convex way, and reaching back along each edge no further than the
+ /// edge is long. A fillet's joints start the cut at the corner, where the mismatch when the
+ /// cut closes is least seen, instead of on a straight edge. Null for any other arc.
+ ///
+ private static double? FilletReach(Shape shape, int arcOrdinal)
+ {
+ var count = shape.Entities.Count;
+ if (shape.Entities[arcOrdinal] is not Arc arc
+ || shape.Entities[(arcOrdinal + count - 1) % count] is not Line before
+ || shape.Entities[(arcOrdinal + 1) % count] is not Line after)
+ return null;
+ var sweep = arc.SweepAngle();
+ if (sweep >= System.Math.PI - OpenNest.Math.Tolerance.Epsilon)
+ return null;
+ if (!IsSmooth(before.EndPoint, before) || !IsSmooth(arc.EndPoint(), arc))
+ return null;
+ var winding = ContourCuttingStrategy.DetermineWinding(shape);
+ if (ContourCuttingStrategy.ClassifyTurn((before.EndPoint - before.StartPoint).Normalize(),
+ (after.EndPoint - after.StartPoint).Normalize(), winding) != ContourCuttingStrategy.CornerKind.Convex)
+ return null;
+ var reach = arc.Radius * System.Math.Tan(sweep / 2);
+ return reach <= System.Math.Min(before.Length, after.Length) ? reach : null;
+
+ bool IsSmooth(Vector vertex, Entity entity) =>
+ ContourCuttingStrategy.TryClassifyAutomaticStartCorner(shape, vertex, entity, out var corner)
+ && corner.Kind == ContourCuttingStrategy.CornerKind.Smooth;
}
/// Emits every contour once in caller order, holes before perimeter, with scribes once.
diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
index 2f4d3e46..3762aee1 100644
--- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
+++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
@@ -867,10 +867,18 @@ namespace OpenNest.CNC.CuttingStrategy
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
/// not a corner.
///
- private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
+ private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) =>
+ ClassifyTurn(corner.TangentIn, corner.TangentOut, winding);
+
+ ///
+ /// The corner a contour of would make turning from travel
+ /// direction to , classified
+ /// exactly as a shared vertex is (see ).
+ ///
+ internal static CornerKind ClassifyTurn(Vector tangentIn, Vector tangentOut, RotationType winding)
{
- var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
- var dot = corner.TangentIn.DotProduct(corner.TangentOut);
+ var cross = tangentIn.X * tangentOut.Y - tangentIn.Y * tangentOut.X;
+ var dot = tangentIn.DotProduct(tangentOut);
var turn = winding == RotationType.CCW ? cross : -cross;
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs
index ef4da05b..ea3eb3de 100644
--- a/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/ContourEntryCandidateTests.cs
@@ -67,6 +67,97 @@ public class ContourEntryCandidateTests
Assert.Equal(8, forward.Count);
}
+ /// Square 0..10 whose corners are 0.5-radius fillets, counterclockwise or clockwise.
+ internal static Program RoundedSquare(bool clockwise)
+ {
+ var p = new Program();
+ p.MoveTo(0.5, 0);
+ if (clockwise)
+ {
+ p.ArcTo(new Vector(0, 0.5), new Vector(0.5, 0.5), RotationType.CW);
+ p.LineTo(0, 9.5);
+ p.ArcTo(new Vector(0.5, 10), new Vector(0.5, 9.5), RotationType.CW);
+ p.LineTo(9.5, 10);
+ p.ArcTo(new Vector(10, 9.5), new Vector(9.5, 9.5), RotationType.CW);
+ p.LineTo(10, 0.5);
+ p.ArcTo(new Vector(9.5, 0), new Vector(9.5, 0.5), RotationType.CW);
+ p.LineTo(0.5, 0);
+ return p;
+ }
+ p.LineTo(9.5, 0);
+ p.ArcTo(new Vector(10, 0.5), new Vector(9.5, 0.5), RotationType.CCW);
+ p.LineTo(10, 9.5);
+ p.ArcTo(new Vector(9.5, 10), new Vector(9.5, 9.5), RotationType.CCW);
+ p.LineTo(0.5, 10);
+ p.ArcTo(new Vector(0, 9.5), new Vector(0.5, 9.5), RotationType.CCW);
+ p.LineTo(0, 0.5);
+ p.ArcTo(new Vector(0.5, 0), new Vector(0.5, 0.5), RotationType.CCW);
+ return p;
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void CornerFilletJoints_AreConvexCorners(bool clockwise)
+ {
+ var prepared = Capture(RoundedSquare(clockwise));
+
+ var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
+
+ // Both joints of each fillet start the cut at its corner, one radius from the sharp corner.
+ var corners = candidates.Where(c => c.Kind == AutomaticEntryKind.ConvexCorner).ToList();
+ Assert.Equal(8, corners.Count);
+ Assert.All(corners, c => Assert.Equal(0.5, c.CornerReach, 9));
+ Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(10, 9.5)) < 1e-6);
+ Assert.Contains(corners, c => Distance(c.Choice.Point, new Vector(9.5, 10)) < 1e-6);
+ Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.TangentJoint);
+ Assert.Equal(4, candidates.Count(c => c.Kind == AutomaticEntryKind.StraightMidpoint));
+ Assert.All(candidates.Where(c => c.Kind == AutomaticEntryKind.StraightMidpoint),
+ c => Assert.Equal(0, c.CornerReach));
+ }
+
+ [Fact]
+ public void FilletLongerThanItsEdge_StaysATangentJoint()
+ {
+ // A 5-radius quarter arc after a 1-long bottom edge is a curve, not a rounded corner.
+ var p = new Program();
+ p.MoveTo(0, 0);
+ p.LineTo(1, 0);
+ p.ArcTo(new Vector(6, 5), new Vector(1, 5), RotationType.CCW);
+ p.LineTo(6, 6);
+ p.LineTo(0, 6);
+ p.LineTo(0, 0);
+ var prepared = Capture(p);
+
+ var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
+
+ Assert.Equal(2, candidates.Count(c => c.Kind == AutomaticEntryKind.TangentJoint));
+ Assert.DoesNotContain(candidates, c => c.Kind == AutomaticEntryKind.ConvexCorner
+ && (Distance(c.Choice.Point, new Vector(1, 0)) < 1e-6 || Distance(c.Choice.Point, new Vector(6, 5)) < 1e-6));
+ }
+
+ [Fact]
+ public void InsideCornerFillet_StaysATangentJoint()
+ {
+ // An L whose inside corner is rounded: that fillet turns the reflex way.
+ var p = new Program();
+ p.MoveTo(0, 0);
+ p.LineTo(10, 0);
+ p.LineTo(10, 4);
+ p.LineTo(5, 4);
+ p.ArcTo(new Vector(4, 5), new Vector(5, 5), RotationType.CW);
+ p.LineTo(4, 10);
+ p.LineTo(0, 10);
+ p.LineTo(0, 0);
+ var prepared = Capture(p);
+
+ var candidates = prepared.AutomaticEntryCandidates(prepared.PerimeterOrdinal);
+
+ var joints = candidates.Where(c => c.Kind == AutomaticEntryKind.TangentJoint).ToList();
+ Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(5, 4)) < 1e-6);
+ Assert.Contains(joints, c => Distance(c.Choice.Point, new Vector(4, 5)) < 1e-6);
+ }
+
[Fact]
public void TangentLineArcJoint_IsTangentJoint_NotACorner()
{
diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs
index b91440e9..739fc740 100644
--- a/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/ContourEntryRankingTests.cs
@@ -93,6 +93,22 @@ public class ContourEntryRankingTests
Assert.True(rightMid < bottomMid);
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void FilletedCorner_BeatsAFacingMidpointNearerTheRoute(bool clockwise)
+ {
+ // Travel favours the right edge's midpoint, but a long edge's middle is a last resort:
+ // the joints of the facing rounded corner count on both of its sides and lead.
+ var ranked = Catalogue(ContourEntryCandidateTests.RoundedSquare(clockwise))
+ .RankTowardNextCut(new Vector(14, 6), new Vector(12, 5)).ToList();
+
+ Assert.Equal(AutomaticEntryKind.ConvexCorner, ranked[0].Kind);
+ Assert.True(At(ranked[0], 10, 9.5) || At(ranked[0], 9.5, 10));
+ var rightMid = ranked.FindIndex(c => c.Kind == AutomaticEntryKind.StraightMidpoint && At(c, 10, 5));
+ Assert.True(ranked.FindIndex(c => At(c, 10, 0.5)) < rightMid);
+ }
+
[Fact]
public void TangentAndMidpoint_ShareARankTier()
{
diff --git a/docs/geometry/lead-in-placement.md b/docs/geometry/lead-in-placement.md
index 768cbfe0..bcc8377e 100644
--- a/docs/geometry/lead-in-placement.md
+++ b/docs/geometry/lead-in-placement.md
@@ -50,6 +50,21 @@ normal, so an arc lead-out starts on its own radius and the tab gap stays uncut.
program generation and the manual preview share them. Other lead-in styles are
unchanged.
+## Rounded corners in Plan Cutting
+
+Plan Cutting prefers to start a contour at a convex corner. The tool returns to the start
+after it has put heat into the plate, so a start partway along a straight edge can leave a
+step. A start at a corner hides it. An edge midpoint is a fallback, used only when no corner on
+a side facing the next cut fits.
+
+A corner rounded by a small fillet counts as a corner. The fillet must be tangent to straight
+edges at both ends, turn convexly through less than a half circle, and have a reach
+(`radius * tan(sweep / 2)`) no longer than either edge. Each of its two tangent joints is then
+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.
+
## Curved leads
Every curved lead (Arc, Line + Arc, Clean Hole, the arc lead-out) meets the cut tangentially and