From 97a468ee2cf711b426c4bd44e7dcae4160c800a6 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 11 Oct 2026 19:45:56 -0400 Subject: [PATCH] fix(cutting): recover checked routes through interlocking holed parts --- .../CuttingPlanning/JointCuttingPlanSearch.cs | 143 ++++++++++++++++-- .../ContourRecoveryFileTests.cs | 78 ++++++++++ .../InterlockingHoleRouteTests.cs | 86 +++++++++++ docs/cutting-planner.md | 32 +++- 4 files changed, 322 insertions(+), 17 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs create mode 100644 OpenNest.Tests/CuttingPlanning/InterlockingHoleRouteTests.cs diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 6c644a6..aafeedf 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -24,6 +24,7 @@ internal static class JointCuttingPlanSearch /// along its order before it gives up and learns from the part that blocked it. /// internal const int StallExpansionsPerEntry = 8; + private const int MaximumRecoveryHoles = 16; /// LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions. internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList Order, @@ -46,6 +47,9 @@ internal static class JointCuttingPlanSearch var preferred = walk.Follow(keptSequence, stall, null, preferredOnly: true); if (preferred.Order != null) return walk.Ready(preferred.Order); + var recovered = walk.Follow(keptSequence, stall, null, contourRecovery: true); + if (recovered.Order != null) + return walk.Ready(recovered.Order); } var kept = walk.Follow(keptSequence, null, null); return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted(); @@ -65,6 +69,9 @@ internal static class JointCuttingPlanSearch var preferred = walk.Follow(sequence, stall, resume, preferredOnly: true); if (preferred.Order != null) return walk.Ready(preferred.Order); + var recovered = walk.Follow(sequence, stall, null, contourRecovery: true); + if (recovered.Order != null) + return walk.Ready(recovered.Order); } var attempt = walk.Follow(sequence, stall, resume); if (attempt.Order != null) @@ -73,6 +80,12 @@ internal static class JointCuttingPlanSearch { // "Cut before" rules are a heuristic: a part blocked straight after another can // still be reachable via a third. Search every order with what budget remains. + if (preferEndpoints) + { + var recovery = walk.Follow(null, stall, null, contourRecovery: true); + if (recovery.Order != null) + return walk.Ready(recovery.Order); + } var full = walk.Follow(null, null, null); return full.Order != null ? walk.Ready(full.Order) : walk.Exhausted(); } @@ -189,7 +202,7 @@ internal static class JointCuttingPlanSearch /// With a stall limit the attempt ends once that many expansions pass without getting /// further along the order; it never backtracks behind its starting node. /// - internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false) + internal Attempt Follow(int[] sequence, int? stall, Node resume, bool preferredOnly = false, bool contourRecovery = false) { var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null); var attempt = new Attempt(sequence); @@ -211,8 +224,18 @@ internal static class JointCuttingPlanSearch if (attempt.Advance(node)) progressExpansions = Expansions; else if (stall is int limit && Expansions - progressExpansions > limit) - return attempt; - frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly).GetEnumerator(); + { + if (sequence != null || !contourRecovery) + return attempt; + // Diversify the first whole part rather than spending the remainder + // on endpoints of a stalled root's already completed prefix. + while (stack.Count > 1) + stack.Pop().Children?.Dispose(); + attempt.Restart(); + progressExpansions = Expansions; + continue; + } + frame.Children ??= OrderedChildren(node, sequence, attempt, preferredOnly, contourRecovery).GetEnumerator(); if (!frame.Children.MoveNext()) { stack.Pop().Children.Dispose(); @@ -269,15 +292,16 @@ internal static class JointCuttingPlanSearch /// one part and contour stage the automatic rank leads — OrderBy(Distance) alone /// would undo the look-ahead facing. Legacy (unranked) children keep distance order. /// - private IEnumerable OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly) + private IEnumerable OrderedChildren(Node node, int[] sequence, Attempt attempt, bool preferredOnly, bool contourRecovery) { // A prescribed next part (or an active part's next contour) has a single // source. Expand already yields its contour/entry rank order, so emit and // validate a sibling only when DFS reaches it. Free whole-part selection - // still needs every source's distances before it can rank them. - if (sequence != null || node.Active != null) - return Expand(node, sequence, attempt, preferredOnly); - var edges = Expand(node, sequence, attempt, preferredOnly).ToList(); + // still needs every source's distances before it can rank them. Recovery + // instead visits dependency-ready sources lazily by their material centres. + if (sequence != null || node.Active != null || contourRecovery) + return Expand(node, sequence, attempt, preferredOnly, contourRecovery); + var edges = Expand(node, sequence, attempt, preferredOnly, contourRecovery).ToList(); if (edges.Count <= 1) return edges.ToArray(); // Stable source order: the minimum incremental rapid per source, ties ordinal. @@ -295,7 +319,7 @@ internal static class JointCuttingPlanSearch .ToArray(); } - private IEnumerable Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly) + private IEnumerable Expand(Node node, int[] sequence, Attempt attempt, bool preferredOnly, bool contourRecovery) { IEnumerable sources; if (node.Active is { } active) @@ -309,6 +333,8 @@ internal static class JointCuttingPlanSearch : snapshot.Dependencies.IsReady(sequence[node.Order.Length], finished) ? [snapshot.Placements[sequence[node.Order.Length]]] : []; } + if (contourRecovery) + sources = sources.OrderBy(p => Centre(p).DistanceTo(node.Position)).ThenBy(p => p.SourceOrdinal); foreach (var source in sources) { token.ThrowIfCancellationRequested(); @@ -322,6 +348,12 @@ internal static class JointCuttingPlanSearch continue; } var prepared = source.Prepared; + if (contourRecovery && prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles && node.Active == null) + { + foreach (var edge in RecoverPart(node, sequence, source, attempt)) + yield return edge; + continue; + } if (prepared.Count > 1 && node.Active == null) { // Selecting an endpoint does not cut it. Each endpoint owns a separate @@ -420,6 +452,92 @@ internal static class JointCuttingPlanSearch } } + // Recovery only: solve directed contour transitions rather than assuming + // that a centre-to-centre hole tour can cross the already released material. + // One complete checked chain per outside endpoint; original DFS remains fallback. + private IEnumerable RecoverPart(Node node, int[] sequence, + FixedProgramPlacement source, Attempt attempt) + { + var prepared = source.Prepared; + var target = LookAheadCentre(node, sequence, source) - source.Location; + // Placement origins are authored program coordinates, not a downstream cut. + // Use the same material-centre look-ahead as the retained entry search. + var perimeters = prepared.AutomaticEntryCandidatesWithFallbacks(prepared.PerimeterOrdinal, token: token) + .OrderBy(c => c.Choice.Point.DistanceTo(target ?? node.Position - source.Location)) + .Where(c => Evaluate(source, c).Status == ContourFeasibilityStatus.Clear).Select(c => c.Choice).ToArray(); + 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(); + var motions = new Dictionary(); + var stop = Expansions + (long)StallExpansionsPerEntry * snapshot.MaxEntries * prepared.Count; + OwnedExecution Motion(ContourChoice choice) + { + if (motions.TryGetValue(choice, out var known)) return known; + CountExpansion(source); + return motions[choice] = ExecutionMotionReader.Read( + prepared.EmitCandidateForValidation(choice), source.Location, null, token); + } + for (var endpoint = 0; endpoint < perimeters.Length; endpoint++) + { + token.ThrowIfCancellationRequested(); + if (Expansions >= stop) yield break; + var outside = perimeters[endpoint]; + var chain = new List(); + // This dominance heuristic is confined to recovery: emitted + // seams can differ for the same released subset. Never prune full DFS. + var failed = new HashSet<(int Mask, long X, long Y)>(); + if (!Solve(0, node.Position, node.Checker)) continue; + var program = prepared.Emit(chain); + var execution = ExecutionMotionReader.Read(program, source.Location, node.Position, token); + var checker = node.Checker.Copy(); + CountExpansion(source); + if (!Check(source, execution, node.Position, checker, attempt)) continue; + var proposal = source.Propose(program, execution, chain, token); + yield return new(new([.. node.Order, proposal], execution.DeparturePoint, checker, null, node), + execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, endpoint, endpoint); + + bool Solve(int mask, Vector position, ReleasedContourState before) + { + token.ThrowIfCancellationRequested(); + if (Expansions >= stop) return false; + var key = (mask, BitConverter.DoubleToInt64Bits(position.X), + BitConverter.DoubleToInt64Bits(position.Y)); + if (failed.Contains(key)) return false; + if (mask == (1 << prepared.PerimeterOrdinal) - 1) + { + CountExpansion(source); + var findings = before.Copy().Check(Motion(outside), position, source.SourceOrdinal + 1, token); + rejected.AddRange(Map(snapshot, findings)); + attempt.NoteCrossings(source.SourceOrdinal, findings); + if (findings.Count != 0) { failed.Add(key); return false; } + chain.Add(outside); + return true; + } + var candidates = Enumerable.Range(0, holes.Length).Where(c => (mask & (1 << c)) == 0) + .SelectMany(c => holes[c]) + .OrderBy(c => Motion(c).Motions[0].End.DistanceTo(position)); + foreach (var choice in candidates) + { + token.ThrowIfCancellationRequested(); + if (Expansions >= stop) return false; + CountExpansion(source); + var execution = Motion(choice); + var after = before.Copy(); + var findings = after.Check(execution, position, source.SourceOrdinal + 1, token); + rejected.AddRange(Map(snapshot, findings)); + attempt.NoteCrossings(source.SourceOrdinal, findings); + if (findings.Count != 0) continue; + chain.Add(choice); + if (Solve(mask | (1 << choice.ContourOrdinal), execution.DeparturePoint, after)) return true; + chain.RemoveAt(chain.Count - 1); + } + failed.Add(key); + return false; + } + } + } + + /// /// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates /// converted exactly once: rank the (fallback-complemented) catalogue toward the @@ -576,6 +694,13 @@ internal static class JointCuttingPlanSearch return true; } + internal void Restart() + { + progress = -1; + Deepest = null; + Crossed.Clear(); + } + internal void NoteCrossings(int ordinal, IEnumerable findings) { if (Blocked != ordinal) diff --git a/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs b/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs new file mode 100644 index 0000000..56c0b6b --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourRecoveryFileTests.cs @@ -0,0 +1,78 @@ +using System.Security.Cryptography; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.IO; + +namespace OpenNest.Tests.CuttingPlanning; + +public class ContourRecoveryFileTests +{ + // Optional private input: never embed customer geometry or names in the repository. + [SkippableTheory] + [InlineData(false)] + [InlineData(true)] + public void InterlockedPlateFinishesThroughDesktopBatchWithoutChangingInput(bool lineLeads) + { + var path = TestConfig.GetExistingPath("InterlockedCuttingNestPath"); + Skip.If(path == null, "Configure InterlockedCuttingNestPath in ignored test-config.json."); + var bytes = SHA256.HashData(File.ReadAllBytes(path!)); + var nest = new NestReader(path).Read(); + var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToArray(); + Assert.NotEmpty(plates); + var parts = plates.SelectMany(p => p.Parts).ToArray(); + var programs = parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray(); + var poses = parts.Select(p => (p.Location, p.Rotation, p.LeadInsLocked)).ToArray(); + var parameters = Parameters(lineLeads); + + var proposal = CuttingPlanBatch.Capture(plates, parameters, preservePartOrder: false).Plan(); + + Assert.True(proposal.CanApply, string.Join(Environment.NewLine, proposal.Describe("in"))); + Assert.False(proposal.RequiresWarningAcceptance); + Assert.Equal(plates.Length, proposal.Plates.Count); + foreach (var planned in proposal.Plates) + { + Assert.True(planned.IsOverlapClear); + Assert.True(planned.Result.IndependentlyReplayed); + Assert.Equal(CuttingPlanStatus.Ready, planned.Result.Status); + Assert.InRange(planned.Result.Expansions, 1, CuttingPlanBatch.PlateBudget(planned.Plate.Parts.Count)); + Assert.Equal(planned.Plate.Parts.Count, planned.PartCount); + var snapshot = planned.Result.Snapshot; + foreach (var part in planned.Result.ProposedOrder) + { + Assert.Equal(snapshot.Placements[part.SourceOrdinal].Prepared.Count, part.ContourChoices.Count); + Assert.Equal(part.ContourChoices.Count, part.ContourChoices.Select(c => c.ContourOrdinal).Distinct().Count()); + } + var replay = CuttingPlanService.ReplayPrograms(snapshot, planned.Result.ProposedOrder, 0, default); + Assert.Equal(CuttingPlanStatus.Ready, replay.Status); + Assert.True(replay.IndependentlyReplayed); + Assert.Empty(replay.Findings); + + var bounded = CuttingPlanService.Plan(snapshot.WithSearchLimits(1, 16)); + Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status); + Assert.Equal(1, bounded.Expansions); + Assert.Empty(bounded.ProposedOrder); + Assert.False(bounded.IndependentlyReplayed); + } + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var cancelled = CuttingPlanBatch.Capture(plates, parameters, false).Plan(token: cancellation.Token); + Assert.True(cancelled.IsCancelled); + Assert.False(cancelled.CanApplyWithWarnings); + Assert.Equal(parts, plates.SelectMany(p => p.Parts)); + Assert.Equal(programs, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program))); + Assert.Equal(poses, parts.Select(p => (p.Location, p.Rotation, p.LeadInsLocked))); + Assert.Equal(bytes, SHA256.HashData(File.ReadAllBytes(path))); + } + + private static CuttingParameters Parameters(bool lineLeads) + { + var parameters = new CuttingParameters(); + if (lineLeads) + { + parameters.ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + parameters.InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + parameters.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + } + return parameters; + } +} diff --git a/OpenNest.Tests/CuttingPlanning/InterlockingHoleRouteTests.cs b/OpenNest.Tests/CuttingPlanning/InterlockingHoleRouteTests.cs new file mode 100644 index 0000000..2124c5e --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/InterlockingHoleRouteTests.cs @@ -0,0 +1,86 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class InterlockingHoleRouteTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void InterlockingHoledPartsPlanWithinDefaultBudget(bool leads, bool keep) + { + var a = Polygon([new(2.5, 2), new(2.5, 4), new(30, 4), new(30, 10), + new(2.5, 10), new(2.5, 12), new(32, 12), new(32, 2)]); + var b = Polygon([new(0, 4.5), new(0, 14.5), new(20, 14.5), new(20, 12.5), + new(2, 12.5), new(2, 6.5), new(20, 6.5), new(20, 4.5)]); + foreach (var x in new[] { 3.5, 12, 21, 31 }) + foreach (var y in new[] { 3.0, 11.0 }) Hole(a, x, y); + foreach (var x in new[] { 1.0, 7, 13, 19 }) + foreach (var y in new[] { 5.5, 13.5 }) Hole(b, x, y); + var parts = new[] { new Part(new Drawing("left-open bracket", a)), new Part(new Drawing("right-open bracket", b)) }; + var parameters = new CuttingParameters(); + if (leads) + { + parameters.ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + parameters.InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + parameters.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + } + var before = parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray(); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest(parts, + confirmedParameters: parameters, preservePartOrder: keep)); + var result = CuttingPlanService.Plan(snapshot); + Assert.True(result.Status == CuttingPlanStatus.Ready, + $"{result.Status}: {result.Expansions} " + string.Join(";", result.Findings.Select(f => f.Message))); + Assert.True(result.IndependentlyReplayed); + Assert.Equal(2, result.ProposedOrder.Count); + Assert.All(result.ProposedOrder, p => Assert.Equal(9, p.ContourChoices.Count)); + var replay = CuttingPlanService.ReplayPrograms(snapshot, result.ProposedOrder, 0, default); + Assert.Equal(CuttingPlanStatus.Ready, replay.Status); + Assert.True(replay.IndependentlyReplayed); + Assert.Equal(before, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program))); + Assert.InRange(result.Expansions, 1, 20000); + if (keep) + Assert.Equal(new[] { 0, 1 }, result.ProposedOrder.Select(p => p.SourceOrdinal)); + var repeated = CuttingPlanService.Plan(snapshot); + Assert.Equal(CuttingPlanStatus.Ready, repeated.Status); + Assert.Equal(result.Expansions, repeated.Expansions); + Assert.Equal(result.ProposedOrder.Select(p => ExplicitContourTests.Fingerprint(p.CopyProgram())), + repeated.ProposedOrder.Select(p => ExplicitContourTests.Fingerprint(p.CopyProgram()))); + + var bounded = CuttingPlanService.Plan(snapshot.WithSearchLimits(1, 16)); + Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, bounded.Status); + Assert.Equal(1, bounded.Expansions); + Assert.Empty(bounded.ProposedOrder); + using var cancellation = new CancellationTokenSource(); + var cancelAt = result.Expansions - 1; + var cancelled = CuttingPlanService.Plan(new CuttingPlanRequest(parts, + confirmedParameters: parameters, preservePartOrder: keep) + { + ExpansionObserver = n => { if (n == cancelAt) cancellation.Cancel(); } + }, cancellation.Token); + Assert.Equal(CuttingPlanStatus.Cancelled, cancelled.Status); + Assert.Equal(cancelAt, cancelled.Expansions); + Assert.Empty(cancelled.ProposedOrder); + Assert.False(cancelled.IndependentlyReplayed); + Assert.Equal(before, parts.Select(p => ExplicitContourTests.Fingerprint(p.Program))); + } + + private static OpenNest.CNC.Program Polygon(Vector[] vertices) + { + var program = new OpenNest.CNC.Program(); + program.MoveTo(vertices[0].X, vertices[0].Y); + foreach (var point in vertices.Skip(1).Append(vertices[0])) program.LineTo(point.X, point.Y); + return program; + } + + private static void Hole(OpenNest.CNC.Program program, double x, double y) + { + program.MoveTo(x + 0.2, y); + program.ArcTo(x + 0.2, y, x, y, RotationType.CCW); + } +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 8787f23..491d74e 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -99,8 +99,15 @@ each outside endpoint. This lets a later blocked approach change the previous part's departure without first exhausting combinations of its earlier holes. This preferred pass uses the same emitted-motion checks, shared expansion budget and stall limit as the retained search; on failure the full entry/hole-order -backtracking pass remains available. Single-part and no-hole requests keep their -existing search order. +backtracking pass remains available. Before that full pass, a failed preferred tour +also gets a directed-contour recovery pass for parts with at most 16 holes. It +tries native outside entries beyond the ranked shortlist, then builds a hole route +from actual emitted pierces, checking each transition against already released +contours. This reaches inner edges of interlocking concave parts which can be +missing from the facing-side shortlist. A failed released-hole-subset/departure +state is memoized only in recovery; it never prunes the retained full search. +Recovered whole programs are checked again and independently replayed. Single-part +and no-hole requests keep their existing search order. A preserved order is followed as given. Otherwise the order is an open travelling-salesman path over part centres from the start point: nearest neighbour, @@ -114,11 +121,18 @@ getting further, so it learns instead of retrying every entry combination of the parts before it. A rule that would contradict an order already required is skipped. "Cut before" rules are a heuristic (a part blocked straight after another may be reachable via a third), so once nothing new can be learned the remaining budget -goes to a full search that tries every ready part, nearest first; only when that -also fails is the result a refusal. +first tries sequence-free recovery, visiting dependency-ready parts lazily by +material-centre distance. When a branch stalls, it disposes the deeper search +frames and resumes alternatives at the root instead of spending the remainder +on that branch's completed prefix. Any budget left then goes to the retained full +search. Recovery shares the original total expansion budget; exhausting it still +returns a refusal, never a best-effort upgrade. Candidates use native closest points, vertices, midpoints and circle angles in -stable order, capped by `maxEntries`. Circle rounding, clamping, corner resolution +stable order. `maxEntries` caps the normal ranked shortlist, not the recovery +catalogue: recovery may inspect the remaining finite native entries, with every +precheck and visited transition charged to the same expansion budget. Circle +rounding, clamping, corner resolution and tab trimming happen during emission. Validation uses the actual emitted motions, never the nominal entry point alone. Existing lead styles are not shortened, disabled or substituted as a search fallback. @@ -168,8 +182,9 @@ are not tie breakers. When the next whole part is fixed by the tour or a part is already active, the search generates and checks contour alternatives on demand in contour/entry rank order. It does not emit every sibling before trying the first. Backtracking still -visits the remaining alternatives when needed; none is pruned. The free whole-part -fallback still evaluates all ready sources before ranking their travel distances. +visits the remaining alternatives when needed; none is pruned. The retained full whole-part +fallback still evaluates all ready sources before ranking their travel distances; +sequence-free recovery instead uses lazy centre-distance order. The expansion budget counts evaluated candidates and frontier ranking, including rejections, before emission; it is not a wall-clock timeout. An unvisited sibling @@ -205,7 +220,8 @@ parts the tour stays nearest-first; the look-ahead rank only orders the entries inside one part's contour stage, so distance sorting cannot undo the facing. Uncertain (numerically incomplete) validator answers are not geometric refusals: those candidates can fill remaining retained slots for emitted-prefix checking and -complete replay. The total retained entry count stays within `maxEntries`. A +complete replay. The normal ranked entry count stays within `maxEntries`; directed recovery can +inspect additional native entries as described above. A part/contour with no fitting lead in its fully evaluated catalogue is reported as "No tested lead-in fits on part N, contour M"; budget exhaustion stays a budget finding and incomplete checks are never presented as geometric