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