fix(cutting): prefer hole entries facing the next contour

This commit is contained in:
aj committed 2026-10-07 14:58:23 -04:00
1 parent 76f73f7a87
commit 414e9b4e75
4 files changed
+372 -34

No files matched your search

@@ -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];
/// <summary>
/// Representative POINTS per contour ordinal (bounding-box centre) for hole routing
/// only — the S10 <c>CuttingHoleOrder</c> proxy. The perimeter has no entry; it is the
/// route's fixed endpoint, not a stop. These are ordering proxies, never cut points.
/// </summary>
internal IReadOnlyList<Vector?> 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();
@@ -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<ContourChoice> 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<ContourChoice> 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<int, ContourChoice>();
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<int, ContourChoice> Entries,
IReadOnlyDictionary<int, Vector> 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);
/// <summary>A search state; Previous links a whole-part boundary to the boundary before it.</summary>
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
ActivePart Active, Node Previous);
/// <summary>Rank is the automatic selection slot (entry order) inside its contour stage; int.MinValue for legacy children.</summary>
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;
@@ -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));
}
+26 -8
View File
@@ -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.