fix(cnc): extend first-cut edge for outside corner lead-ins
A straight lead-in at a convex outside-perimeter corner now runs along the extension of the edge cut first, so the torch enters on that line and keeps cutting it. The result no longer depends on which of the corner's two edges auto-assign or the manual cursor picked, which made placement flip between straight and 90 degrees. The approach angle is ignored at such corners. The straight lead falls back to the first-cut edge normal when its pierce would be closer than PierceClearance to the contour (very flat or tessellated corners). Reflex perimeter corners bisect the notch. Line lead-outs run on straight past a convex corner along the last-cut edge, except on tabbed perimeters. Program generation and the Place Lead-in preview share ResolveLeadIn/ResolveLeadOut.
This commit is contained in:
@@ -367,20 +367,26 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
return;
|
||||
}
|
||||
|
||||
var leadInNormal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
||||
leadIn = ResolveLeadIn(shape, point, entity, contourType, leadIn, winding,
|
||||
Parameters.PierceClearance, out var leadInNormal);
|
||||
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
|
||||
|
||||
var reindexedShape = shape.ReindexAt(point, entity);
|
||||
|
||||
if (
|
||||
Parameters.TabsEnabled
|
||||
var tabbed = Parameters.TabsEnabled
|
||||
&& Parameters.TabConfig != null
|
||||
&& contourType == ContourType.External
|
||||
)
|
||||
&& contourType == ContourType.External;
|
||||
if (tabbed)
|
||||
reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size);
|
||||
|
||||
// A tab leaves the contour short of the corner; a run-out through it would cut the tab.
|
||||
var leadOutNormal = normal;
|
||||
if (!tabbed)
|
||||
leadOut = ResolveLeadOut(shape, point, entity, contourType, leadOut, winding,
|
||||
Parameters.PierceClearance, out leadOutNormal);
|
||||
|
||||
program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point));
|
||||
program.Codes.AddRange(leadOut.Generate(point, normal, winding));
|
||||
program.Codes.AddRange(leadOut.Generate(point, leadOutNormal, winding));
|
||||
}
|
||||
|
||||
private void EmitScribeContours(Program program, List<Entity> scribeEntities)
|
||||
@@ -453,17 +459,136 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
{
|
||||
var normal = ComputeNormal(point, entity, contourType, winding);
|
||||
if (contourType != ContourType.Internal || leadIn is not LineLeadIn
|
||||
|| entity is not (Line or Arc) || entity.Length <= Tolerance.Epsilon
|
||||
|| shape.Entities.Count < 2 || !shape.IsClosed())
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
return normal;
|
||||
|
||||
return BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the lead-in to emit at <paramref name="point"/> and the normal to
|
||||
/// generate it with. At a corner of an outside perimeter, a straight
|
||||
/// (<see cref="LineLeadIn"/>) lead-in extends the edge cut first so the torch
|
||||
/// enters on that edge's line, provided the pierce keeps
|
||||
/// <paramref name="pierceClearance"/> from the contour; the approach angle is
|
||||
/// ignored there. Otherwise it is perpendicular to the edge cut first, and at a
|
||||
/// reflex corner it bisects the notch. The result does not depend on which of
|
||||
/// the two edges meeting at the corner was picked. Other styles and contour
|
||||
/// types keep <see cref="ComputeLeadInNormal"/>.
|
||||
/// </summary>
|
||||
public static LeadIn ResolveLeadIn(
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType contourType,
|
||||
LeadIn leadIn,
|
||||
RotationType winding,
|
||||
double pierceClearance,
|
||||
out double normal
|
||||
)
|
||||
{
|
||||
normal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
|
||||
if (contourType != ContourType.External || leadIn is not LineLeadIn line
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
return leadIn;
|
||||
|
||||
switch (ClassifyCorner(corner, winding))
|
||||
{
|
||||
case CornerKind.Convex:
|
||||
var pierce = point - corner.TangentOut * line.Length;
|
||||
if (IsClearStraightLead(shape, point, pierce, pierceClearance))
|
||||
{
|
||||
normal = Angle.NormalizeRad((-corner.TangentOut).Angle());
|
||||
return new LineLeadIn { Length = line.Length, ApproachAngle = 90 };
|
||||
}
|
||||
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
return leadIn;
|
||||
case CornerKind.Smooth:
|
||||
normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
return leadIn;
|
||||
case CornerKind.Reflex:
|
||||
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||
return leadIn;
|
||||
default:
|
||||
return leadIn;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lead-out counterpart of <see cref="ResolveLeadIn"/>. At a convex outside
|
||||
/// perimeter corner a <see cref="LineLeadOut"/> runs straight on past the corner
|
||||
/// along the edge cut last, when its end keeps <paramref name="clearance"/> from
|
||||
/// the contour; otherwise it is perpendicular to that edge. At a reflex corner it
|
||||
/// bisects the notch. Other styles and contour types keep the entity normal.
|
||||
/// </summary>
|
||||
public static LeadOut ResolveLeadOut(
|
||||
Shape shape,
|
||||
Vector point,
|
||||
Entity entity,
|
||||
ContourType contourType,
|
||||
LeadOut leadOut,
|
||||
RotationType winding,
|
||||
double clearance,
|
||||
out double normal
|
||||
)
|
||||
{
|
||||
normal = ComputeNormal(point, entity, contourType, winding);
|
||||
if (contourType != ContourType.External || leadOut is not LineLeadOut line
|
||||
|| !TryGetCorner(shape, point, entity, out var corner))
|
||||
return leadOut;
|
||||
|
||||
switch (ClassifyCorner(corner, winding))
|
||||
{
|
||||
case CornerKind.Convex:
|
||||
var end = point + corner.TangentIn * line.Length;
|
||||
if (IsClearStraightLead(shape, point, end, clearance))
|
||||
{
|
||||
normal = Angle.NormalizeRad(corner.TangentIn.Angle());
|
||||
return new LineLeadOut { Length = line.Length, ApproachAngle = 90 };
|
||||
}
|
||||
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
return leadOut;
|
||||
case CornerKind.Smooth:
|
||||
normal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
return leadOut;
|
||||
case CornerKind.Reflex:
|
||||
normal = BisectCorner(point, corner, contourType, winding) ?? normal;
|
||||
return leadOut;
|
||||
default:
|
||||
return leadOut;
|
||||
}
|
||||
}
|
||||
|
||||
private enum CornerKind
|
||||
{
|
||||
Convex,
|
||||
Reflex,
|
||||
Smooth,
|
||||
Cusp,
|
||||
}
|
||||
|
||||
/// <summary>A contour vertex: the entity cut into it and the one cut away from it.</summary>
|
||||
private readonly record struct ContourCorner(
|
||||
Entity Incoming,
|
||||
Entity Outgoing,
|
||||
Vector TangentIn,
|
||||
Vector TangentOut
|
||||
);
|
||||
|
||||
private static bool TryGetCorner(Shape shape, Vector point, Entity entity, out ContourCorner corner)
|
||||
{
|
||||
corner = default;
|
||||
if (entity is not (Line or Arc) || entity.Length <= Tolerance.Epsilon
|
||||
|| shape.Entities.Count < 2 || !shape.IsClosed())
|
||||
return false;
|
||||
|
||||
var index = shape.Entities.IndexOf(entity);
|
||||
if (index < 0)
|
||||
return normal;
|
||||
return false;
|
||||
|
||||
var atStart = point.DistanceTo(EntityStartPoint(entity)) <= Tolerance.Epsilon;
|
||||
if (!atStart && point.DistanceTo(EntityEndPoint(entity)) > Tolerance.Epsilon)
|
||||
return normal;
|
||||
return false;
|
||||
|
||||
var adjacentIndex = atStart
|
||||
? (index + shape.Entities.Count - 1) % shape.Entities.Count
|
||||
@@ -473,20 +598,95 @@ namespace OpenNest.CNC.CuttingStrategy
|
||||
|
||||
if (adjacent is not (Line or Arc) || adjacent.Length <= Tolerance.Epsilon
|
||||
|| point.DistanceTo(adjacentPoint) > Tolerance.Epsilon)
|
||||
return normal;
|
||||
return false;
|
||||
|
||||
var adjacentNormal = ComputeNormal(point, adjacent, contourType, winding);
|
||||
var incoming = atStart ? adjacent : entity;
|
||||
var outgoing = atStart ? entity : adjacent;
|
||||
var tangentIn = TravelTangent(incoming, point);
|
||||
var tangentOut = TravelTangent(outgoing, point);
|
||||
if (!IsFinite(tangentIn) || !IsFinite(tangentOut))
|
||||
return false;
|
||||
|
||||
corner = new ContourCorner(incoming, outgoing, tangentIn, tangentOut);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Unit direction of travel along a line or arc at a point on it.</summary>
|
||||
private static Vector TravelTangent(Entity entity, Vector point)
|
||||
{
|
||||
if (entity is Line line)
|
||||
return (line.EndPoint - line.StartPoint).Normalize();
|
||||
|
||||
var arc = (Arc)entity;
|
||||
var radial = (point - arc.Center).Normalize();
|
||||
return arc.IsReversed ? new Vector(radial.Y, -radial.X) : new Vector(-radial.Y, radial.X);
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector v) => double.IsFinite(v.X) && double.IsFinite(v.Y);
|
||||
|
||||
/// <summary>
|
||||
/// Convex corners point away from the part (interior angle under 180 degrees).
|
||||
/// 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)
|
||||
{
|
||||
var cross = corner.TangentIn.X * corner.TangentOut.Y - corner.TangentIn.Y * corner.TangentOut.X;
|
||||
var dot = corner.TangentIn.DotProduct(corner.TangentOut);
|
||||
var turn = winding == RotationType.CCW ? cross : -cross;
|
||||
|
||||
if (System.Math.Abs(turn) <= Tolerance.Epsilon)
|
||||
return dot > 0 ? CornerKind.Smooth : CornerKind.Cusp;
|
||||
|
||||
return turn > 0 ? CornerKind.Convex : CornerKind.Reflex;
|
||||
}
|
||||
|
||||
private static double? BisectCorner(
|
||||
Vector point,
|
||||
ContourCorner corner,
|
||||
ContourType contourType,
|
||||
RotationType winding
|
||||
)
|
||||
{
|
||||
var normal = ComputeNormal(point, corner.Outgoing, contourType, winding);
|
||||
var adjacentNormal = ComputeNormal(point, corner.Incoming, contourType, winding);
|
||||
// Sum unit normals rather than averaging angles (which fails at 0/2π).
|
||||
// Winding makes this point into the scrap even at reflex corners.
|
||||
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
|
||||
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
|
||||
if (!double.IsFinite(x) || !double.IsFinite(y)
|
||||
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
|
||||
return normal; // Opposing normals at a cusp have no unique bisector.
|
||||
return null; // Opposing normals at a cusp have no unique bisector.
|
||||
|
||||
return Angle.NormalizeRad(System.Math.Atan2(y, x));
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// crosses the contour nowhere but at the corner.
|
||||
/// </summary>
|
||||
private static bool IsClearStraightLead(Shape shape, Vector corner, Vector end, double clearance)
|
||||
{
|
||||
if (!IsFinite(end) || end.DistanceTo(corner) <= Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
var nearest = shape.ClosestPointTo(end, out _);
|
||||
if (nearest.DistanceTo(end) < System.Math.Max(clearance, 0) - Tolerance.Epsilon)
|
||||
return false;
|
||||
|
||||
if (shape.Intersects(new Line(end, corner), out var crossings))
|
||||
{
|
||||
foreach (var crossing in crossings)
|
||||
{
|
||||
if (crossing.DistanceTo(corner) > Tolerance.ChainTolerance)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Vector EntityEndPoint(Entity entity)
|
||||
{
|
||||
if (entity is Line line)
|
||||
|
||||
@@ -120,20 +120,20 @@ public class CutoutCornerLeadInTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplySingleLeadIn_ExternalCorner_KeepsEntityNormal()
|
||||
public void ApplySingleLeadIn_ExternalCorner_ExtendsFirstCutEdgeNotBisector()
|
||||
{
|
||||
// Outside perimeter corners have their own rule (PerimeterCornerLeadInTests).
|
||||
var part = MakePart(Rectangle());
|
||||
var profile = Profile(part);
|
||||
var entity = Assert.IsType<Line>(profile.Perimeter.Entities[0]);
|
||||
var point = entity.StartPoint;
|
||||
var parameters = Parameters();
|
||||
parameters.ExternalLeadIn = parameters.InternalLeadIn;
|
||||
var normal = ContourCuttingStrategy.ComputeNormal(point, entity, ContourType.External,
|
||||
ContourCuttingStrategy.DetermineWinding(profile.Perimeter));
|
||||
var direction = (entity.EndPoint - entity.StartPoint).Normalize();
|
||||
|
||||
part.ApplySingleLeadIn(parameters, point, entity, ContourType.External);
|
||||
|
||||
AssertPoint(parameters.ExternalLeadIn.GetPiercePoint(point, normal), SingleLeadIn(part).StartPoint);
|
||||
AssertPoint(point - direction * LeadLength, SingleLeadIn(part).StartPoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.CNC.CuttingStrategy;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingStrategy;
|
||||
|
||||
/// <summary>
|
||||
/// Straight lead-ins at outside perimeter corners extend the edge cut first, so the
|
||||
/// torch enters on that edge's line whichever of the corner's two edges was picked.
|
||||
/// </summary>
|
||||
public class PerimeterCornerLeadInTests
|
||||
{
|
||||
private const double LeadLength = 0.25;
|
||||
|
||||
public static IEnumerable<object[]> SquareCorners()
|
||||
{
|
||||
foreach (var reverse in new[] { false, true })
|
||||
foreach (var rotation in new[] { 0.0, 0.63 })
|
||||
for (var corner = 0; corner < 4; corner++)
|
||||
yield return new object[] { reverse, rotation, corner };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(SquareCorners))]
|
||||
public void ApplyLeadIns_ConvexCorner_ExtendsFirstCutEdge(bool reverse, double rotation, int cornerIndex)
|
||||
{
|
||||
var part = MakePart(Square(), reverse);
|
||||
part.Rotate(rotation);
|
||||
var perimeter = Perimeter(part);
|
||||
var outgoing = Assert.IsType<Line>(perimeter.Entities[cornerIndex]);
|
||||
var corner = outgoing.StartPoint;
|
||||
var centroid = Centroid(perimeter);
|
||||
var approach = corner + (corner - centroid) * 2;
|
||||
|
||||
part.ApplyLeadIns(Parameters(), approach);
|
||||
|
||||
AssertStraightEntry(part, corner, Direction(outgoing));
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> PickedEdges()
|
||||
{
|
||||
foreach (var args in SquareCorners())
|
||||
foreach (var incoming in new[] { false, true })
|
||||
yield return args.Append(incoming).ToArray();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(PickedEdges))]
|
||||
public void ApplySingleLeadIn_ConvexCorner_SameStraightLeadForEitherEdge(
|
||||
bool reverse, double rotation, int cornerIndex, bool incoming)
|
||||
{
|
||||
var part = MakePart(Square(), reverse);
|
||||
part.Rotate(rotation);
|
||||
var perimeter = Perimeter(part);
|
||||
var count = perimeter.Entities.Count;
|
||||
var outgoing = Assert.IsType<Line>(perimeter.Entities[cornerIndex]);
|
||||
var corner = outgoing.StartPoint;
|
||||
var entity = incoming ? perimeter.Entities[(cornerIndex + count - 1) % count] : outgoing;
|
||||
var parameters = Parameters();
|
||||
var preview = PreviewPierce(perimeter, corner, entity, parameters);
|
||||
|
||||
part.ApplySingleLeadIn(parameters, corner, entity, ContourType.External);
|
||||
|
||||
AssertStraightEntry(part, corner, Direction(outgoing));
|
||||
AssertPoint(preview, SingleLead(part, SpecialLayers.Leadin).StartPoint);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ApplySingleLeadIn_ConvexCorner_IgnoresApproachAngle(bool incoming)
|
||||
{
|
||||
var part = MakePart(Square());
|
||||
var perimeter = Perimeter(part);
|
||||
var outgoing = Assert.IsType<Line>(perimeter.Entities[1]);
|
||||
var entity = incoming ? perimeter.Entities[0] : outgoing;
|
||||
var parameters = Parameters(approachAngle: 60);
|
||||
|
||||
part.ApplySingleLeadIn(parameters, outgoing.StartPoint, entity, ContourType.External);
|
||||
|
||||
AssertStraightEntry(part, outgoing.StartPoint, Direction(outgoing));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplySingleLeadIn_MidEdge_KeepsApproachAngle()
|
||||
{
|
||||
var part = MakePart(Square());
|
||||
var perimeter = Perimeter(part);
|
||||
var entity = Assert.IsType<Line>(perimeter.Entities[0]);
|
||||
var point = entity.MidPoint;
|
||||
var parameters = Parameters(approachAngle: 60);
|
||||
var normal = ContourCuttingStrategy.ComputeNormal(point, entity, ContourType.External,
|
||||
ContourCuttingStrategy.DetermineWinding(perimeter));
|
||||
|
||||
part.ApplySingleLeadIn(parameters, point, entity, ContourType.External);
|
||||
|
||||
AssertPoint(parameters.ExternalLeadIn.GetPiercePoint(point, normal),
|
||||
SingleLead(part, SpecialLayers.Leadin).StartPoint);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(90, true, false)]
|
||||
[InlineData(150, true, false)]
|
||||
[InlineData(160, true, true)]
|
||||
[InlineData(170, false, false)]
|
||||
[InlineData(170, false, true)]
|
||||
[InlineData(179, false, true)]
|
||||
public void ApplySingleLeadIn_FlatCorner_FallsBackToPerpendicularWithoutPierceClearance(
|
||||
double interiorDegrees, bool straight, bool incoming)
|
||||
{
|
||||
// Corner at (10, 0): cut along +X, then turn left by 180 - interior degrees.
|
||||
var turn = System.Math.PI - interiorDegrees * System.Math.PI / 180;
|
||||
var corner = new Vector(10, 0);
|
||||
var next = corner + new Vector(System.Math.Cos(turn), System.Math.Sin(turn)) * 5;
|
||||
var part = MakePart(new[] { new Vector(0, 0), corner, next, new Vector(0, next.Y) });
|
||||
var perimeter = Perimeter(part);
|
||||
var outgoing = perimeter.Entities.OfType<Line>().Single(e => e.StartPoint == corner);
|
||||
var entity = incoming ? perimeter.Entities.OfType<Line>().Single(e => e.EndPoint == corner) : outgoing;
|
||||
var parameters = Parameters();
|
||||
Assert.Equal(0.0625, parameters.PierceClearance);
|
||||
|
||||
part.ApplySingleLeadIn(parameters, corner, entity, ContourType.External);
|
||||
|
||||
if (straight)
|
||||
{
|
||||
AssertStraightEntry(part, corner, Direction(outgoing));
|
||||
return;
|
||||
}
|
||||
|
||||
var normal = ContourCuttingStrategy.ComputeNormal(corner, outgoing, ContourType.External,
|
||||
ContourCuttingStrategy.DetermineWinding(perimeter));
|
||||
AssertPoint(parameters.ExternalLeadIn.GetPiercePoint(corner, normal),
|
||||
SingleLead(part, SpecialLayers.Leadin).StartPoint);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(true, true)]
|
||||
public void ApplySingleLeadIn_ReflexCorner_BisectsNotch(bool reverse, bool pickFirst)
|
||||
{
|
||||
var notch = new Vector(4, 4);
|
||||
var part = MakePart(LShape(), reverse);
|
||||
var perimeter = Perimeter(part);
|
||||
var touching = perimeter.Entities.OfType<Line>()
|
||||
.Where(e => e.StartPoint == notch || e.EndPoint == notch).ToList();
|
||||
Assert.Equal(2, touching.Count);
|
||||
var entity = pickFirst ? touching[0] : touching[1];
|
||||
var parameters = Parameters();
|
||||
var preview = PreviewPierce(perimeter, notch, entity, parameters);
|
||||
|
||||
part.ApplySingleLeadIn(parameters, notch, entity, ContourType.External);
|
||||
|
||||
var lead = SingleLead(part, SpecialLayers.Leadin);
|
||||
AssertPoint(notch, lead.EndPoint);
|
||||
AssertPoint(notch + new Vector(1, 1).Normalize() * LeadLength, lead.StartPoint);
|
||||
AssertPoint(preview, lead.StartPoint);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(true, true)]
|
||||
public void ApplySingleLeadIn_ConvexCorner_LineLeadOutRunsOnAlongLastCutEdge(bool reverse, bool incoming)
|
||||
{
|
||||
var part = MakePart(Square(), reverse);
|
||||
var perimeter = Perimeter(part);
|
||||
var count = perimeter.Entities.Count;
|
||||
var outgoing = Assert.IsType<Line>(perimeter.Entities[2]);
|
||||
var lastCut = Assert.IsType<Line>(perimeter.Entities[1]);
|
||||
var corner = outgoing.StartPoint;
|
||||
var entity = incoming ? lastCut : outgoing;
|
||||
var parameters = Parameters();
|
||||
parameters.ExternalLeadOut = new LineLeadOut { Length = 0.1, ApproachAngle = 60 };
|
||||
|
||||
part.ApplySingleLeadIn(parameters, corner, entity, ContourType.External);
|
||||
|
||||
var leadOut = SingleLead(part, SpecialLayers.Leadout);
|
||||
AssertPoint(corner, leadOut.StartPoint);
|
||||
AssertPoint(corner + Direction(lastCut) * 0.1, leadOut.EndPoint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplySingleLeadIn_CutoutCorner_KeepsBisector()
|
||||
{
|
||||
// Outside perimeter handling must not leak into cutouts.
|
||||
var program = new Program(Mode.Absolute);
|
||||
AddContour(program, Square());
|
||||
AddContour(program, new[] { new Vector(2, 2), new Vector(4, 2), new Vector(4, 4), new Vector(2, 4) });
|
||||
var part = new Part(new Drawing("cutout", program));
|
||||
var cutout = Assert.Single(Profile(part).Cutouts);
|
||||
var entity = cutout.Entities.OfType<Line>().First(e => e.StartPoint == new Vector(4, 4));
|
||||
var parameters = Parameters();
|
||||
parameters.InternalLeadIn = new LineLeadIn { Length = LeadLength, ApproachAngle = 90 };
|
||||
|
||||
part.ApplySingleLeadIn(parameters, entity.StartPoint, entity, ContourType.Internal);
|
||||
|
||||
var lead = SingleLead(part, SpecialLayers.Leadin);
|
||||
AssertPoint(new Vector(4, 4) + new Vector(-1, -1).Normalize() * LeadLength, lead.StartPoint);
|
||||
}
|
||||
|
||||
private static Vector PreviewPierce(Shape shape, Vector point, Entity entity, CuttingParameters parameters)
|
||||
{
|
||||
var leadIn = ContourCuttingStrategy.ResolveLeadIn(shape, point, entity, ContourType.External,
|
||||
parameters.ExternalLeadIn, ContourCuttingStrategy.DetermineWinding(shape),
|
||||
parameters.PierceClearance, out var normal);
|
||||
return leadIn.GetPiercePoint(point, normal);
|
||||
}
|
||||
|
||||
/// <summary>The lead-in runs on the first-cut edge's line and cutting continues along it.</summary>
|
||||
private static void AssertStraightEntry(Part part, Vector corner, Vector direction)
|
||||
{
|
||||
var lead = SingleLead(part, SpecialLayers.Leadin);
|
||||
AssertPoint(corner, lead.EndPoint);
|
||||
AssertPoint(corner - direction * LeadLength, lead.StartPoint);
|
||||
|
||||
var geometry = part.Program.ToGeometry();
|
||||
var firstCut = Assert.IsType<Line>(geometry
|
||||
.SkipWhile(e => e.Layer != SpecialLayers.Leadin).Skip(1)
|
||||
.First(e => SpecialLayers.IsMaterial(e.Layer)));
|
||||
AssertPoint(corner, firstCut.StartPoint);
|
||||
AssertPoint(direction, Direction(firstCut));
|
||||
}
|
||||
|
||||
private static CuttingParameters Parameters(double approachAngle = 90) => new()
|
||||
{
|
||||
ExternalLeadIn = new LineLeadIn { Length = LeadLength, ApproachAngle = approachAngle },
|
||||
};
|
||||
|
||||
private static Vector[] Square() => new[]
|
||||
{
|
||||
new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10),
|
||||
};
|
||||
|
||||
private static Vector[] LShape() => new[]
|
||||
{
|
||||
new Vector(0, 0), new Vector(10, 0), new Vector(10, 4),
|
||||
new Vector(4, 4), new Vector(4, 10), new Vector(0, 10),
|
||||
};
|
||||
|
||||
private static Part MakePart(Vector[] perimeter, bool reverse = false)
|
||||
{
|
||||
var program = new Program(Mode.Absolute);
|
||||
AddContour(program, reverse ? perimeter.Reverse().ToArray() : perimeter);
|
||||
return new Part(new Drawing("perimeter-corner-test", program));
|
||||
}
|
||||
|
||||
private static void AddContour(Program program, Vector[] vertices)
|
||||
{
|
||||
program.Codes.Add(new RapidMove(vertices[0]));
|
||||
foreach (var point in vertices.Skip(1).Append(vertices[0]))
|
||||
program.Codes.Add(new LinearMove(point));
|
||||
}
|
||||
|
||||
private static ShapeProfile Profile(Part part) => new(part.Program.ToGeometry()
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList());
|
||||
|
||||
private static Shape Perimeter(Part part)
|
||||
{
|
||||
var perimeter = Profile(part).Perimeter;
|
||||
Assert.True(perimeter.IsClosed());
|
||||
return perimeter;
|
||||
}
|
||||
|
||||
private static Vector Centroid(Shape shape)
|
||||
{
|
||||
var lines = shape.Entities.OfType<Line>().ToList();
|
||||
var sum = lines.Aggregate(Vector.Zero, (total, line) => total + line.StartPoint);
|
||||
return sum / lines.Count;
|
||||
}
|
||||
|
||||
private static Vector Direction(Line line) => (line.EndPoint - line.StartPoint).Normalize();
|
||||
|
||||
private static Line SingleLead(Part part, Layer layer) => Assert.IsType<Line>(Assert.Single(
|
||||
part.Program.ToGeometry().Where(e => e.Layer == layer)));
|
||||
|
||||
private static void AssertPoint(Vector expected, Vector actual)
|
||||
{
|
||||
Assert.Equal(expected.X, actual.X, 8);
|
||||
Assert.Equal(expected.Y, actual.Y, 8);
|
||||
}
|
||||
}
|
||||
@@ -339,13 +339,17 @@ namespace OpenNest.Actions
|
||||
if (leadIn == null)
|
||||
return;
|
||||
|
||||
snapNormal = ContourCuttingStrategy.ComputeLeadInNormal(
|
||||
// Same resolution as program generation, so a corner previews identically
|
||||
// whichever of its two edges the cursor picked.
|
||||
leadIn = ContourCuttingStrategy.ResolveLeadIn(
|
||||
hoveredContour.Shape,
|
||||
snapPoint,
|
||||
snapEntity,
|
||||
snapContourType,
|
||||
leadIn,
|
||||
hoveredContour.Winding
|
||||
hoveredContour.Winding,
|
||||
parameters.PierceClearance,
|
||||
out snapNormal
|
||||
);
|
||||
leadIn = ClampLeadInForCircle(leadIn, parameters);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Lead-in placement at cutout corners
|
||||
# Lead-in placement at corners
|
||||
|
||||
Straight (`LineLeadIn`) lead-ins at closed internal contour corners use the
|
||||
inward angle bisector instead of the normal of whichever edge was selected.
|
||||
@@ -24,13 +24,39 @@ An excessively long lead-in or an approach angle rotated away from the bisector
|
||||
can still leave a small cutout. General non-circular containment/length clamping
|
||||
and sharp-corner handling for curved/composite lead-ins remain separate work.
|
||||
|
||||
## Outside perimeter corners
|
||||
|
||||
A straight (`LineLeadIn`) lead-in at a convex corner of the outside perimeter
|
||||
(interior angle under 180 degrees) extends the edge cut first: the pierce sits on
|
||||
that edge's line, behind the corner, and the torch travels straight into the corner
|
||||
and keeps cutting along the same line. Which of the two edges was picked (auto
|
||||
assignment or the manual cursor) does not matter; the cut direction never changes.
|
||||
The approach angle is ignored at such a corner.
|
||||
|
||||
The straight lead is used only while its pierce stays at least
|
||||
`CuttingParameters.PierceClearance` from the contour and the lead crosses the
|
||||
contour nowhere but at the corner. Very flat corners (about 165 degrees and over for
|
||||
a 0.25 lead with 0.0625 clearance) fall back to the normal lead-in, perpendicular to
|
||||
the first-cut edge, so tessellated curves do not get straight leads. Reflex perimeter
|
||||
corners (the inside corner of an L) use the notch bisector, like cutout corners.
|
||||
|
||||
A `LineLeadOut` mirrors this: at a convex perimeter corner it runs straight on past
|
||||
the corner along the last-cut edge, with the same clearance fallback to the last-cut
|
||||
edge's normal and a bisector at reflex corners. A tabbed perimeter keeps its old
|
||||
lead-out. `ContourCuttingStrategy.ResolveLeadIn`/`ResolveLeadOut` own these rules;
|
||||
program generation and the manual preview share them. Other lead-in styles are
|
||||
unchanged.
|
||||
|
||||
## Regression checks
|
||||
|
||||
Run `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter FullyQualifiedName~CutoutCornerLeadInTests`.
|
||||
The tests exercise generated part programs, default automatic placement, every
|
||||
Run `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~CutoutCornerLeadInTests|FullyQualifiedName~PerimeterCornerLeadInTests"`.
|
||||
The cutout tests exercise generated part programs, default automatic placement, every
|
||||
rectangular corner with both adjoining edges and windings, part rotation, acute
|
||||
and obtuse angles, reflex corners, line/arc corners, preview agreement, and
|
||||
unchanged/fallback behavior.
|
||||
unchanged/fallback behavior. The perimeter tests cover every square corner under both
|
||||
windings and a rotation, auto and manual placement from either edge, preview
|
||||
agreement, approach-angle handling, the flat-corner clearance fallback, reflex
|
||||
notches, and straight lead-outs.
|
||||
|
||||
A headless before/after import of `4980 A01 PT07.dxf` (SHA-256
|
||||
`1535D77BC1EEEDD21A27E7CE91EA4C51055118D019C5A09C144F1F41740895B6`)
|
||||
|
||||
Reference in New Issue
Block a user