diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs new file mode 100644 index 0000000..a1964bb --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.Engine.CuttingPlanning; + +/// Why a selection ended the way it did. Honest metadata, never a geometric overclaim. +internal enum ContourSelectionShortfall +{ + /// Selection ended at the cap with the side scan satisfied: a complete verdict. + None, + + /// + /// The finite catalogue was fully evaluated and the selection is all there is. With an + /// empty selection this is exactly the sentence "no tested lead-in fits on part N, + /// contour M" — and ONLY here may that sentence be spoken. + /// + Exhausted, + + /// + /// At least one check could not complete (incomplete material or emission). Never + /// present this as geometric impossibility and never as "nothing fits". + /// + Incomplete, +} + +/// +/// Outcome of one bounded selection: selected choices in global rank order, how many +/// distinct candidates were evaluated, which ones (in catalogue order, for cost tests), and +/// why the selection stopped. +/// +internal sealed record ContourSelectionResult( + IReadOnlyList Choices, + int EvaluatedCount, + IReadOnlyList<(long, long)> EvaluatedKeys, + ContourSelectionShortfall Shortfall, + string? Reason); + +/// +/// 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 +/// 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 +/// exceeded and points are never manufactured on infeasible sides — a capped selection may +/// truthfully omit a feasible side. Sides are exactly the ranker's side geometry (the +/// candidate-set bounding rectangle). Verdicts are memoized per attempt (the adapter +/// memoizes too): no candidate is ever evaluated twice, and once selection settles the +/// untouched tail is never evaluated. No search/DFS changes and no larger search budget. +/// +internal static class ContourEntrySelection +{ + internal const int DefaultMaxEntries = 16; + + /// Below this cap all-side coverage is not promised; the cap and ranking bind first (caps 1-3). + internal const int SideCoverageMinCap = 4; + + /// + /// Selects up to feasible choices from one contour's + /// globally ranked catalogue. is the S07 adapter verdict, + /// called lazily at most once per distinct candidate. + /// + internal static ContourSelectionResult Select( + IReadOnlyList rankedCandidates, + Func evaluate, + int maxEntries = DefaultMaxEntries, + CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + if (rankedCandidates == null) + throw new ArgumentException("Ranked candidates are required.", nameof(rankedCandidates)); + if (evaluate == null) + throw new ArgumentException("A feasibility evaluation is required.", nameof(evaluate)); + if (maxEntries <= 0) + throw new ArgumentException("The cap must be positive.", nameof(maxEntries)); + + // Distinct points only — the cap must not double-count a geometric duplicate. + var candidates = new List(); + var seen = new HashSet<(long, long)>(); + foreach (var candidate in rankedCandidates) + if (seen.Add(candidate.GeometryKey)) + candidates.Add(candidate); + + var evaluated = new List(); + var verdicts = new Dictionary<(long, long), ContourFeasibilityVerdict>(); + var box = Box(candidates); + + var selected = new List(); + var sawIncomplete = false; + var index = 0; + + ContourFeasibilityVerdict Verdict(ContourEntryCandidate candidate) + { + if (!verdicts.TryGetValue(candidate.GeometryKey, out var known)) + { + token.ThrowIfCancellationRequested(); + known = evaluate(candidate); + verdicts[candidate.GeometryKey] = known; + evaluated.Add(candidate); + } + return known; + } + + // Phase 1: greedy global order until the cap fills or the catalogue ends. + for (; index < candidates.Count && selected.Count < maxEntries; index++) + { + var verdict = Verdict(candidates[index]); + if (verdict.Status == ContourFeasibilityStatus.Incomplete) + { + sawIncomplete = true; + break; + } + if (verdict.IsClear) + 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 + // 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; + if (coverage && !sawIncomplete) + for (var side = 0; side < 4 && !sawIncomplete; side++) + { + if (selected.Any(c => Sides(c, box).Contains(side))) + continue; + for (; index < candidates.Count; index++) + { + var verdict = Verdict(candidates[index]); + if (verdict.Status == ContourFeasibilityStatus.Incomplete) + { + sawIncomplete = true; + break; + } + if (!verdict.IsClear || !Sides(candidates[index], box).Contains(side)) + continue; + TryPlace(selected, candidates[index], box, maxEntries); + break; + } + } + + var exhausted = index >= candidates.Count; + ContourSelectionShortfall shortfall; + string? reason; + if (sawIncomplete) + { + shortfall = ContourSelectionShortfall.Incomplete; + reason = "At least one lead check could not complete; this is not a geometric verdict and nothing is proven impossible."; + } + else if (selected.Count >= maxEntries) + { + shortfall = ContourSelectionShortfall.None; + reason = null; + } + else if (exhausted) + { + shortfall = ContourSelectionShortfall.Exhausted; + reason = selected.Count == 0 + ? "No tested lead-in fits on this contour." + : null; + } + else + { + // Unreachable: phase 1 ends at cap or catalogue end and phase 2 chases every + // side to the end; a cancellation throws before this point. + shortfall = ContourSelectionShortfall.Incomplete; + reason = "Selection stopped before the catalogue ended."; + } + + 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)) + .Select(c => c.Choice) + .ToList(); + return new(ordered, evaluated.Count, + evaluated.Select(c => c.GeometryKey).ToList(), shortfall, reason); + } + + private static (double MinX, double MinY, double MaxX, double MaxY) Box( + List candidates) + { + double minX = double.PositiveInfinity, minY = double.PositiveInfinity, + maxX = double.NegativeInfinity, maxY = double.NegativeInfinity; + foreach (var c in candidates) + { + var p = c.Choice.Point; + if (p.X < minX) minX = p.X; + if (p.X > maxX) maxX = p.X; + if (p.Y < minY) minY = p.Y; + if (p.Y > maxY) maxY = p.Y; + } + if (!double.IsFinite(minX)) + return (0, 0, 0, 0); + return (minX, minY, maxX, maxY); + } + + /// Sides 0 left, 1 right, 2 bottom, 3 top of the candidate bounding rectangle that the point sits on. + private static List Sides(ContourEntryCandidate candidate, + (double MinX, double MinY, double MaxX, double MaxY) box) + { + var p = candidate.Choice.Point; + var left = p.X - box.MinX; + var right = box.MaxX - p.X; + var bottom = p.Y - box.MinY; + var top = box.MaxY - p.Y; + var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top)); + var sides = new List(2); + if (left <= min + PostVerificationGeometry.Epsilon) sides.Add(0); + if (right <= min + PostVerificationGeometry.Epsilon) sides.Add(1); + if (bottom <= min + PostVerificationGeometry.Epsilon) sides.Add(2); + if (top <= min + PostVerificationGeometry.Epsilon) sides.Add(3); + return sides; + } + + /// Appends when a slot remains, else replaces the worst (latest-ranked) candidate whose removal preserves every other covered side. + private static void TryPlace(List selected, ContourEntryCandidate candidate, + (double MinX, double MinY, double MaxX, double MaxY) box, int maxEntries) + { + if (selected.Count < maxEntries) + { + selected.Add(candidate); + return; + } + var incoming = Sides(candidate, box); + for (var i = selected.Count - 1; i >= 0; i--) + { + var removalSafe = true; + for (var side = 0; side < 4 && removalSafe; side++) + { + if (incoming.Contains(side)) + continue; // the replacement covers it + var covers = selected.Where((c, at) => at != i && Sides(c, box).Contains(side)).Any(); + var wasCovered = selected.Any(c => Sides(c, box).Contains(side)); + if (wasCovered && !covers) + removalSafe = false; + } + if (removalSafe) + { + selected[i] = candidate; + return; + } + } + // No safe victim: the side stays truthfully unrepresented at this cap. + } +} diff --git a/OpenNest.Tests/CuttingPlanning/ContourEntrySelectionTests.cs b/OpenNest.Tests/CuttingPlanning/ContourEntrySelectionTests.cs new file mode 100644 index 0000000..d3e0577 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourEntrySelectionTests.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// +/// The bounded, lazy selection over ranked candidates and the S07 verdict: cap first, side +/// coverage as a corrective scan, honest shortfall metadata — and the untouched tail is +/// never evaluated. Counts, not elapsed time. +/// +public class ContourEntrySelectionTests +{ + private static readonly PreparedContours Owner = + PreparedContours.Capture(ExplicitContourTests.Square(false), ExplicitContourTests.Parameters()); + + private static ContourEntryCandidate Cand(double x, double y, AutomaticEntryKind kind = AutomaticEntryKind.ConvexCorner) => + new(new ContourChoice(0, 0, new Vector(x, y)) { Owner = Owner }, kind); + + private static Func Feasible(params (double X, double Y)[] clearAt) + { + var set = new HashSet<(long, long)>(clearAt.Select(p => Cand(p.X, p.Y).GeometryKey)); + return c => set.Contains(c.GeometryKey) + ? new(ContourFeasibilityStatus.Clear, null) + : new(ContourFeasibilityStatus.Blocked, "test block"); + } + + private static Func AllClear() => + _ => new(ContourFeasibilityStatus.Clear, null); + + private static bool Selected(ContourSelectionResult result, double x, double y) => + result.Choices.Any(c => c.Point.DistanceTo(new Vector(x, y)) < 1e-9); + + [Fact] + public void LaterSafeCandidateSurvivesTwentyRejectedRivals() + { + // 18 blocked facing candidates cannot crowd out the one safe point at the tail. + var candidates = new List(); + for (var i = 0; i < 18; i++) + candidates.Add(Cand(i * 0.001, 0)); + candidates.Add(Cand(100, 100)); + + var result = ContourEntrySelection.Select(candidates, Feasible((100, 100))); + + Assert.Single(result.Choices); + Assert.True(Selected(result, 100, 100)); + Assert.Equal(19, result.EvaluatedCount); + Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall); + } + + [Fact] + public void EveryFeasibleSideIsRepresented() + { + // Corners on left/bottom and right/bottom, plus one point each on top-left and + // top-right; a wide tail of blocked points so coverage must chase the sides lazily. + var candidates = new List + { + Cand(0, 0), Cand(10, 0), // bottom corners + Cand(0, 10, AutomaticEntryKind.StraightMidpoint), // top-left + Cand(10, 10, AutomaticEntryKind.TangentJoint), // top-right + }; + for (var i = 1; i < 30; i++) + candidates.Add(Cand(i * 0.3, 0, AutomaticEntryKind.NearCorner)); // blocked bottom tail + + var result = ContourEntrySelection.Select(candidates, + Feasible((0, 0), (10, 0), (0, 10), (10, 10)), maxEntries: 6); + + Assert.Equal(4, result.Choices.Count); + Assert.True(Selected(result, 0, 10)); + Assert.True(Selected(result, 10, 10)); + // The blocked tail was chased to its end exactly once per missing side scan. + Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall); + } + + [Fact] + public void BlockedSideIsOmittedWithoutManufacturingPoints() + { + // No feasible candidate touches the top side. + var candidates = new List + { + Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10), + }; + + var result = ContourEntrySelection.Select(candidates, Feasible((0, 0), (10, 0))); + + Assert.Equal(2, result.Choices.Count); + Assert.False(Selected(result, 0, 10)); + Assert.False(Selected(result, 10, 10)); + // Every selected choice was feasible: nothing was manufactured. + Assert.All(result.Choices, c => Assert.InRange(c.Point.Y, 0, 0)); + } + + [Fact] + public void CapIsNeverExceededAndRankingComesFirst() + { + var candidates = new List(); + for (var i = 0; i < 10; i++) + candidates.Add(Cand(10 - i * 0.01, 0)); // first-ranked points on the right/bottom + + var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 3); + + Assert.Equal(3, result.Choices.Count); + Assert.Equal(3, result.EvaluatedCount); // stopped the moment the cap filled + Assert.Equal(ContourSelectionShortfall.None, result.Shortfall); + Assert.Equal(10.0, result.Choices[0].Point.X, 6); // rank-first point kept + } + + [Fact] + public void NormalCaseNeverEvaluatesTheUntouchedTail() + { + // Four corners first (all sides covered), then a long clear tail. The cap fills at + // 16 with full coverage, so the chase runs empty and the tail stays untouched. + var candidates = new List + { + Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10), + }; + for (var i = 0; i < 36; i++) + candidates.Add(Cand(1 + i * 0.1, 1)); + + var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 16); + + Assert.Equal(16, result.Choices.Count); + Assert.Equal(16, result.EvaluatedCount); + Assert.DoesNotContain(candidates[16].GeometryKey, result.EvaluatedKeys); + Assert.DoesNotContain(candidates[^1].GeometryKey, result.EvaluatedKeys); + Assert.Equal(ContourSelectionShortfall.None, result.Shortfall); + } + + [Fact] + public void AllBlockedIsExhaustedGeometricImpossibility() + { + var candidates = new[] { Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10) }; + + var result = ContourEntrySelection.Select(candidates, + _ => new(ContourFeasibilityStatus.Blocked, "test block")); + + Assert.Empty(result.Choices); + Assert.Equal(4, result.EvaluatedCount); + Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall); + Assert.Contains("No tested lead-in fits", result.Reason); + } + + [Fact] + public void IncompleteCheckIsNeverGeometricImpossibility() + { + var candidates = new[] { Cand(0, 0), Cand(10, 0) }; + var first = true; + + var result = ContourEntrySelection.Select(candidates, _ => + { + ContourFeasibilityVerdict verdict = first + ? new(ContourFeasibilityStatus.Incomplete, "incomplete material") + : new(ContourFeasibilityStatus.Clear, null); + first = false; + return verdict; + }); + + Assert.Equal(ContourSelectionShortfall.Incomplete, result.Shortfall); + Assert.DoesNotContain("No tested lead-in fits", result.Reason ?? string.Empty); + Assert.Contains("not a geometric verdict", result.Reason); + } + + [Fact] + public void SmallCapsObeyTheCapNotCoverage() + { + var candidates = new List + { + Cand(0, 0), Cand(10, 0), Cand(0, 10), Cand(10, 10), + }; + + var cap1 = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 1); + var cap3 = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 3); + + Assert.Single(cap1.Choices); + Assert.Equal(3, cap3.Choices.Count); + Assert.Equal(1, cap1.EvaluatedCount); // cap fills before any coverage scan + Assert.Equal(3, cap3.EvaluatedCount); + } + + [Fact] + public void InvalidCapStaysInvalid() + { + Assert.Throws(() => + ContourEntrySelection.Select(Array.Empty(), _ => null!, 0)); + Assert.Throws(() => + ContourEntrySelection.Select(Array.Empty(), _ => null!, -1)); + } + + [Fact] + public void DuplicatePointsNeverConsumeTwoSlots() + { + var candidates = new[] { Cand(5, 5), Cand(5, 5), Cand(5, 5) }; + + var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 2); + + Assert.Single(result.Choices); + Assert.Equal(1, result.EvaluatedCount); // deduplicated before evaluation + } + + [Fact] + public void EmptyCatalogueIsExhaustedWithoutEvaluation() + { + var result = ContourEntrySelection.Select(Array.Empty(), _ => null!); + + Assert.Empty(result.Choices); + Assert.Equal(0, result.EvaluatedCount); + Assert.Equal(ContourSelectionShortfall.Exhausted, result.Shortfall); + } + + [Fact] + public void CancellationStopsBeforeAnyEvaluation() + { + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + var candidates = new[] { Cand(0, 0) }; + + Assert.ThrowsAny(() => + ContourEntrySelection.Select(candidates, _ => new(ContourFeasibilityStatus.Clear, null), + token: cancelled.Token)); + } + + [Fact] + public void CoverageReplacementDropsTheWorstAndResorts() + { + // Cap 5 fills on points covering left/bottom/top; the right side is chased to (6,6), + // which must displace the worst selected point whose removal preserves the others + // ((3,4), the top-only cover is re-covered by (6,6)) and settle at its true rank slot. + var candidates = new List + { + Cand(0, 0), Cand(2, 0), Cand(0, 2), Cand(0, 4), Cand(3, 4), Cand(6, 6), + }; + + var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 5); + + Assert.Equal(5, result.Choices.Count); + Assert.True(Selected(result, 6, 6)); + Assert.False(Selected(result, 3, 4)); + Assert.Equal(6, result.EvaluatedCount); + // Evaluation order IS global rank order; the late chaser sorts to the end. + Assert.Equal(6, result.Choices[^1].Point.X); + Assert.Equal(0, result.Choices[0].Point.X); + Assert.Equal(0, result.Choices[0].Point.Y); + } + + [Fact] + public void CoverageNeverDestroysTheOnlyCoverOfAnotherSide() + { + // (2,0) is the ONLY cover of bottom; chasing the right side must not remove it even + // though it outranks nothing. The chaser replaces (2,4) (top, re-covered by (6,6)). + var candidates = new List + { + Cand(0, 0), Cand(2, 0), Cand(0, 2), Cand(2, 4), Cand(6, 6), + }; + + var result = ContourEntrySelection.Select(candidates, AllClear(), maxEntries: 4); + + // Cap 4: phase 1 keeps the first four (right not covered: (2,4) left+top only). + Assert.True(Selected(result, 2, 0)); // sole bottom cover survives + Assert.True(Selected(result, 6, 6)); // right-side chaser placed + Assert.False(Selected(result, 2, 4)); // safely displaced (top re-covered) + Assert.Equal(ContourSelectionShortfall.None, result.Shortfall); + } +}