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.