feat(planning): start contours at rounded corners, not mid-edge

Plan Cutting ranked a convex corner first, but a corner rounded by a small
fillet had no corner vertex: its tangent joints ranked level with straight
edge midpoints, so travel decided and a large part (P230323-02 plate 8,
PT06, 0.015 corner fillets) started in the middle of a 64-inch edge. A start
there can leave a step where the cut closes after the head has put heat into
the plate; at a corner it is hidden.

A convex fillet tangent to straight edges at both ends, turning less than a
half circle and reaching no further along either edge than its length, now
makes its two joints convex-corner entries. Each carries its reach from the
sharp corner it rounds, so ranking counts it on both sides of that corner.
Longer curves, half-circle ends and inside fillets keep their tangent joints.
Every perimeter on that plate now starts at a corner; the problem-nest corpus
has no plate below its baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-27 13:42:30 -04:00
1 parent 0ede9443de
commit 0f1e5fb568
7 files changed
+190 -18

No files matched your search

@@ -70,9 +70,11 @@ internal enum SupportEntries
/// <summary>
/// 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. <paramref name="CornerReach"/>
/// 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.
/// </summary>
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind)
internal sealed record ContourEntryCandidate(ContourChoice Choice, AutomaticEntryKind Kind, double CornerReach = 0)
{
/// <summary>
/// Stable geometric tie key: the point quantized to the preparation epsilon grid, so
@@ -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);
@@ -602,7 +602,7 @@ public sealed class PreparedContours
/// <summary>Preferred tier: convex corners, straight midpoints, tangent joints.</summary>
private List<ContourEntryCandidate> 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();
/// <summary>
@@ -763,12 +763,14 @@ public sealed class PreparedContours
/// <summary>
/// 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 <see cref="FilletReach"/>)
/// are that corner's convex corners. Reflex, cusp and collinear-split vertices contribute
/// nothing; a single whole circle yields no points at all.
/// </summary>
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);
}
/// <summary>
/// How far the joints of the arc at <paramref name="arcOrdinal"/> 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.
/// </summary>
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;
}
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
@@ -867,10 +867,18 @@ namespace OpenNest.CNC.CuttingStrategy
/// A turn whose offset over the tangent is within chaining tolerance is smooth,
/// not a corner.
/// </summary>
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding)
private static CornerKind ClassifyCorner(ContourCorner corner, RotationType winding) =>
ClassifyTurn(corner.TangentIn, corner.TangentOut, winding);
/// <summary>
/// The corner a contour of <paramref name="winding"/> would make turning from travel
/// direction <paramref name="tangentIn"/> to <paramref name="tangentOut"/>, classified
/// exactly as a shared vertex is (see <see cref="TryClassifyAutomaticStartCorner"/>).
/// </summary>
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)
@@ -67,6 +67,97 @@ public class ContourEntryCandidateTests
Assert.Equal(8, forward.Count);
}
/// <summary>Square 0..10 whose corners are 0.5-radius fillets, counterclockwise or clockwise.</summary>
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()
{
@@ -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()
{
+15
View File
@@ -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