From 9196d9157f3aaea5da2c4ef8cd2cb65209ba35cf Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 7 Oct 2026 16:03:16 -0400 Subject: [PATCH] fix(cutting): reuse clear candidates across side coverage scans --- .../CuttingPlanning/ContourEntrySelection.cs | 17 +- .../SideCoverageRegressionTests.cs | 248 ++++++++++++++++++ docs/cutting-planner.md | 9 +- 3 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/SideCoverageRegressionTests.cs diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs index 7945678..f070bda 100644 --- a/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs +++ b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs @@ -46,7 +46,7 @@ internal sealed record ContourSelectionResult( /// /// Bounded, lazy selection of lead-feasible entry candidates for ONE contour. Greedy global /// rank order until the cap (default 16) fills or the finite catalogue ends — rejected and -/// incomplete candidates never consume a slot. When the cap can afford it (5+), one +/// incomplete candidates never consume a slot. When the cap can afford it (4+), one /// corrective scan makes sure every side of the candidate bounding rectangle that HAS a /// feasible candidate is represented, replacing the worst selected candidate only when /// every other covered side survives; one corner may cover two sides. The cap is never @@ -101,9 +101,9 @@ internal static class ContourEntrySelection ContourFeasibilityVerdict Verdict(ContourEntryCandidate candidate) { + token.ThrowIfCancellationRequested(); if (!verdicts.TryGetValue(candidate.GeometryKey, out var known)) { - token.ThrowIfCancellationRequested(); known = evaluate(candidate); verdicts[candidate.GeometryKey] = known; evaluated.Add(candidate); @@ -128,8 +128,9 @@ internal static class ContourEntrySelection selected.Add(candidates[index]); } - // Phase 2: side coverage when the cap affords it. Every missing side with an - // unexamined tail is chased lazily; a clear candidate on that side is appended when + // Phase 2: reconsider the globally ranked catalogue for each missing side, reusing + // verdicts passed while chasing earlier sides and evaluating the tail lazily. + // A clear candidate on that side is appended when // a slot remains, otherwise it replaces the worst selected candidate whose removal // keeps every other covered side covered. var coverage = maxEntries >= SideCoverageMinCap && selected.Count > 0; @@ -138,7 +139,7 @@ internal static class ContourEntrySelection { if (selected.Any(c => Sides(c, box).Contains(side))) continue; - for (; index < candidates.Count; index++) + for (index = 0; index < candidates.Count; index++) { var verdict = Verdict(candidates[index]); if (verdict.Status == ContourFeasibilityStatus.Incomplete) @@ -151,6 +152,9 @@ internal static class ContourEntrySelection if (!verdict.IsClear || !Sides(candidates[index], box).Contains(side)) continue; TryPlace(selected, candidates[index], box, maxEntries); + // A reused candidate can outrank an earlier replacement. Keep the + // safe-victim scan in global rank order for the next missing side. + selected.Sort((a, b) => candidates.IndexOf(a).CompareTo(candidates.IndexOf(b))); break; } } @@ -185,8 +189,7 @@ internal static class ContourEntrySelection } var ordered = selected - .OrderBy(c => evaluated.FindIndex(x => x.GeometryKey == c.GeometryKey) is var e && e >= 0 - ? e : candidates.FindIndex(x => x.GeometryKey == c.GeometryKey)) + .OrderBy(c => candidates.IndexOf(c)) .Select(c => c.Choice) .ToList(); return new(ordered, evaluated.Count, diff --git a/OpenNest.Tests/CuttingPlanning/SideCoverageRegressionTests.cs b/OpenNest.Tests/CuttingPlanning/SideCoverageRegressionTests.cs new file mode 100644 index 0000000..9224cbd --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/SideCoverageRegressionTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class SideCoverageRegressionTests +{ + [Fact] + public void DefaultCap_ReusesClearBottomCandidatePassedWhileSeekingLeft() + { + var clean = new Program(); + clean.MoveTo(0, 0); + clean.LineTo(0, 10); + for (var i = 1; i <= 12; i++) + clean.LineTo(i * 10.0 / 12, 10); + for (var i = 1; i <= 12; i++) + clean.LineTo(10, 10 - i * 10.0 / 12); + clean.LineTo(0, 0); + var parameters = Parameters(0.15, 90, 0); + var prepared = PreparedContours.Capture(clean, parameters); + var blockers = new[] + { + Rectangle(-0.5, -0.5, 1.5, 0.4), Rectangle(9, -0.5, 1.05, 0.4), + Rectangle(10.1, -0.3, 0.5, 0.6), Rectangle(-0.5, 9.8, 0.4, 0.4), + }; + var target = new Vector(20, 20); + var ranked = prepared.AutomaticEntryCandidatesWithFallbacks(0, target) + .RankTowardNextCut(target, new Vector(10, -2)); + var adapter = Adapter(prepared, clean, blockers); + var calls = new HashSet<(long, long)>(); + + var selected = ContourEntrySelection.Select(ranked, candidate => + { + Assert.True(calls.Add(candidate.GeometryKey), "A candidate was evaluated twice."); + return adapter.Check(candidate.Choice); + }); + + Assert.Equal(16, selected.Choices.Count); + var bottom = ranked.Single(c => c.Choice.Point.DistanceTo(new Vector(5, 0)) < 1e-9); + Assert.Equal(32, ranked.ToList().IndexOf(bottom)); + Assert.True(adapter.Check(bottom.Choice).IsClear); + Assert.Equal(new[] { 0, 1, 2, 3 }, ClearSides(ranked, adapter)); + Assert.Equal(new[] { 0, 1, 2, 3 }, SelectedSides(selected, ranked)); + Assert.Contains(selected.Choices, c => c.Point.DistanceTo(bottom.Choice.Point) < 1e-9); + Assert.All(selected.Choices, c => Assert.True(adapter.Check(c).IsClear)); + Assert.Equal(ContourSelectionShortfall.None, selected.Shortfall); + AssertGlobalOrder(selected, ranked); + Assert.Equal(calls.Count, selected.EvaluatedCount); + } + + [Fact] + public void SupportedCapFour_ReusesClearTopCandidatePassedWhileSeekingBottom() + { + var clean = Polygon(); + var prepared = PreparedContours.Capture(clean, Parameters()); + var target = new Vector(100 * System.Math.Cos(System.Math.PI), 100 * System.Math.Sin(System.Math.PI)); + var arrival = new Vector(40 * System.Math.Cos(3 * System.Math.PI / 2), 40 * System.Math.Sin(3 * System.Math.PI / 2)); + var ranked = prepared.AutomaticEntryCandidatesWithFallbacks(0, target).RankTowardNextCut(target, arrival); + var adapter = Adapter(prepared, clean, []); + + var selected = ContourEntrySelection.Select(ranked, c => adapter.Check(c.Choice), 4); + + Assert.Equal(4, selected.Choices.Count); + Assert.True(adapter.Check(ranked[4].Choice).IsClear); + Assert.Contains(3, Sides(ranked[4].Choice.Point, ranked)); + Assert.Equal(new[] { 0, 1, 2, 3 }, ClearSides(ranked, adapter)); + Assert.Equal(new[] { 0, 1, 2, 3 }, SelectedSides(selected, ranked)); + Assert.All(selected.Choices, c => Assert.True(adapter.Check(c).IsClear)); + AssertGlobalOrder(selected, ranked); + } + + [Theory] + [InlineData(4)] + [InlineData(16)] + public void FullCheckedRoute_RetainsTopDepartureToLockedMarkedSquare(int cap) + { + var parameters = Parameters(); + var first = new Part(new Drawing("convex polygon", Polygon())); + var nextClean = Rectangle(0, 0, 2, 2); + nextClean.MoveTo(101, 21); + nextClean.Codes.Add(new LinearMove(102, 21) { Layer = LayerType.Scribe }); + var next = new Part(new Drawing("locked marked square", nextClean), new Vector(-101, -1)); + var nextPrepared = PreparedContours.Capture(nextClean, parameters); + Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit([nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true)); + var before = new[] { ExplicitContourTests.Fingerprint(first.Program), ExplicitContourTests.Fingerprint(next.Program) }; + var start = new Vector(-7.347880794884118e-15, -40); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], start, + confirmedParameters: parameters, preservePartOrder: true, maxEntries: cap)); + var source = snapshot.Placements[0]; + var prepared = source.Prepared; + var witness = prepared.AutomaticEntryCandidatesWithFallbacks(0, new Vector(-100, 0)) + .First(c => c.Choice.Point.DistanceTo(new Vector(-4.99220639970189, 8.664749001718139)) < 1e-9); + var witnessProgram = prepared.Emit([witness.Choice]); + var witnessExecution = ExecutionMotionReader.Read(witnessProgram, source.Location, start, default); + var witnessProposal = source.Propose(witnessProgram, witnessExecution, [witness.Choice], default); + var witnessReplay = CuttingPlanService.ReplayPrograms(snapshot, [witnessProposal, snapshot.Placements[1]], 0, default); + Assert.Equal(CuttingPlanStatus.Ready, witnessReplay.Status); + Assert.True(witnessReplay.IndependentlyReplayed); + + var result = CuttingPlanService.Plan(snapshot); + + Assert.True(result.Status == CuttingPlanStatus.Ready, + $"{result.Status}, {result.Expansions} expansions: {string.Join("; ", result.Findings.Select(f => f.Message))}"); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + Assert.Single(result.ProposedOrder[0].ContourChoices); + Assert.False(result.ProposedOrder[1].IsRegenerated); + var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default); + Assert.Equal(CuttingPlanStatus.Ready, replay.Status); + Assert.True(replay.IndependentlyReplayed); + Assert.Equal(before, new[] { ExplicitContourTests.Fingerprint(first.Program), ExplicitContourTests.Fingerprint(next.Program) }); + } + + [Fact] + public void CorrectiveRescan_PreservesGlobalRankAndLeavesTailUnevaluated() + { + var candidates = CorrectiveCandidates(); + var calls = new HashSet<(long, long)>(); + var result = ContourEntrySelection.Select(candidates, c => + { + Assert.True(calls.Add(c.GeometryKey)); + return new(ContourFeasibilityStatus.Clear, null); + }, 4); + + Assert.Equal(new[] { candidates[0].Choice, candidates[1].Choice, candidates[4].Choice, candidates[5].Choice }, result.Choices); + Assert.Equal(6, result.EvaluatedCount); + Assert.DoesNotContain(candidates[6].GeometryKey, result.EvaluatedKeys); + Assert.Equal(ContourSelectionShortfall.None, result.Shortfall); + } + + [Theory] + [InlineData(ContourFeasibilityStatus.Blocked)] + [InlineData(ContourFeasibilityStatus.Incomplete)] + public void CorrectiveRescan_DoesNotPromoteUnclearSide(ContourFeasibilityStatus status) + { + var candidates = CorrectiveCandidates(); + var calls = new HashSet<(long, long)>(); + var result = ContourEntrySelection.Select(candidates, c => + { + Assert.True(calls.Add(c.GeometryKey)); + return c.GeometryKey == candidates[4].GeometryKey + ? new(status, "unclear bottom") : new(ContourFeasibilityStatus.Clear, null); + }, 4); + + Assert.DoesNotContain(candidates[4].Choice, result.Choices); + Assert.Equal(4, result.Choices.Count); + Assert.Equal(status == ContourFeasibilityStatus.Incomplete ? ContourSelectionShortfall.Incomplete : ContourSelectionShortfall.None, result.Shortfall); + if (status == ContourFeasibilityStatus.Incomplete) + { + Assert.Contains(candidates[4].Choice, result.UncertainChoices); + Assert.DoesNotContain("No tested lead-in fits", result.Reason); + } + Assert.Equal(calls.Count, result.EvaluatedCount); + } + + [Fact] + public void CorrectiveRescan_HonoursCancellationBeforeReusingCachedVerdict() + { + var candidates = CorrectiveCandidates(); + using var cancellation = new CancellationTokenSource(); + Assert.ThrowsAny(() => ContourEntrySelection.Select(candidates, c => + { + if (c.GeometryKey == candidates[5].GeometryKey) + cancellation.Cancel(); + return new(ContourFeasibilityStatus.Clear, null); + }, 4, cancellation.Token)); + } + + private static ContourEntryCandidate[] CorrectiveCandidates() + { + var prepared = PreparedContours.Capture(Rectangle(0, 0, 10, 10), Parameters()); + return new[] { (10, 5), (5, 10), (9, 5), (5, 9), (5, 0), (0, 5), (6, 1) } + .Select(p => new ContourEntryCandidate(new ContourChoice(0, 0, new Vector(p.Item1, p.Item2)) { Owner = prepared }, AutomaticEntryKind.ConvexCorner)).ToArray(); + } + + private static ContourEntryFeasibility Adapter(PreparedContours prepared, Program clean, Program[] blockers) + { + var own = LeadMaterialSnapshot.Capture(clean, Vector.Zero); + var others = blockers.Select(p => LeadMaterialSnapshot.Capture(p, Vector.Zero)).ToArray(); + Assert.True(own.IsComplete, own.Reason); + Assert.All(others, m => Assert.True(m.IsComplete, m.Reason)); + return new(prepared, Vector.Zero, own, others); + } + + private static int[] ClearSides(IReadOnlyList ranked, ContourEntryFeasibility adapter) => + ranked.Where(c => adapter.Check(c.Choice).IsClear).SelectMany(c => Sides(c.Choice.Point, ranked)).Distinct().Order().ToArray(); + + private static int[] SelectedSides(ContourSelectionResult selected, IReadOnlyList ranked) => + selected.Choices.SelectMany(c => Sides(c.Point, ranked)).Distinct().Order().ToArray(); + + private static IEnumerable Sides(Vector point, IReadOnlyList ranked) + { + var gaps = new[] + { + point.X - ranked.Min(c => c.Choice.Point.X), ranked.Max(c => c.Choice.Point.X) - point.X, + point.Y - ranked.Min(c => c.Choice.Point.Y), ranked.Max(c => c.Choice.Point.Y) - point.Y, + }; + return Enumerable.Range(0, 4).Where(side => gaps[side] <= gaps.Min() + 1e-6); + } + + private static void AssertGlobalOrder(ContourSelectionResult selected, IReadOnlyList ranked) + { + var ranks = selected.Choices.Select(c => ranked.ToList().FindIndex(candidate => ReferenceEquals(candidate.Choice, c))).ToArray(); + Assert.Equal(ranks.Order(), ranks); + } + + private static Program Polygon() + { + var points = Enumerable.Range(0, 16).Select(i => new Vector( + 10 * System.Math.Cos(0.13 - i * 2 * System.Math.PI / 16), + 10 * System.Math.Sin(0.13 - i * 2 * System.Math.PI / 16))).ToArray(); + var program = new Program(); + program.MoveTo(points[0].X, points[0].Y); + foreach (var point in points.Skip(1).Append(points[0])) + program.LineTo(point.X, point.Y); + return program; + } + + private static Program Rectangle(double x, double y, double width, double height) + { + var program = new Program(); + program.MoveTo(x, y); + program.LineTo(x, y + height); + program.LineTo(x + width, y + height); + program.LineTo(x + width, y); + program.LineTo(x, y); + return program; + } + + private static CuttingParameters Parameters(double length = 0.3, double angle = 45, double clearance = 0.05) => new() + { + ExternalLeadIn = new LineLeadIn { Length = length, ApproachAngle = angle }, + InternalLeadIn = new LineLeadIn { Length = length, ApproachAngle = angle }, + ArcCircleLeadIn = new LineLeadIn { Length = length }, + ExternalLeadOut = new NoLeadOut(), + InternalLeadOut = new NoLeadOut(), + TabsEnabled = false, + RoundLeadInAngles = false, + PierceClearance = clearance, + }; +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index cdaeb4f..bfa2a5c 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -141,8 +141,13 @@ unranked fallback or a proof of geometric impossibility. An unlocked part's outside entry is chosen automatically toward the NEXT cut: the ranker orders the native candidate catalogue by the facing side(s) of the next part's placed-material centre, and the shared lead validator certifies each emitted lead lazily until up to -`maxEntries` feasible candidates remain (side coverage when the cap affords -it). The next cut is the next unfinished part in a supplied order — recomputed +`maxEntries` feasible candidates remain. At caps of four or more, a corrective +scan reconsiders memoized clear candidates for each missing side before evaluating +more of the catalogue; a later side cannot lose a usable point merely because +an earlier side's scan passed it. Replacements preserve other covered sides and +global rank, never exceed the cap, and stop evaluating the tail once coverage +settles. Smaller caps retain rank priority rather than promising all-side coverage. +The next cut is the next unfinished part in a supplied order — recomputed after every learned-order replan — or, in the full fallback search, the nearest dependency-ready remaining part, stable-ordinal ties; the last part has no target and ranks by tier then distance to the tool's arrival. Between source