From 40e4e559ddb7db17b1ba040ec89e75e44086bd9b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 19:09:20 -0400 Subject: [PATCH] fix(planning): rank hole entries by a clear way out to the next pierce With no lead-out the tool leaves a hole where it entered it. A tiny corner arc's midpoint lies on both facing sides of its hole, so the ranker scored it as an ideal two-side corner, but the tool leaves it along its 45-degree tangent: a shallow rapid to a nearby next hole re-entered the corner and crossed the hole just cut. Every outside endpoint's preferred hole chain then failed the same way, and the reorder search spent its budget on full backtracking. A 31-part plate of such parts needed 31,959 expansions against a default budget of 20,000. Hole entries whose straight rapid to the next pierce clears the hole itself now rank first, in the preferred chain and in the full search. This is a stable reorder only: no entry is dropped, and every emitted rapid is still checked. That plate now plans Ready on its first preferred pass, using 7,781 expansions; the budget is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../CuttingPlanning/JointCuttingPlanSearch.cs | 4 + .../PreferredContourEntries.cs | 33 +++++- .../ContourRecoveryFileTests.cs | 28 +++++ .../CuttingPlanning/HoleDepartureTests.cs | 103 ++++++++++++++++++ docs/cutting-planner.md | 10 ++ 5 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 OpenNest.Tests/CuttingPlanning/HoleDepartureTests.cs diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 0335b495..278e31a5 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -739,6 +739,10 @@ internal static class JointCuttingPlanSearch ordered = ordered.OrderBy(c => sightTargets.Count(sight => PostVerificationGeometry.Crosses( c.Choice.Point + source.Location, sight, perimeter, token))).ToList(); } + // A hole is left where it was entered too: entries whose rapid to the next + // pierce clears the hole itself lead. + if (target is { } next && contour != source.Prepared.PerimeterOrdinal) + ordered = ordered.DepartingClear(source.Prepared, next, token); var selection = ContourEntrySelection.Select(ordered, candidate => Evaluate(source, candidate), snapshot.MaxEntries, token); // No configured lead-in fits the perimeter: with a pierce diameter, try open scrap. diff --git a/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs b/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs index fcb5818c..f7a9dfe1 100644 --- a/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs +++ b/OpenNest.Engine/CuttingPlanning/PreferredContourEntries.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using OpenNest.CNC; using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; using OpenNest.Geometry; namespace OpenNest.Engine.CuttingPlanning; @@ -70,7 +71,7 @@ internal static class PreferredContourEntries // The S04 merged catalogue: preferred kinds when present, fallback kinds // (compass points, arc midpoints, ...) otherwise — pure circles included. var catalogue = prepared.AutomaticEntryCandidatesWithFallbacks(contour, target, token); - var ranked = catalogue.RankTowardNextCut(target, arrivalProxy); + var ranked = catalogue.RankTowardNextCut(target, arrivalProxy).DepartingClear(prepared, target, token); var selection = ContourEntrySelection.Select(ranked, evaluate, 1, token); if (selection.Choices.Count == 0) return new(Array.Empty(), selection.Shortfall, contour, @@ -84,6 +85,36 @@ internal static class PreferredContourEntries return new(choices, ContourSelectionShortfall.None, null, null); } + /// + /// Stable reordering of one hole's ranked entries: those whose straight rapid to + /// (the next cut's pierce, local) clears the hole itself come + /// first. With no lead-out the tool leaves a hole where it entered it, so from an entry + /// whose way out turns back into the hole (a tiny corner arc's midpoint, whose tangent + /// leaves at 45 degrees, ranks as a two-side corner) the rapid onward re-enters the hole + /// just cut and is refused. Order only: every entry stays and every rapid is still checked. + /// + internal static IReadOnlyList DepartingClear(this IReadOnlyList ranked, + PreparedContours prepared, Vector target, CancellationToken token) + { + if (ranked.Count < 2) + return ranked; + IReadOnlyList contour; + try + { + // Any entry's probe cuts the same contour (a tab gap aside); scribes and leads are not part of it. + var execution = ExecutionMotionReader.Read(prepared.EmitCandidateForValidation(ranked[0].Choice), + Vector.Zero, null, token); + contour = execution.Motions + .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null) + .Select(m => m.Curve).ToArray(); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException) + { + return ranked; // An unemittable probe is the lead checks' finding, not the ranker's. + } + return ranked.OrderBy(c => PostVerificationGeometry.Crosses(c.Choice.Point, target, contour, token) ? 1 : 0).ToList(); + } + /// /// The actual emitted pierce of one owned choice in local coordinates: the start of the /// first non-rapid motion (lead-in when present — that is where the next rapid must diff --git a/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs b/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs index 56c0b6b4..a1a9fe3c 100644 --- a/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs +++ b/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs @@ -64,6 +64,34 @@ public class ContourRecoveryFileTests Assert.Equal(bytes, SHA256.HashData(File.ReadAllBytes(path))); } + // Optional private input: a plate of holed parts whose preferred hole chains used to fail on + // cutouts left back across themselves, spending the whole default budget before a new order. + [SkippableFact] + public void HoledPlatesFindANewOrderWithinTheDefaultBudget() + { + var path = TestConfig.GetExistingPath("HoledReorderNestPath"); + Skip.If(path == null, "Configure HoledReorderNestPath in ignored test-config.json."); + var bytes = SHA256.HashData(File.ReadAllBytes(path!)); + var nest = new NestReader(path).Read(); + // Locked programs are fixed emissions: those plates yield unverified proposals by design. + var plates = nest.Plates.Where(p => p.Parts.Count > 0 && p.Parts.All(part => !part.LeadInsLocked)).ToArray(); + Skip.If(plates.Length == 0, "The configured nest has no plate of unlocked parts."); + var parts = plates.SelectMany(p => p.Parts).ToArray(); + var programs = parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray(); + + var proposal = CuttingPlanBatch.Capture(plates, Parameters(lineLeads: true), preservePartOrder: false).Plan(); + + Assert.True(proposal.CanApply, string.Join(Environment.NewLine, proposal.Describe("in"))); + foreach (var planned in proposal.Plates) + { + Assert.False(planned.KeptCurrentOrder); + Assert.True(planned.IsReady); + Assert.InRange(planned.Result.Expansions, 1, CuttingPlanBatch.PlateBudget(planned.Plate.Parts.Count)); + } + Assert.Equal(programs, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program))); + Assert.Equal(bytes, SHA256.HashData(File.ReadAllBytes(path))); + } + private static CuttingParameters Parameters(bool lineLeads) { var parameters = new CuttingParameters(); diff --git a/OpenNest.Tests/CuttingPlanning/HoleDepartureTests.cs b/OpenNest.Tests/CuttingPlanning/HoleDepartureTests.cs new file mode 100644 index 00000000..3a78eec3 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/HoleDepartureTests.cs @@ -0,0 +1,103 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Diagnostics; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class HoleDepartureTests +{ + private static readonly Vector Start = new(4, -3); + + // Holes on one axis with a cutout between them whose corners are rounded by a tiny radius. A + // corner arc's midpoint lies on both facing sides of the cutout, so it ranks as the ideal + // corner, but the tool leaves it along its 45 degree tangent: the shallow rapid to the hole + // just above re-enters the corner and runs back across the cutout just cut. + private static Program RoundedCutoutBetweenHoles() + { + const double r = 0.015; + var clean = LeadPathValidationTests.Rectangle(0, 0, 8, 10); + Circle(clean, new Vector(4, 2.3), 0.156); + clean.MoveTo(3 + r, 3); + clean.LineTo(5 - r, 3); clean.ArcTo(5, 3 + r, 5 - r, 3 + r, RotationType.CCW); + clean.LineTo(5, 5 - r); clean.ArcTo(5 - r, 5, 5 - r, 5 - r, RotationType.CCW); + clean.LineTo(3 + r, 5); clean.ArcTo(3, 5 - r, 3 + r, 5 - r, RotationType.CCW); + clean.LineTo(3, 3 + r); clean.ArcTo(3 + r, 3, 3 + r, 3 + r, RotationType.CCW); + Circle(clean, new Vector(4, 5.36), 0.156); + Circle(clean, new Vector(4, 8.5), 0.156); + return clean; + } + + private static void Circle(Program program, Vector centre, double radius) + { + program.MoveTo(centre.X + radius, centre.Y); + program.ArcTo(centre.X + radius, centre.Y, centre.X, centre.Y, RotationType.CCW); + } + + // The saved settings this regression came from: 0.15 line lead-ins at 90 degrees, no lead-outs. + private static CuttingParameters Parameters() + { + var lead = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + return new CuttingParameters + { + ExternalLeadIn = lead, + InternalLeadIn = lead, + ArcCircleLeadIn = lead, + ExternalLeadOut = new NoLeadOut(), + InternalLeadOut = new NoLeadOut(), + ArcCircleLeadOut = new NoLeadOut(), + PierceClearance = 0 + }; + } + + [Fact] + public void PreferredHoleEntryLeavesTowardTheNextHole() + { + var part = new Part(new Drawing("rounded cutout", RoundedCutoutBetweenHoles())); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part], Start, + confirmedParameters: Parameters(), preservePartOrder: true)); + var source = snapshot.Placements[0]; + var prepared = source.Prepared; + var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material, []); + var outside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal) + .RankTowardNextCut(null, Start), c => adapter.Check(c.Choice)).Choices[0]; + var centres = prepared.HoleCentres().Select(c => c ?? Vector.Zero).ToArray(); + var route = new[] { 0, 1, 2, 3 }; + var preferred = PreferredContourEntries.TryPlan(prepared, outside, route, centres, Start, + c => adapter.Check(c.Choice)); + Assert.True(preferred.IsPreferred, preferred.Reason); + + var cutout = preferred.HoleChoices[1]; + var next = PreferredContourEntries.Pierce(prepared, preferred.HoleChoices[2], default); + var curves = ExecutionMotionReader.Read(prepared.EmitCandidateForValidation(cutout), Vector.Zero, null, default) + .Motions.Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display).Select(m => m.Curve).ToArray(); + Assert.False(PostVerificationGeometry.Crosses(cutout.Point, next, curves, default), + $"The cutout's entry {cutout.Point} leaves back across the cutout toward {next}."); + var choices = preferred.HoleChoices.Append(outside).ToArray(); + var execution = ExecutionMotionReader.Read(prepared.Emit(choices), Vector.Zero, Start, default); + Assert.Empty(new ReleasedContourState().Check(execution, Start, 1)); + } + + [Fact] + public void PartsWithRoundedCutoutsPlanOnTheirPreferredHoleChains() + { + var parts = Enumerable.Range(0, 3) + .Select(i => new Part(new Drawing($"rounded cutout {i}", RoundedCutoutBetweenHoles()), new Vector(i * 10, 0))) + .ToArray(); + var fingerprints = parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray(); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts, Start, confirmedParameters: Parameters())); + var stall = JointCuttingPlanSearch.StallExpansionsPerEntry * snapshot.MaxEntries + * snapshot.Placements.Max(p => p.Prepared.Count); + + // Within one stall the preferred pass must find every part's hole chain; a corner entry + // whose way out re-enters the cutout fails each outside endpoint's chain in turn. + var result = CuttingPlanService.Plan(snapshot.WithSearchLimits(stall, snapshot.MaxEntries)); + + Assert.True(result.Status == CuttingPlanStatus.Ready, + $"{result.Status}: " + string.Join("; ", result.Findings.Select(f => f.Message))); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(fingerprints, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program))); + } +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 596f002e..5077749b 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -320,6 +320,16 @@ pierce, ignoring scribe marks: each hole faces the following contour's actual pi Convex corners lead the preference tiers, then straight midpoints/tangent joints, then native fallbacks. Reflex/cusp corners remain manual-only. +A hole is left where it was entered too, so ahead of those tiers its entries are ordered +by whether the straight rapid from the entry to the following contour's pierce clears the +hole itself. Without this, the midpoint of a tiny corner arc (it lies on both facing sides +of the hole, so it ranks as an ideal corner) is left along its 45° tangent, and a shallow +rapid to a nearby next hole re-enters the corner and crosses the hole just cut; every +outside endpoint's preferred chain then fails the same way. The full search orders its +hole entries the same way toward its route's next pierce. This is a stable reordering +measured from the nominal entry, which is the departure when there is no lead-out; no +entry is dropped and every emitted rapid is still checked. + This preference orders the search; it never certifies a rapid or prunes alternate retained entries or hole orders. A different outside endpoint recomputes its hole preference. Every standalone emitted prefix is replayed from the original part