diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index c3a3f3c..ffc8c9e 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -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 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; + } + + /// + /// Returns the lead-in to emit at and the normal to + /// generate it with. At a corner of an outside perimeter, a straight + /// () lead-in extends the edge cut first so the torch + /// enters on that edge's line, provided the pierce keeps + /// 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 . + /// + 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; + } + } + + /// + /// Lead-out counterpart of . At a convex outside + /// perimeter corner a runs straight on past the corner + /// along the edge cut last, when its end keeps 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. + /// + 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, + } + + /// A contour vertex: the entity cut into it and the one cut away from it. + 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; + } + + /// Unit direction of travel along a line or arc at a point on it. + 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); + + /// + /// 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. + /// + 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)); } + /// + /// A straight lead from to the corner stays in the scrap: + /// its free end keeps from the contour and the lead + /// crosses the contour nowhere but at the corner. + /// + 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) diff --git a/OpenNest.Tests/CuttingStrategy/CutoutCornerLeadInTests.cs b/OpenNest.Tests/CuttingStrategy/CutoutCornerLeadInTests.cs index 3b69fa1..b0cfba2 100644 --- a/OpenNest.Tests/CuttingStrategy/CutoutCornerLeadInTests.cs +++ b/OpenNest.Tests/CuttingStrategy/CutoutCornerLeadInTests.cs @@ -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(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] diff --git a/OpenNest.Tests/CuttingStrategy/PerimeterCornerLeadInTests.cs b/OpenNest.Tests/CuttingStrategy/PerimeterCornerLeadInTests.cs new file mode 100644 index 0000000..bd4d29d --- /dev/null +++ b/OpenNest.Tests/CuttingStrategy/PerimeterCornerLeadInTests.cs @@ -0,0 +1,284 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingStrategy; + +/// +/// 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. +/// +public class PerimeterCornerLeadInTests +{ + private const double LeadLength = 0.25; + + public static IEnumerable 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(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 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(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(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(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().Single(e => e.StartPoint == corner); + var entity = incoming ? perimeter.Entities.OfType().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() + .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(perimeter.Entities[2]); + var lastCut = Assert.IsType(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().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); + } + + /// The lead-in runs on the first-cut edge's line and cutting continues along it. + 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(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().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(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); + } +} diff --git a/OpenNest/Actions/ActionLeadIn.cs b/OpenNest/Actions/ActionLeadIn.cs index d9a6003..a84ed0b 100644 --- a/OpenNest/Actions/ActionLeadIn.cs +++ b/OpenNest/Actions/ActionLeadIn.cs @@ -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); diff --git a/docs/geometry/lead-in-placement.md b/docs/geometry/lead-in-placement.md index 3abb82b..da29590 100644 --- a/docs/geometry/lead-in-placement.md +++ b/docs/geometry/lead-in-placement.md @@ -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`)