From 414e9b4e75e526cd83762c00073b43d7672f4b08 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 7 Oct 2026 14:58:23 -0400 Subject: [PATCH] fix(cutting): prefer hole entries facing the next contour --- .../CNC/CuttingPlanning/PreparedContours.cs | 17 ++ .../CuttingPlanning/JointCuttingPlanSearch.cs | 141 +++++++++--- .../CuttingPlanning/HoleLookAheadTests.cs | 214 ++++++++++++++++++ docs/cutting-planner.md | 34 ++- 4 files changed, 372 insertions(+), 34 deletions(-) create mode 100644 OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index 777cd33..ef1e206 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -493,6 +493,23 @@ public sealed class PreparedContours private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count ? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal]; + /// + /// Representative POINTS per contour ordinal (bounding-box centre) for hole routing + /// only — the S10 CuttingHoleOrder proxy. The perimeter has no entry; it is the + /// route's fixed endpoint, not a stop. These are ordering proxies, never cut points. + /// + internal IReadOnlyList HoleCentres(CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + var centres = new Vector?[Count]; + for (var contour = 0; contour < PerimeterOrdinal; contour++) + { + token.ThrowIfCancellationRequested(); + centres[contour] = GetShape(contour).BoundingBox.Center; + } + return centres; + } + private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint(); private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint(); diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index cf69414..848fc1f 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -255,6 +255,7 @@ internal static class JointCuttingPlanSearch .ToDictionary(x => x.Edge, x => x.Rank); return edges .OrderBy(e => sourceOrder[e]) + .ThenBy(e => e.ContourRank) .ThenBy(e => e.Contour) .ThenBy(e => e.Rank) .ThenBy(e => e.Distance) @@ -289,6 +290,25 @@ internal static class JointCuttingPlanSearch continue; } var prepared = source.Prepared; + if (prepared.Count > 1 && node.Active == null) + { + // Selecting an endpoint does not cut it. Each endpoint owns a separate + // branch, whose holes are still emitted first and checked normally. + CountExpansion(source); + var perimeters = AutomaticEntries(node, sequence, source, prepared.PerimeterOrdinal); + for (var index = 0; index < perimeters.Count; index++) + { + CountExpansion(source); + var state = new ActivePart(source, [], node.Position, node.Checker, 0, node, + perimeters[index]); + yield return new(new(node.Order, node.Position, node.Checker, state, null), + Centre(source).DistanceTo(node.Position), source.SourceOrdinal, -1, index, index); + } + continue; + } + var preference = node.Active?.Preference; + if (node.Active?.Perimeter is { } outside && preference == null) + preference = PlanHoles(node.Active, outside); var choices = node.Active?.Choices ?? []; var arrival = node.Active?.Arrival ?? node.Position; var before = node.Active?.Before ?? node.Checker; @@ -298,20 +318,34 @@ internal static class JointCuttingPlanSearch foreach (var contour in contours) { token.ThrowIfCancellationRequested(); - // No-hole parts: the single outside contour gets the automatic entry - // pipeline, facing the next cut (S03-S08). Holed parts keep the legacy - // nearest-entry path until S12 wires hole look-ahead. - var automatic = prepared.Count == 1; IReadOnlyList entries; - if (!automatic) - entries = prepared.Entries(contour, node.Position - source.Location, snapshot.MaxEntries, token); + var contourRank = 0; + if (node.Active?.Perimeter is { } perimeter) + { + if (contour == prepared.PerimeterOrdinal) + entries = [perimeter]; + else + { + contourRank = Array.IndexOf(preference.Route, contour); + var downstream = preference.Route.Skip(contourRank + 1) + .FirstOrDefault(c => !choices.Any(e => e.ContourOrdinal == c), -1); + var target = downstream < 0 ? preference.PerimeterPierce + : preference.Pierces[downstream]; + entries = SelectEntries(source, contour, target, node.Position - source.Location); + // Preference only reorders retained candidates. Neither a failed + // preferred entry nor an alternate hole order prunes this branch. + if (preference.Entries.TryGetValue(contour, out var preferred)) + entries = entries.OrderBy(e => e.Point.DistanceTo(preferred.Point) + <= PostVerificationGeometry.Epsilon ? 0 : 1).ToArray(); + } + } else { - CountExpansion(source); // The catalogue/validator pipeline is search work, counted here. + CountExpansion(source); entries = AutomaticEntries(node, sequence, source, contour); - if (entries.Count == 0) - continue; // the S08 finding (when complete) is already recorded } + if (entries.Count == 0) + continue; for (var entry = 0; entry < entries.Count; entry++) { CountExpansion(source); // Before emission/native queries, including rejected candidates. @@ -337,9 +371,10 @@ internal static class JointCuttingPlanSearch ? new Node([.. node.Order, source.Propose(program, execution, prefix, token)], execution.DeparturePoint, checker, null, boundary) : new Node(node.Order, execution.DeparturePoint, checker, - new(source, prefix, arrival, before, distance, boundary), null); + new(source, prefix, arrival, before, distance, boundary, + node.Active?.Perimeter, preference), null); yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, - contour, entry, automatic ? entry : int.MinValue); + contour, entry, entry, contourRank); } } } @@ -360,19 +395,31 @@ internal static class JointCuttingPlanSearch // One global->local conversion of target and arrival; geometry is already rotated. Vector? local = LookAheadCentre(node, sequence, source) is { } target ? target - source.Location : null; - var preferred = prepared.AutomaticEntryCandidates(contour, token); - var catalogue = preferred.Count > 0 - ? preferred - : prepared.AutomaticEntryCandidatesWithFallbacks(contour, local, token); - var ordered = catalogue.RankTowardNextCut(local, arrival); - var adapter = feasibility.TryGetValue(source.SourceOrdinal, out var known) - ? known + return SelectEntries(source, contour, local, arrival); + } + + private ContourEntryFeasibility Adapter(FixedProgramPlacement source) => + feasibility.TryGetValue(source.SourceOrdinal, out var known) ? known : feasibility[source.SourceOrdinal] = new ContourEntryFeasibility( - prepared, source.Location, source.Material, materials); + source.Prepared, source.Location, source.Material, materials); + + private ContourFeasibilityVerdict Evaluate(FixedProgramPlacement source, ContourEntryCandidate candidate) + { + CountExpansion(source); + var adapter = Adapter(source); var before = adapter.EvaluationCount; - var selection = ContourEntrySelection.Select(ordered, - candidate => adapter.Check(candidate.Choice, token: token), snapshot.MaxEntries, token); + var verdict = adapter.Check(candidate.Choice, token: token); LeadPrechecks += adapter.EvaluationCount - before; + return verdict; + } + + private IReadOnlyList SelectEntries(FixedProgramPlacement source, int contour, + Vector? target, Vector arrival) + { + var catalogue = source.Prepared.AutomaticEntryCandidatesWithFallbacks(contour, target, token); + var ordered = catalogue.RankTowardNextCut(target, arrival); + var selection = ContourEntrySelection.Select(ordered, + candidate => Evaluate(source, candidate), snapshot.MaxEntries, token); if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0) rejected.Add(Finding(source, PostVerificationKind.Incomplete, $"Contour {contour}: {selection.Reason}")); @@ -382,9 +429,46 @@ internal static class JointCuttingPlanSearch $"No tested lead-in fits on part {source.SourceOrdinal}, contour {contour}: {selection.Reason}")); // Uncertain candidates are NOT refused by the precheck: they reach the emitted- // prefix Check and complete replay, which remain the authority on them. - return selection.Choices.Count == 0 - ? selection.UncertainChoices - : [.. selection.Choices, .. selection.UncertainChoices]; + return selection.Choices.Concat(selection.UncertainChoices).Take(snapshot.MaxEntries).ToArray(); + } + + private HolePreference PlanHoles(ActivePart active, ContourChoice perimeter) + { + var source = active.Source; + var prepared = source.Prepared; + var centres = prepared.HoleCentres(token).Select(c => c ?? Vector.Zero).ToArray(); + var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray(); + var localArrival = active.Arrival - source.Location; + var endpoint = perimeter.Point; + var entries = new Dictionary(); + var pierces = holes.ToDictionary(h => h, h => centres[h]); + var route = holes; + try + { + CountExpansion(source); + endpoint = PreferredContourEntries.Pierce(prepared, perimeter, token); + route = CuttingHoleOrder.Plan(holes, centres, localArrival, endpoint, token).ToArray(); + var proposal = PreferredContourEntries.TryPlan(prepared, perimeter, route, centres, + localArrival, candidate => Evaluate(source, candidate), token); + if (proposal.IsPreferred) + foreach (var choice in proposal.HoleChoices) + { + CountExpansion(source); + entries.Add(choice.ContourOrdinal, choice); + pierces[choice.ContourOrdinal] = PreferredContourEntries.Pierce(prepared, choice, token); + } + else + rejected.Add(Finding(source, proposal.Shortfall == ContourSelectionShortfall.Incomplete + ? PostVerificationKind.Incomplete : null, proposal.Reason ?? "No preferred hole path.")); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException) + { + // A proposal is not a gate. Unknown emissions still reach prefix/final + // replay through retained candidates; never label an uncertain probe clear. + rejected.Add(Finding(source, PostVerificationKind.Incomplete, + $"Preferred hole path unavailable: {ex.Message}")); + } + return new(route, entries, pierces, endpoint); } private void CountExpansion(FixedProgramPlacement source) @@ -467,13 +551,18 @@ internal static class JointCuttingPlanSearch } private sealed class BudgetExceededException : Exception; + // Every endpoint branch owns its preference; all retained entries/orders remain searchable. + private sealed record HolePreference(int[] Route, IReadOnlyDictionary Entries, + IReadOnlyDictionary Pierces, Vector PerimeterPierce); private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival, - ReleasedContourState Before, double Distance, Node Boundary); + ReleasedContourState Before, double Distance, Node Boundary, + ContourChoice Perimeter = null, HolePreference Preference = null); /// A search state; Previous links a whole-part boundary to the boundary before it. private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker, ActivePart Active, Node Previous); /// Rank is the automatic selection slot (entry order) inside its contour stage; int.MinValue for legacy children. - private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry, int Rank = int.MinValue); + private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry, + int Rank = int.MinValue, int ContourRank = 0); private sealed class Frame(Node node) { internal Node Node { get; } = node; diff --git a/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs b/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs new file mode 100644 index 0000000..617a625 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/HoleLookAheadTests.cs @@ -0,0 +1,214 @@ +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 HoleLookAheadTests +{ + private static readonly Vector Start = new(-2, 4); + + internal static Program MixedHoles() + { + var clean = LeadPathValidationTests.Rectangle(0, 0, 12, 10); + clean.MoveTo(3, 3); + clean.LineTo(5, 3); clean.LineTo(5, 5); clean.LineTo(3, 5); clean.LineTo(3, 3); + clean.MoveTo(9, 4); + clean.ArcTo(9, 4, 8, 4, RotationType.CCW); + return clean; + } + + internal static CuttingParameters Parameters() + { + var p = ExplicitContourTests.Parameters(); + p.InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + p.ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }; + p.PierceClearance = 0; + return p; + } + + [Fact] + public void MixedHolesFaceTheNextContourAndRectanglesUseFeasibleCorners() + { + var parameters = Parameters(); + var part = new Part(new Drawing("mixed holes", MixedHoles())); + var next = new Part(new Drawing("next", LeadPathValidationTests.Rectangle(0, 0, 2, 2)), new Vector(20, 2)); + var fingerprint = ExplicitContourTests.Fingerprint(part.Program); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([part, next], Start, + confirmedParameters: parameters, preservePartOrder: true)); + var result = CuttingPlanService.Plan(snapshot); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + var proposal = result.ProposedOrder[0]; + Assert.Equal(new[] { 0, 1, 2 }, proposal.ContourChoices.Select(c => c.ContourOrdinal)); + var rectangle = proposal.ContourChoices[0].Point; + Assert.True(rectangle.X > 4.9 && (rectangle.Y < 3.1 || rectangle.Y > 4.9), + $"Expected a right-facing rectangle corner, got {rectangle}"); + Assert.True(proposal.ContourChoices[1].Point.X > 8, "Circle must finish toward the outside start."); + Assert.True(proposal.ContourChoices[^1].Point.X > 11.9, "Outside start must face the next part."); + var runs = CutRuns(proposal.Execution); + Assert.Equal(3, runs.Count); + for (var i = 0; i < runs.Count - 1; i++) + Assert.True(runs[i].End.X < runs[i + 1].Pierce.X, "Actual emitted cuts must flow rightward."); + Assert.Empty(new ReleasedContourState().Check(proposal.Execution, Start, 1)); + Assert.Equal(fingerprint, ExplicitContourTests.Fingerprint(part.Program)); + } + + [Fact] + public void CrossingPreferredHolePathBacktracksToACheckedAlternative() + { + // Calibrated: the preferred rapid from hole 1 to hole 2 touches already-cut hole 3. + var centres = new[] { new Vector(3, 3), new Vector(7, 15), new Vector(3, 7), new Vector(3, 11) }; + var clean = LeadPathValidationTests.Rectangle(0, 0, 18, 18); + foreach (var c in centres) + { + clean.MoveTo(c.X + 1.75, c.Y); + clean.ArcTo(c.X + 1.75, c.Y, c.X, c.Y, RotationType.CCW); + } + var part = new Part(new Drawing("crossing preference", clean)); + var unchanged = ExplicitContourTests.Fingerprint(part.Program); + var start = new Vector(-2, 9); + 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(4) + .RankTowardNextCut(null, start), c => adapter.Check(c.Choice)).Choices[0]; + var route = CuttingHoleOrder.Plan(new[] { 0, 1, 2, 3 }, centres, start, + PreferredContourEntries.Pierce(prepared, outside, default)); + var preferred = PreferredContourEntries.TryPlan(prepared, outside, route, centres, start, + c => adapter.Check(c.Choice)); + Assert.True(preferred.IsPreferred, preferred.Reason); + var unsafeChoices = preferred.HoleChoices.Append(outside).ToArray(); + var unsafeProgram = prepared.Emit(unsafeChoices); + var unsafeExecution = ExecutionMotionReader.Read(unsafeProgram, Vector.Zero, start, default); + Assert.Contains(new ReleasedContourState().Check(unsafeExecution, start, 1), + f => f.Kind == PostVerificationKind.RapidCrossing); + var rejected = CuttingPlanService.ReplayPrograms(snapshot, + [source.Propose(unsafeProgram, unsafeExecution, unsafeChoices)], 0, default); + Assert.NotEqual(CuttingPlanStatus.Ready, rejected.Status); + Assert.False(rejected.IndependentlyReplayed); + + var result = CuttingPlanService.Plan(snapshot); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + var actual = result.ProposedOrder[0]; + Assert.Equal(outside.Point, actual.ContourChoices[^1].Point); + Assert.Equal(route, actual.ContourChoices.Take(4).Select(c => c.ContourOrdinal)); + Assert.NotEqual(unsafeChoices.Single(c => c.ContourOrdinal == 2).Point, + actual.ContourChoices.Single(c => c.ContourOrdinal == 2).Point); + Assert.Empty(new ReleasedContourState().Check(actual.Execution, start, 1)); + Assert.Equal(unchanged, ExplicitContourTests.Fingerprint(part.Program)); + } + + [Fact] + public void LaterLockedPartForcesNewPerimeterAndRecomputedHolePreference() + { + var parameters = Parameters(); + var first = new Part(new Drawing("holes", MixedHoles())); + var nextClean = LeadPathValidationTests.Rectangle(0, 0, 2, 2); + // The fixed next program starts above-left, then moves above the first part before + // cutting on the right. Its material centre alone cannot predict that approach. + nextClean.MoveTo(-22, 10); + nextClean.Codes.Add(new LinearMove(-21, 10) { Layer = LayerType.Scribe }); + nextClean.MoveTo(0, 10); + nextClean.Codes.Add(new LinearMove(1, 10) { Layer = LayerType.Scribe }); + var next = new Part(new Drawing("locked marked part", nextClean), new Vector(20, 2)); + var nextPrepared = PreparedContours.Capture(nextClean, parameters); + Assert.True(next.RestoreLeadInProgram(nextPrepared.Emit( + [nextPrepared.ClosestEntry(0, new Vector(3, 1))]), true)); + var lockedFingerprint = ExplicitContourTests.Fingerprint(next.Program); + var snapshot = CuttingPlanService.Capture(new CuttingPlanRequest([first, next], Start, + confirmedParameters: parameters, preservePartOrder: true)); + var source = snapshot.Placements[0]; + var prepared = source.Prepared; + var adapter = new ContourEntryFeasibility(prepared, source.Location, source.Material, + snapshot.Placements.Select(p => p.Material).ToArray()); + var preferredOutside = ContourEntrySelection.Select(prepared.AutomaticEntryCandidatesWithFallbacks(2, new Vector(21, 3)) + .RankTowardNextCut(new Vector(21, 3), Start), c => adapter.Check(c.Choice)).Choices[0]; + var centres = prepared.HoleCentres().Select(c => c ?? Vector.Zero).ToArray(); + var route = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Start, + PreferredContourEntries.Pierce(prepared, preferredOutside, default)); + var preferred = PreferredContourEntries.TryPlan(prepared, preferredOutside, route, centres, Start, + c => adapter.Check(c.Choice)); + Assert.True(preferred.IsPreferred, preferred.Reason); + var firstExecution = ExecutionMotionReader.Read(prepared.Emit( + preferred.HoleChoices.Append(preferredOutside).ToArray()), Vector.Zero, Start, default); + var state = new ReleasedContourState(); + Assert.Empty(state.Check(firstExecution, Start, 1)); + Assert.Contains(state.Check(snapshot.Placements[1].Execution, firstExecution.DeparturePoint, 2), + f => f.Kind == PostVerificationKind.RapidCrossing); + + var result = CuttingPlanService.Plan(snapshot); + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + Assert.NotEqual(preferredOutside.Point, result.ProposedOrder[0].ContourChoices[^1].Point); + Assert.NotEqual(preferred.HoleChoices.Select(c => c.Point).ToArray(), + result.ProposedOrder[0].ContourChoices.Take(2).Select(c => c.Point).ToArray()); + Assert.Equal(lockedFingerprint, ExplicitContourTests.Fingerprint(result.ProposedOrder[1].CopyProgram())); + Assert.Equal(lockedFingerprint, ExplicitContourTests.Fingerprint(next.Program)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void HoleProgramsRetainScribesOnceAndNeverInstallOnFailure(bool tabs) + { + var clean = MixedHoles(); + clean.MoveTo(-2, -2); + clean.Codes.Add(new LinearMove(-1, -1) { Layer = LayerType.Scribe }); + var parameters = Parameters(); + parameters.TabsEnabled = tabs; + parameters.TabConfig = new NormalTab { Size = 0.2 }; + parameters.ExternalLeadOut = new ArcLeadOut { Radius = 0.2 }; + var part = new Part(new Drawing("marked", clean)); + var fingerprint = ExplicitContourTests.Fingerprint(part.Program); + var result = CuttingPlanService.Plan(new CuttingPlanRequest([part], Start, + confirmedParameters: parameters, preservePartOrder: true)); + if (tabs) + { + Assert.NotEqual(CuttingPlanStatus.Ready, result.Status); + Assert.Empty(result.ProposedOrder); + Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.Incomplete); + } + else + { + Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result)); + Assert.True(result.IndependentlyReplayed); + var actual = result.ProposedOrder[0]; + Assert.Equal(2, actual.ContourChoices[^1].ContourOrdinal); + Assert.Equal(3, actual.ContourChoices.Select(c => c.ContourOrdinal).Distinct().Count()); + Assert.Single(actual.Execution.Motions.Where(m => !m.Rapid && m.Layer == LayerType.Scribe)); + } + Assert.Equal(fingerprint, ExplicitContourTests.Fingerprint(part.Program)); + } + + internal static List<(Vector Pierce, Vector End)> CutRuns(OwnedExecution execution) + { + var runs = new List<(Vector Pierce, Vector End)>(); + Vector? pierce = null; + var end = Vector.Zero; + foreach (var motion in execution.Motions) + { + if (motion.Rapid) + { + if (pierce is { } p) runs.Add((p, end)); + pierce = null; + } + else if (motion.Layer != LayerType.Scribe) + { + pierce ??= motion.Start ?? motion.End; + end = motion.End; + } + } + if (pierce is { } last) runs.Add((last, end)); + return runs; + } + + private static string Describe(CuttingPlanResult result) => + $"{result.Status}: " + string.Join("; ", result.Findings.Select(f => f.Message)); +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index ffb4e70..c5b1ee7 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -121,8 +121,9 @@ tangent arc at a shared vertex, such as a fillet, is an ordinary joint: an exact contact that the native query rounds away is not uncertain at a line endpoint the other curve already touches, while a contact anywhere else on the line still refuses. -Candidates rank by actual modeled rapid distance with stable source/contour/entry -ordinals. Hash values and drawing names are not tie breakers. The expansion budget +Source parts rank by modeled travel (material-centre distance at a holed-part +boundary); within a part, preferred contour order and facing-entry rank precede +travel. Ties use stable source/contour/entry ordinals. Hash values and drawing names are not tie breakers. The expansion budget counts rejected candidates and frontier ranking as well as accepted moves, before emission; it is not a wall-clock timeout. Callers can cancel. Exhaustion may occur before already-generated siblings are traversed; it returns a refusal, not an @@ -130,8 +131,7 @@ unranked fallback or a proof of geometric impossibility. ## Automatic outside entries and look-ahead -For a part whose only contour is its outside (no holes), the entry is chosen -automatically toward the NEXT cut: the ranker orders the native candidate +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 @@ -141,13 +141,31 @@ 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 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 never precheck-refused: -those candidates reach the emitted-prefix check and complete replay unchanged. A +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 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 -impossibility. Lead prechecks are tracked separately from expansions. Hole -parts keep the legacy nearest-entry path. +impossibility. Lead prechecks are reported separately and their requests also consume +the shared expansion budget before native work. + +For a holed part, the search chooses an outside endpoint before cutting any hole. +Each endpoint branch builds an open hole-centre route from the original arrival to +that endpoint, using bounded nearest-neighbour, 2-opt and Or-opt improvement. +Preferred hole entries are then resolved backward from the outside's actual emitted +pierce, ignoring scribe marks: each hole faces the following contour's actual pierce. +Convex corners lead the preference tiers, then straight midpoints/tangent joints, +then native fallbacks. Reflex/cusp corners remain manual-only. + +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 +arrival and a copy of the checker from before that part, not from the previous +prefix (which would double-consume holes and scribes). Holes are cut once, the +outside last, and scribes once. Locked programs remain exact. A crossed preferred +route must recover through checked backtracking or return a refusal, never unsafe +`Ready`. Selected programs are replayed from the beginning with a fresh checker and fresh lead validation, without regenerating them or trusting cached search verdicts.