mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-02 21:51:35 -05:00
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:
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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user