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.