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