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