diff --git a/AGENTS.md b/AGENTS.md
index 35126636..b99c0339 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -58,7 +58,7 @@ Read the relevant contract before changing its behavior:
- [Nest file format](docs/nest-file-format.md)
- [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md)
-- [Lead-in placement](docs/geometry/lead-in-placement.md) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
+- [Lead-in placement](docs/geometry/lead-in-placement.md) (including the planner's eroded pierce for cutouts too small for their lead-in) and [pierce zones](docs/geometry/pierce-zone.md): opt-in `PierceDiameter` clearance, the hole pole fallback and the planner's perimeter scrap fallback
- [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material
- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them
- [Support tabs](docs/support-tabs.md): the nest's support grid, the center-of-gravity check, and Plan Cutting's tab placement for parts the grid cannot hold level
diff --git a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
index cb20589a..87560b04 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ContourEntryCandidate.cs
@@ -42,6 +42,13 @@ internal enum AutomaticEntryKind
/// perimeter tabbed for support has it (see ).
///
SupportTab = 8,
+
+ ///
+ /// A cutout entry pierced where eroding the cutout leaves room (see
+ /// ): only offered once no configured
+ /// lead-in of the cutout fits.
+ ///
+ ErodedPierce = 9,
}
///
diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
index 663ea97c..4acd8045 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs
@@ -15,9 +15,11 @@ public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector
internal PreparedContours Owner { get; init; }
///
- /// A perimeter pierce the planner placed in open scrap for the pierce zone (see
- /// ): the lead-in runs straight from here to
- /// instead of the configured lead-in. Null for every other choice.
+ /// A pierce the planner placed off the contour: a perimeter's in open scrap for the pierce
+ /// zone (see ), or a cutout's where eroding it
+ /// leaves room (see ). The lead-in runs
+ /// straight from here to instead of the configured lead-in; a cutout so
+ /// pierced has no lead-out. Null for every other choice.
///
public Vector? Pierce { get; internal init; }
}
@@ -444,13 +446,48 @@ public sealed class PreparedContours
/// emission reads only this choice and the perimeter, and the lead validator checks the
/// lead as usual. A pinned perimeter keeps its pin's entry.
///
- internal ContourChoice ScrapPierceEntry(Vector pierce)
+ internal ContourChoice ScrapPierceEntry(Vector pierce) => PiercedEntry(PerimeterOrdinal, pierce);
+
+ ///
+ /// Entries of cutout pierced where eroding it leaves room
+ /// (see ), the one nearest each of (local
+ /// coordinates, usually its ranked entries) in turn: the planner's fallback when no configured
+ /// lead-in fits the cutout. Each enters at the native point of the cutout closest to its
+ /// pierce, along a straight lead-in, with no lead-out. Nothing for the perimeter or a pinned
+ /// cutout; the lead validator still checks each one.
+ ///
+ internal IReadOnlyList ErodedPierceEntries(int contourOrdinal, IReadOnlyList targets,
+ int max, CancellationToken token = default)
+ {
+ token.ThrowIfCancellationRequested();
+ var shape = GetShape(contourOrdinal);
+ if (contourOrdinal == PerimeterOrdinal || pinned[contourOrdinal] != null)
+ return Array.Empty();
+ var found = ErodedPierce.Find(shape, ParametersFor(contourOrdinal).GetEffectivePierceClearance(), targets, max);
+ var entries = new List();
+ foreach (var pierce in found.Pierces)
+ {
+ token.ThrowIfCancellationRequested();
+ try
+ {
+ entries.Add(PiercedEntry(contourOrdinal, pierce));
+ }
+ catch (ArgumentException)
+ {
+ // A pierce on the wall itself has no lead; the others still stand.
+ }
+ }
+ return entries;
+ }
+
+ // A contour entry at the closest native point to a pierce off it, along a straight lead-in.
+ private ContourChoice PiercedEntry(int contourOrdinal, Vector pierce)
{
PostVerificationGeometry.Validate(pierce);
- var shape = GetShape(PerimeterOrdinal);
+ var shape = GetShape(contourOrdinal);
var point = shape.ClosestPointTo(pierce, out var entity);
point = SnapToVertex(shape, shape.Entities.IndexOf(entity), point);
- var choice = new ContourChoice(PerimeterOrdinal, shape.Entities.IndexOf(entity), point)
+ var choice = new ContourChoice(contourOrdinal, shape.Entities.IndexOf(entity), point)
{
Owner = this,
Pierce = pierce,
@@ -866,10 +903,8 @@ public sealed class PreparedContours
if (choice.Pierce is { } pierce)
{
PostVerificationGeometry.Validate(pierce);
- if (choice.ContourOrdinal != PerimeterOrdinal)
- throw new ArgumentException("Only a perimeter entry can be pierced in scrap.");
if (pierce.DistanceTo(choice.Point) <= PostVerificationGeometry.Epsilon)
- throw new ArgumentException("A scrap pierce must lie off its contour.");
+ throw new ArgumentException("A planner-placed pierce must lie off its contour.");
}
}
diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
index eca8d858..2f4d3e46 100644
--- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
+++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs
@@ -449,6 +449,9 @@ namespace OpenNest.CNC.CuttingStrategy
var leadIn = SelectLeadIn(contourType);
var leadOut = SelectLeadOut(contourType);
+ // A cutout the planner pierced by erosion is too small for its configured leads.
+ if (scrapPierce != null && contourType != ContourType.External)
+ leadOut = new NoLeadOut();
if (contourType == ContourType.ArcCircle && entity is Circle circle)
{
@@ -465,9 +468,14 @@ namespace OpenNest.CNC.CuttingStrategy
);
}
- leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
- Parameters.GetEffectivePierceClearance());
- leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius(), winding);
+ if (scrapPierce is { } holePierce)
+ leadIn = StraightLeadIn(point, holePierce, out normal);
+ else
+ {
+ leadIn = ClampLeadInForCircle(leadIn, circle, point, normal,
+ Parameters.GetEffectivePierceClearance());
+ leadIn = ResolvePierceZone(shape, point, leadIn, normal, Parameters.GetPierceZoneRadius(), winding);
+ }
// Build hole sub-program relative to (0,0)
var holeCenter = circle.Center;
diff --git a/OpenNest.Core/CNC/CuttingStrategy/ErodedPierce.cs b/OpenNest.Core/CNC/CuttingStrategy/ErodedPierce.cs
new file mode 100644
index 00000000..57e1055e
--- /dev/null
+++ b/OpenNest.Core/CNC/CuttingStrategy/ErodedPierce.cs
@@ -0,0 +1,113 @@
+using System.Collections.Generic;
+using System.Linq;
+using Clipper2Lib;
+using OpenNest.Geometry;
+
+namespace OpenNest.CNC.CuttingStrategy
+{
+ ///
+ /// Pierces found by eroding a cutout: the part of it that keeps a clearance from its wall
+ /// (see ).
+ ///
+ /// Distinct pierces, the one nearest each target in turn.
+ /// How far the eroded region keeps every pierce from the wall.
+ public sealed record ErodedPierces(IReadOnlyList Pierces, double Clearance)
+ {
+ public static ErodedPierces None { get; } = new(System.Array.Empty(), 0);
+ }
+
+ ///
+ /// The pierce search for cutouts too small for their configured lead-in (text, slots, small
+ /// holes): erode the cutout inward, as a laser kerf offset would, and pierce in what is left.
+ /// The erosion goes as deep as the pierce clearance when the cutout has room for it, else
+ /// almost to its pole of inaccessibility, so a slot or letter stroke is pierced on its
+ /// centre line, where the most clearance is. Each target, an entry the planner ranked, gets
+ /// the eroded point nearest it; the lead then runs straight from there to the wall.
+ ///
+ public static class ErodedPierce
+ {
+ ///
+ /// The share of a cutout's inscribed radius the erosion keeps when the full clearance does
+ /// not fit: deep enough to stay near the centre line, with room for every place the
+ /// cutout is about as wide as its widest.
+ ///
+ public const double DepthFraction = 0.9;
+
+ private const int PoleSearchCells = 4096;
+
+ ///
+ /// Up to distinct pierces in the closed cutout ,
+ /// the eroded point nearest each of in turn. A cutout too small to
+ /// erode is pierced at its pole alone. Nothing for an open or degenerate contour.
+ ///
+ public static ErodedPierces Find(Shape hole, double clearance, IReadOnlyList targets, int max)
+ {
+ if (hole == null || !hole.IsClosed() || targets == null || targets.Count == 0 || max <= 0
+ || !double.IsFinite(clearance))
+ return ErodedPierces.None;
+
+ // The inscribed radius, from the true wall: the deepest any erosion can go.
+ var coarse = ClipperBridge.Flatten(hole, Tolerance(hole.BoundingBox.Width, hole.BoundingBox.Length), false);
+ if (coarse.Vertices.Count < 3)
+ return ErodedPierces.None;
+ var pole = PolyLabel.Find(coarse, null, Tolerance(coarse.BoundingBox.Width, coarse.BoundingBox.Length) / 10,
+ PoleSearchCells);
+ if (!double.IsFinite(pole.X) || !double.IsFinite(pole.Y) || !coarse.ContainsPoint(pole))
+ return ErodedPierces.None;
+ var inscribed = hole.ClosestPointTo(pole, out _).DistanceTo(pole);
+ if (!(inscribed > 0))
+ return ErodedPierces.None;
+
+ // Inscribed flattening can stand off a concave arc, and round joins cut their arcs,
+ // each by the tolerance; Clipper rounds to its precision. Erode past both.
+ var depth = System.Math.Min(System.Math.Max(clearance, 0), inscribed * DepthFraction);
+ var tolerance = System.Math.Max(depth / 100, System.Math.Pow(10, -ClipperBridge.Precision));
+ var margin = 2 * tolerance + System.Math.Pow(10, -ClipperBridge.Precision);
+ if (depth + margin >= inscribed - tolerance)
+ return new(new[] { pole }, inscribed);
+
+ var polygon = ClipperBridge.Flatten(hole, tolerance, circumscribe: false);
+ var region = Clipper.InflatePaths(new PathsD { ClipperBridge.ToPath(polygon, positive: true) },
+ -(depth + margin), JoinType.Round, EndType.Polygon, 2.0, ClipperBridge.Precision, tolerance)
+ .Where(p => p.Count >= 3).ToList();
+ if (region.Count == 0)
+ return new(new[] { pole }, inscribed);
+
+ var pierces = new List();
+ foreach (var target in targets)
+ {
+ var pierce = Nearest(region, target);
+ if (pierces.All(p => p.DistanceTo(pierce) > tolerance))
+ pierces.Add(pierce);
+ if (pierces.Count == max)
+ break;
+ }
+ return new(pierces, depth);
+ }
+
+ // A hundredth of the smaller side: fine enough to find a thin cutout's pole.
+ private static double Tolerance(double width, double length) =>
+ System.Math.Max(System.Math.Min(width, length) / 100, System.Math.Pow(10, -ClipperBridge.Precision));
+
+ // The closest point of the region's boundary; targets lie on the wall, outside it.
+ private static Vector Nearest(List paths, Vector target)
+ {
+ var best = default(Vector);
+ var bestDistance = double.PositiveInfinity;
+ foreach (var path in paths)
+ for (var i = 0; i < path.Count; i++)
+ {
+ var a = new Vector(path[i].x, path[i].y);
+ var b = new Vector(path[(i + 1) % path.Count].x, path[(i + 1) % path.Count].y);
+ var point = new Line(a, b).ClosestPointTo(target);
+ var distance = point.DistanceTo(target);
+ if (distance < bestDistance)
+ {
+ bestDistance = distance;
+ best = point;
+ }
+ }
+ return best;
+ }
+ }
+}
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index a6c25242..99c57ec9 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -687,8 +687,16 @@ internal static class JointCuttingPlanSearch
// No outside to cut: one pass, complete once every hole is cut.
perimeters = [null];
var holes = Enumerable.Range(0, prepared.PerimeterOrdinal)
- .Select(c => prepared.AutomaticEntryCandidatesWithFallbacks(c, token: token)
- .Where(e => Evaluate(source, e).Status == ContourFeasibilityStatus.Clear).Select(e => e.Choice).ToArray()).ToArray();
+ .Select(c =>
+ {
+ var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(c, token: token);
+ var clear = catalogue.Where(e => Evaluate(source, e).Status == ContourFeasibilityStatus.Clear).ToArray();
+ // No configured lead-in fits the cutout: pierce where eroding it leaves room.
+ if (clear.Length == 0)
+ clear = PreferredContourEntries.ErodedPierces(prepared, c, catalogue, snapshot.MaxEntries, token)
+ .Where(e => Evaluate(source, e).Status == ContourFeasibilityStatus.Clear).ToArray();
+ return clear.Select(e => e.Choice).ToArray();
+ }).ToArray();
var motions = new Dictionary();
var stop = Expansions + (long)StallExpansionsPerEntry * snapshot.MaxEntries * prepared.Count;
// The part's scribes and other open cuts come once, before every contour; check
@@ -983,6 +991,19 @@ internal static class JointCuttingPlanSearch
selection = Steadiest(source.Prepared, ContourEntrySelection.Select(ordered,
candidate => Evaluate(source, candidate), snapshot.MaxEntries, token));
}
+ // No configured lead-in fits a cutout: pierce where eroding it leaves room.
+ string erodedReason = null;
+ if (contour != source.Prepared.PerimeterOrdinal && selection.Choices.Count == 0
+ && selection.UncertainChoices.Count == 0 && selection.Shortfall == ContourSelectionShortfall.Exhausted)
+ {
+ var eroded = ContourEntrySelection.Select(
+ PreferredContourEntries.ErodedPierces(source.Prepared, contour, ordered, snapshot.MaxEntries, token),
+ candidate => Evaluate(source, candidate), snapshot.MaxEntries, token);
+ if (eroded.Choices.Count != 0 || eroded.UncertainChoices.Count != 0)
+ selection = eroded;
+ else
+ erodedReason = eroded.Reason ?? "eroding the cutout leaves no room to pierce.";
+ }
// No configured lead-in fits the perimeter: with a pierce diameter, try open scrap.
string scrapReason = null;
if (contour == source.Prepared.PerimeterOrdinal && selection.Choices.Count == 0
@@ -1002,6 +1023,7 @@ internal static class JointCuttingPlanSearch
+ "Try reducing the lead-in length in Cutting Settings...; if nearby parts obstruct the lead-in, "
+ "space the parts farther apart." + PierceZoneHint()
+ (scrapReason == null ? "" : $" A pierce in open scrap was also tried: {scrapReason}")
+ + (erodedReason == null ? "" : $" A pierce found by eroding the cutout was also tried: {erodedReason}")
+ " Replan to check the changes."));
// Uncertain candidates are NOT refused by the precheck: they reach the emitted-
// prefix Check and complete replay, which remain the authority on them.
diff --git a/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs b/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs
index f7a9dfe1..94d9c62d 100644
--- a/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs
+++ b/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs
@@ -73,6 +73,12 @@ internal static class PreferredContourEntries
var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(contour, target, token);
var ranked = catalogue.RankTowardNextCut(target, arrivalProxy).DepartingClear(prepared, target, token);
var selection = ContourEntrySelection.Select(ranked, evaluate, 1, token);
+ if (selection.Choices.Count == 0 && selection.Shortfall == ContourSelectionShortfall.Exhausted)
+ {
+ var eroded = ContourEntrySelection.Select(ErodedPierces(prepared, contour, ranked, 1, token), evaluate, 1, token);
+ if (eroded.Choices.Count != 0)
+ selection = eroded;
+ }
if (selection.Choices.Count == 0)
return new(Array.Empty(), selection.Shortfall, contour,
$"Hole contour {contour} has no preferred lead-feasible entry: {selection.Reason}");
@@ -85,6 +91,17 @@ internal static class PreferredContourEntries
return new(choices, ContourSelectionShortfall.None, null, null);
}
+ ///
+ /// The fallback for a cutout none of whose entries has a configured
+ /// lead-in that fits: up to entries pierced where eroding the cutout
+ /// leaves room, the one nearest each ranked entry in turn (see
+ /// ). Proposals only: the lead checks decide.
+ ///
+ internal static IReadOnlyList ErodedPierces(PreparedContours prepared, int contour,
+ IReadOnlyList ranked, int max, CancellationToken token) =>
+ prepared.ErodedPierceEntries(contour, ranked.Select(c => c.Choice.Point).ToArray(), max, token)
+ .Select(choice => new ContourEntryCandidate(choice, AutomaticEntryKind.ErodedPierce)).ToArray();
+
///
/// Stable reordering of one hole's ranked entries: those whose straight rapid to
/// (the next cut's pierce, local) clears the hole itself come
diff --git a/OpenNest.Tests/CuttingPlanning/ErodedPierceTests.cs b/OpenNest.Tests/CuttingPlanning/ErodedPierceTests.cs
new file mode 100644
index 00000000..28af273a
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ErodedPierceTests.cs
@@ -0,0 +1,132 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class ErodedPierceTests
+{
+ private static readonly Vector Start = new(-2, 1.5);
+
+ [Fact]
+ public void Find_SlotIsPiercedOnItsCentreLineNearEachTarget()
+ {
+ var slot = Shape(Rectangle(2, 1, 2.4, 1.05));
+
+ var found = ErodedPierce.Find(slot, 0.0625, [new Vector(2.1, 1), new Vector(2.4, 1.02)], 4);
+
+ Assert.Equal(2, found.Pierces.Count);
+ // 0.025 is all the slot allows; the erosion keeps nine tenths of it.
+ Assert.Equal(0.025 * ErodedPierce.DepthFraction, found.Clearance, 6);
+ Assert.All(found.Pierces, p => Assert.InRange(p.Y, 1.0225, 1.0275));
+ Assert.Equal(2.1, found.Pierces[0].X, 2);
+ Assert.InRange(found.Pierces[1].X, 2.37, 2.38);
+ }
+
+ [Fact]
+ public void Find_RoomForTheClearanceErodesOnlyThatDeep()
+ {
+ var hole = Shape(Rectangle(0, 0, 1, 1));
+
+ var found = ErodedPierce.Find(hole, 0.0625, [new Vector(0.5, 0)], 1);
+
+ Assert.Equal(0.0625, found.Clearance);
+ Assert.Equal(0.5, Assert.Single(found.Pierces).X, 3);
+ Assert.InRange(found.Pierces[0].Y, 0.0625, 0.066);
+ }
+
+ [Fact]
+ public void Planner_CutoutsTooSmallForTheLeadArePiercedByErosion()
+ {
+ var program = new Program();
+ program.Merge(Rectangle(2, 1, 2.4, 1.05));
+ program.Merge(Circle(5, 1.5, 0.03));
+ program.Merge(Rectangle(0, 0, 10, 3));
+ var part = new Part(new Drawing("tag", program));
+ var parameters = new CuttingParameters
+ {
+ InternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 },
+ InternalLeadOut = new LineLeadOut { Length = 0.1 },
+ };
+
+ var (snapshot, result) = Plan(parameters, part);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var proposal = Assert.Single(result.ProposedOrder);
+ var holes = proposal.ContourChoices.Where(c => c.ContourOrdinal != snapshot.Placements[0].Prepared.PerimeterOrdinal).ToArray();
+ Assert.Equal(2, holes.Length);
+ Assert.All(holes, c => Assert.NotNull(c.Pierce));
+ // Each lead runs from the centre of its cutout to the nearest wall: no longer than the
+ // cutout is deep, and no lead-out follows.
+ var leads = proposal.Execution.Motions.Where(m => m.Layer == LayerType.Leadin).ToArray();
+ Assert.Equal(2, leads.Count(m => m.Length < 0.031));
+ Assert.DoesNotContain(proposal.Execution.Motions, m => m.Layer == LayerType.Leadout);
+ var circlePierce = holes.Single(c => c.Pierce!.Value.X > 4).Pierce!.Value;
+ Assert.True(circlePierce.DistanceTo(new Vector(5, 1.5)) < 0.004);
+ }
+
+ [Fact]
+ public void Planner_CutoutWithRoomForItsLeadKeepsIt()
+ {
+ var program = new Program();
+ program.Merge(Rectangle(2, 1, 4, 2));
+ program.Merge(Rectangle(0, 0, 10, 3));
+ var part = new Part(new Drawing("plate", program));
+
+ var (_, result) = Plan(new CuttingParameters(), part);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.All(Assert.Single(result.ProposedOrder).ContourChoices, c => Assert.Null(c.Pierce));
+ }
+
+ [Fact]
+ public void Capture_CutoutJustBelowAnotherSeamVertexIsNotNestedInIt()
+ {
+ // Text baselines a micro-inch apart: the parity ray from the left cutout's start ran
+ // exactly through the right cutout's seam vertex, whose two copies differ in the last
+ // bit, and counted one of its edges, so the left cutout read as nested in it.
+ var program = new Program();
+ program.MoveTo(4.393012, 0.8892639999999999);
+ program.LineTo(4.242387, 0.8892639999999999);
+ program.LineTo(4.242387, 0.911497);
+ program.LineTo(4.393012, 0.911497);
+ program.LineTo(4.393012, 0.889264);
+ program.MoveTo(1.834001, 0.8892629999999999);
+ program.LineTo(1.834001, 0.95);
+ program.LineTo(1.9, 0.95);
+ program.LineTo(1.9, 0.8892629999999999);
+ program.LineTo(1.834001, 0.8892629999999999);
+ program.Merge(Rectangle(0, 0, 10, 2));
+
+ var material = LeadMaterialSnapshot.Capture(program, Vector.Zero);
+
+ Assert.True(material.IsComplete, material.Reason);
+ }
+
+ private static (CuttingPlanSnapshot, CuttingPlanResult) Plan(CuttingParameters parameters, params Part[] parts)
+ {
+ var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts, Start,
+ confirmedParameters: parameters, preservePartOrder: true));
+ return (snapshot, CuttingPlanService.Plan(snapshot));
+ }
+
+ private static Shape Shape(Program program) =>
+ ShapeBuilder.GetShapes(OpenNest.Converters.ConvertProgram.ToGeometry(program)
+ .Where(e => e.Layer != SpecialLayers.Rapid).ToList()).Single();
+
+ private static Program Rectangle(double x1, double y1, double x2, double y2) =>
+ LeadPathValidationTests.Rectangle(x1, y1, x2, y2);
+
+ private static Program Circle(double x, double y, double radius)
+ {
+ var p = new Program();
+ p.MoveTo(x + radius, y);
+ p.ArcTo(x + radius, y, x, y, RotationType.CCW);
+ return p;
+ }
+
+ private static string Describe(CuttingPlanResult result) =>
+ $"{result.Status}: " + string.Join("; ", result.Findings.Select(f => f.Message).Take(5));
+}
diff --git a/OpenNest.Tests/CuttingPlanning/FixedLeadWarningTests.cs b/OpenNest.Tests/CuttingPlanning/FixedLeadWarningTests.cs
index aa12ae29..79e4b1c4 100644
--- a/OpenNest.Tests/CuttingPlanning/FixedLeadWarningTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/FixedLeadWarningTests.cs
@@ -97,8 +97,12 @@ public class FixedLeadWarningTests
{
var plate = new Nest().CreatePlate();
plate.Parts.Add(new Part(new Drawing("tight", TightHole()), new Vector(1, 1)));
+ // The hole is eroded for a pierce when its lead-in does not fit; a pierce zone wider than
+ // the hole leaves even that pierce no room.
+ var parameters = ExplicitContourTests.Parameters();
+ parameters.PierceDiameter = 0.2;
- var planned = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan().Plates[0];
+ var planned = CuttingPlanBatch.Capture([plate], parameters, false).Plan().Plates[0];
Assert.False(planned.CanApplyWithWarnings);
Assert.Empty(planned.Result.ProposedOrder);
diff --git a/docs/geometry/lead-in-placement.md b/docs/geometry/lead-in-placement.md
index 18accaf4..be9d3bd7 100644
--- a/docs/geometry/lead-in-placement.md
+++ b/docs/geometry/lead-in-placement.md
@@ -70,16 +70,44 @@ corner's edges was picked; the arc then lies wholly on the scrap side of that ed
corners and cutout corners keep the picked edge's normal: a tangent arc there would cross the
part.
+## Cutouts too small for their lead-in
+
+Text, narrow slots and small holes often have no entry where the configured lead-in fits. When
+Plan Cutting has tried every entry of a cutout and none fits, it erodes the cutout instead
+(`ErodedPierce`, OpenNest.Core):
+
+1. The cutout's pole of inaccessibility gives its inscribed radius, the deepest any erosion can go.
+2. The cutout is offset inward (`Clipper.InflatePaths`, negative) by the pierce clearance
+ (`GetEffectivePierceClearance`) when it has room for it, else by nine tenths
+ (`ErodedPierce.DepthFraction`) of its inscribed radius, plus a margin for flattening, round
+ joins and Clipper rounding. What survives keeps a letter stroke's pierces on its centre line
+ and finds the widest spot of a feature that tapers.
+3. For each of the cutout's ranked entries, the eroded point nearest it is a pierce. The entry
+ becomes the cutout's native point closest to that pierce, and the lead-in runs straight from
+ the pierce to it (`PreparedContours.ErodedPierceEntries`, a `ContourChoice` whose `Pierce` is
+ set). A cutout so pierced has no lead-out: a feature too small for its lead-in has no room for
+ one either.
+4. A cutout too small to erode is pierced at its pole alone.
+5. `LeadPathValidator` checks each candidate like any other planner-chosen entry, pierce zone
+ included. When none is clear, the "No tested lead-in fits" finding says why.
+
+Cutouts where a configured lead-in fits keep it, so plans of other parts are unchanged. Hole entry
+selection, the preferred hole path and contour recovery all use the fallback. Only Plan Cutting
+uses it: the emitter's own automatic assignment and the manual lead-in preview keep the
+configured lead.
+
## Regression checks
-Run `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~CutoutCornerLeadInTests|FullyQualifiedName~PerimeterCornerLeadInTests"`.
+Run `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter "FullyQualifiedName~CutoutCornerLeadInTests|FullyQualifiedName~PerimeterCornerLeadInTests|FullyQualifiedName~ErodedPierceTests"`.
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. 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. `ArcLeadInTangentTests` checks curved-lead tangency, direction
+notches, and straight lead-outs. `ErodedPierceTests` covers the eroded pierces of a slot and a small round hole, a cutout that keeps
+its configured lead, and the containment of text cutouts that sit a micro-inch apart.
+`ArcLeadInTangentTests` checks curved-lead tangency, direction
and sweep on perimeters and cutouts under both windings, and at every perimeter corner from
either edge.