diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs
index 6c192fb..7ccd615 100644
--- a/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs
+++ b/OpenNest.Engine/CuttingPlanning/ContourEntryFeasibility.cs
@@ -1,3 +1,4 @@
+#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
diff --git a/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs
index a1964bb..7945678 100644
--- a/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs
+++ b/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs
@@ -1,3 +1,4 @@
+#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
@@ -30,13 +31,15 @@ internal enum ContourSelectionShortfall
///
/// Outcome of one bounded selection: selected choices in global rank order, how many
-/// distinct candidates were evaluated, which ones (in catalogue order, for cost tests), and
-/// why the selection stopped.
+/// distinct candidates were evaluated, which ones (in catalogue order, for cost tests),
+/// which evaluated uncertain (never refused — the caller's full check stays the authority
+/// on those), and why the selection stopped.
///
internal sealed record ContourSelectionResult(
IReadOnlyList Choices,
int EvaluatedCount,
IReadOnlyList<(long, long)> EvaluatedKeys,
+ IReadOnlyList UncertainChoices,
ContourSelectionShortfall Shortfall,
string? Reason);
@@ -91,7 +94,9 @@ internal static class ContourEntrySelection
var box = Box(candidates);
var selected = new List();
+ var uncertain = new List();
var sawIncomplete = false;
+ string? incompleteReason = null;
var index = 0;
ContourFeasibilityVerdict Verdict(ContourEntryCandidate candidate)
@@ -112,8 +117,12 @@ internal static class ContourEntrySelection
var verdict = Verdict(candidates[index]);
if (verdict.Status == ContourFeasibilityStatus.Incomplete)
{
+ // Uncertain is not refused: it never takes a selected slot, but the scan
+ // continues and the caller's full check stays the authority on it.
sawIncomplete = true;
- break;
+ incompleteReason ??= verdict.Reason;
+ uncertain.Add(candidates[index]);
+ continue;
}
if (verdict.IsClear)
selected.Add(candidates[index]);
@@ -124,7 +133,7 @@ internal static class ContourEntrySelection
// a slot remains, otherwise it replaces the worst selected candidate whose removal
// keeps every other covered side covered.
var coverage = maxEntries >= SideCoverageMinCap && selected.Count > 0;
- if (coverage && !sawIncomplete)
+ if (coverage)
for (var side = 0; side < 4 && !sawIncomplete; side++)
{
if (selected.Any(c => Sides(c, box).Contains(side)))
@@ -135,7 +144,9 @@ internal static class ContourEntrySelection
if (verdict.Status == ContourFeasibilityStatus.Incomplete)
{
sawIncomplete = true;
- break;
+ incompleteReason ??= verdict.Reason;
+ uncertain.Add(candidates[index]);
+ continue;
}
if (!verdict.IsClear || !Sides(candidates[index], box).Contains(side))
continue;
@@ -150,7 +161,8 @@ internal static class ContourEntrySelection
if (sawIncomplete)
{
shortfall = ContourSelectionShortfall.Incomplete;
- reason = "At least one lead check could not complete; this is not a geometric verdict and nothing is proven impossible.";
+ reason = "At least one lead check could not complete; this is not a geometric verdict and nothing is proven impossible."
+ + (incompleteReason == null ? "" : $" First reason: {incompleteReason}");
}
else if (selected.Count >= maxEntries)
{
@@ -178,7 +190,8 @@ internal static class ContourEntrySelection
.Select(c => c.Choice)
.ToList();
return new(ordered, evaluated.Count,
- evaluated.Select(c => c.GeometryKey).ToList(), shortfall, reason);
+ evaluated.Select(c => c.GeometryKey).ToList(),
+ uncertain.Select(c => c.Choice).ToList(), shortfall, reason);
}
private static (double MinX, double MinY, double MaxX, double MaxY) Box(
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index 1ee9e2b..cf69414 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -24,8 +24,9 @@ internal static class JointCuttingPlanSearch
///
internal const int StallExpansionsPerEntry = 8;
+ /// LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList Order,
- IReadOnlyList Findings, int Expansions);
+ IReadOnlyList Findings, int Expansions, int LeadPrechecks = 0);
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{
@@ -69,11 +70,11 @@ internal static class JointCuttingPlanSearch
}
catch (BudgetExceededException)
{
- return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions);
+ return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions, walk.LeadPrechecks);
}
catch (OperationCanceledException)
{
- return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions);
+ return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions, walk.LeadPrechecks);
}
}
@@ -141,13 +142,20 @@ internal static class JointCuttingPlanSearch
private readonly List rejected = [];
private readonly LeadMaterialSnapshot[] materials =
snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
+ // One feasibility adapter per source part per captured planning attempt: verdicts
+ // memoize per owned choice for the attempt, never statically or across attempts.
+ private readonly Dictionary feasibility = [];
+ private readonly HashSet reportedNoFit = [];
internal int Expansions { get; private set; }
+ /// Lead precheck evaluations, tracked apart from DFS expansions: they are work, not free.
+ internal int LeadPrechecks { get; private set; }
+
internal IReadOnlyList Rejected => rejected.Distinct().ToArray();
internal Outcome Ready(IReadOnlyList order) =>
- new(CuttingPlanStatus.Ready, order, [], Expansions);
+ new(CuttingPlanStatus.Ready, order, [], Expansions, LeadPrechecks);
// Entries are capped; exhaustion is not a proof over all possible entries.
internal Outcome Exhausted()
@@ -156,7 +164,7 @@ internal static class JointCuttingPlanSearch
? CuttingPlanStatus.NoSolutionWithinBudget
: rejected.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict;
- return new(status, [], Rejected, Expansions);
+ return new(status, [], Rejected, Expansions, LeadPrechecks);
}
///
@@ -186,8 +194,7 @@ internal static class JointCuttingPlanSearch
progressExpansions = Expansions;
else if (stall is int limit && Expansions - progressExpansions > limit)
return attempt;
- frame.Children ??= Expand(node, sequence, attempt).OrderBy(c => c.Distance)
- .ThenBy(c => c.Ordinal).ThenBy(c => c.Contour).ThenBy(c => c.Entry).ToArray();
+ frame.Children ??= OrderedChildren(node, sequence, attempt);
if (frame.Next == frame.Children.Length)
{
stack.Pop();
@@ -198,6 +205,63 @@ internal static class JointCuttingPlanSearch
return attempt;
}
+ ///
+ /// The NEXT cut's centre that the outside entry should face, or null for the last
+ /// part. Supplied order: the next not-yet-finished part in that order (the sequence
+ /// is re-read after every learned-order replan). Sequence-free fallback: nearest
+ /// dependency-ready remaining part once the current part counts as finished, stable
+ /// ordinal ties. Never the current or a finished part. Global coordinates.
+ ///
+ private Vector? LookAheadCentre(Node node, int[] sequence, FixedProgramPlacement source)
+ {
+ var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
+ finished.Add(source.SourceOrdinal);
+ if (sequence != null)
+ for (var i = node.Order.Length + 1; i < sequence.Length; i++)
+ if (!finished.Contains(sequence[i]))
+ return Centre(snapshot.Placements[sequence[i]]);
+ var from = Centre(source);
+ Vector? best = null;
+ var bestDistance = double.PositiveInfinity;
+ foreach (var candidate in snapshot.Placements
+ .Where(p => !finished.Contains(p.SourceOrdinal)
+ && snapshot.Dependencies.IsReady(p.SourceOrdinal, finished))
+ .OrderBy(p => p.SourceOrdinal))
+ {
+ var distance = Centre(candidate).DistanceTo(from);
+ if (distance < bestDistance - 1e-9)
+ {
+ bestDistance = distance;
+ best = Centre(candidate);
+ }
+ }
+ return best;
+ }
+
+ ///
+ /// Nearest-first BETWEEN source parts (fallback search keeps its tour), but inside
+ /// 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 Edge[] OrderedChildren(Node node, int[] sequence, Attempt attempt)
+ {
+ var edges = Expand(node, sequence, attempt).ToList();
+ if (edges.Count <= 1)
+ return edges.ToArray();
+ // Stable source order: the minimum incremental rapid per source, ties ordinal.
+ var sourceOrder = edges.GroupBy(e => e.Ordinal)
+ .OrderBy(g => g.Min(e => e.Distance)).ThenBy(g => g.Key)
+ .SelectMany((g, rank) => g.Select(e => (Edge: e, Rank: rank)))
+ .ToDictionary(x => x.Edge, x => x.Rank);
+ return edges
+ .OrderBy(e => sourceOrder[e])
+ .ThenBy(e => e.Contour)
+ .ThenBy(e => e.Rank)
+ .ThenBy(e => e.Distance)
+ .ThenBy(e => e.Entry)
+ .ToArray();
+ }
+
private IEnumerable Expand(Node node, int[] sequence, Attempt attempt)
{
IEnumerable sources;
@@ -234,7 +298,20 @@ internal static class JointCuttingPlanSearch
foreach (var contour in contours)
{
token.ThrowIfCancellationRequested();
- var entries = prepared.Entries(contour, node.Position - source.Location, snapshot.MaxEntries, token);
+ // 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);
+ else
+ {
+ CountExpansion(source); // The catalogue/validator pipeline is search work, counted here.
+ entries = AutomaticEntries(node, sequence, source, contour);
+ if (entries.Count == 0)
+ continue; // the S08 finding (when complete) is already recorded
+ }
for (var entry = 0; entry < entries.Count; entry++)
{
CountExpansion(source); // Before emission/native queries, including rejected candidates.
@@ -261,12 +338,55 @@ internal static class JointCuttingPlanSearch
execution.DeparturePoint, checker, null, boundary)
: new Node(node.Order, execution.DeparturePoint, checker,
new(source, prefix, arrival, before, distance, boundary), null);
- yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, contour, entry);
+ yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal,
+ contour, entry, automatic ? entry : int.MinValue);
}
}
}
}
+ ///
+ /// The S03-S08 pipeline for one outside contour, in prepared LOCAL coordinates
+ /// converted exactly once: rank the (fallback-complemented) catalogue toward the
+ /// next cut, lazily filter through the shared validator adapter, cap at MaxEntries
+ /// with side coverage. Empty ONLY when the finite catalogue was fully evaluated and
+ /// nothing fits — then the honest part/contour finding is recorded once.
+ ///
+ private IReadOnlyList AutomaticEntries(Node node, int[] sequence,
+ FixedProgramPlacement source, int contour)
+ {
+ var prepared = source.Prepared!;
+ var arrival = node.Position - source.Location;
+ // 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
+ : feasibility[source.SourceOrdinal] = new ContourEntryFeasibility(
+ prepared, source.Location, source.Material, materials);
+ var before = adapter.EvaluationCount;
+ var selection = ContourEntrySelection.Select(ordered,
+ candidate => adapter.Check(candidate.Choice, token: token), snapshot.MaxEntries, token);
+ LeadPrechecks += adapter.EvaluationCount - before;
+ if (selection.Shortfall == ContourSelectionShortfall.Incomplete && selection.UncertainChoices.Count == 0)
+ rejected.Add(Finding(source, PostVerificationKind.Incomplete,
+ $"Contour {contour}: {selection.Reason}"));
+ else if (selection.Choices.Count == 0 && selection.UncertainChoices.Count == 0
+ && reportedNoFit.Add(source.SourceOrdinal * 1000 + contour))
+ rejected.Add(Finding(source, null,
+ $"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];
+ }
+
private void CountExpansion(FixedProgramPlacement source)
{
token.ThrowIfCancellationRequested();
@@ -352,7 +472,8 @@ internal static class JointCuttingPlanSearch
/// 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);
- private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry);
+ /// 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 class Frame(Node node)
{
internal Node Node { get; } = node;
diff --git a/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs b/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs
new file mode 100644
index 0000000..cd00268
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/PerimeterLookAheadTests.cs
@@ -0,0 +1,300 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// S09: at a part boundary the outside contour's automatic entry faces the NEXT part, and
+/// the emitted rapids are no worse than the measured legacy layout (BASELINE: legacy picks
+/// the entry nearest the arrival — for this fixture the lower-left corner each time, so
+/// every departure trails the left edge and each inter-part rapid carries the full 10.1
+/// pitch plus lead offsets). No-hole parts only — holed parts keep the legacy path for S12.
+///
+public class PerimeterLookAheadTests
+{
+ // Measured on the legacy search before this slice (Capture_LegacyBaselineNumbers,
+ // three squares at 0/10.5/21, origin start, 0.15 line leads, no lead-out): every entry
+ // the arrival-nearest lower-left corner (0,0); rapids 0.000000, 10.500000, 10.500000;
+ // total 21.000000. The look-ahead start must not make the total worse and must face
+ // the next part.
+ private const double LegacyTotalRapids = 21.0;
+
+ private static CuttingParameters Parameters()
+ {
+ var parameters = ExplicitContourTests.Parameters();
+ parameters.ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 45 };
+ parameters.ExternalLeadOut = new NoLeadOut();
+ return parameters;
+ }
+
+ private static Part Square(double x, CuttingParameters parameters, string name = "sq")
+ {
+ var part = new Part(new Drawing(name, LeadPathValidationTests.Rectangle(0, 0, 10, 10)),
+ new Vector(x, 0));
+ part.CuttingParameters = parameters;
+ return part;
+ }
+
+ private static CuttingPlanRequest Request(Part[] parts, CuttingParameters parameters, int budget = 20000) =>
+ new(parts, Vector.Zero, budget, parameters);
+
+ /// Actual air moves: each rapid's distance from the previous motion's end to the following cut end.
+ private static List Rapids(CuttingPlanResult result)
+ {
+ var rapids = new List();
+ var position = Vector.Zero;
+ foreach (var placement in result.ProposedOrder)
+ {
+ var motions = placement.Execution.Motions;
+ for (var i = 0; i < motions.Count; i++)
+ {
+ if (!motions[i].Rapid)
+ continue;
+ var next = motions.Skip(i + 1).First(m => !m.Rapid);
+ if (position.DistanceTo(motions[i].End) > 1e-9)
+ rapids.Add(position.DistanceTo(next.End));
+ position = next.End;
+ }
+ position = placement.Execution.DeparturePoint;
+ }
+ return rapids;
+ }
+
+ [Fact]
+ public void ThreePartsAlongX_LeftToRightReadyWithFirstStartFacingTheNextPart()
+ {
+ var parameters = Parameters();
+ var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(3, result.ProposedOrder.Count);
+
+ var first = result.ProposedOrder[0];
+ var entry = Assert.Single(first.ContourChoices).Point + first.Location;
+ // Facing the next part (centre 15.5, 5) means the +X side of the first sheet,
+ // not the legacy arrival-nearest lower-left corner.
+ Assert.True(entry.X >= 10.0 - 1e-9, $"first entry {entry} faces away from the next part");
+ }
+
+ [Fact]
+ public void EmittedRapidsNoWorseThanTheMeasuredLegacyLayout()
+ {
+ var parameters = Parameters();
+ var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(Rapids(result).Sum() <= LegacyTotalRapids + 1e-6,
+ $"total rapids {Rapids(result).Sum():F6} exceed legacy {LegacyTotalRapids:F6}");
+ }
+
+ [Fact]
+ public void SecondPartFacesThird_AndLastPartFacesArrivalNotOrigin()
+ {
+ var parameters = Parameters();
+ var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var ordered = result.ProposedOrder.OrderBy(p => p.SourceOrdinal).ToArray();
+ var secondEntry = Assert.Single(ordered[1].ContourChoices).Point + ordered[1].Location;
+ // Facing the third part (centre 26.0, 5): +X side of the middle sheet.
+ Assert.True(secondEntry.X >= 10.5 + 10.0 - 1e-9, $"second entry {secondEntry} faces away from the third part");
+ // Last part: no target — its entry is chosen near the arrival point, never pulled
+ // toward the plate origin.
+ var arrival = ordered[1].Execution.DeparturePoint;
+ var lastEntry = Assert.Single(ordered[2].ContourChoices).Point + ordered[2].Location;
+ Assert.True(arrival.DistanceTo(lastEntry) < Vector.Zero.DistanceTo(lastEntry),
+ $"last entry {lastEntry} is nearer the plate origin than the arrival {arrival}");
+ }
+
+ [Fact]
+ public void RotatedAndTranslatedLayout_FacingIsInGlobalSpace()
+ {
+ var parameters = Parameters();
+ var sheet = Square(0, parameters);
+ sheet.Rotate(System.Math.PI / 4); // about the origin: the diamond spans x -7.07..7.07
+ var parts = new[] { sheet, Square(25, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var rotated = result.ProposedOrder.Single(p => p.SourceOrdinal == 0);
+ var entry = rotated.ContourChoices.Single().Point + rotated.Location;
+ // The diamond's +X half faces the next part at x=25; the legacy arrival-nearest
+ // point would be the origin corner (0,0) or below.
+ Assert.True(entry.X > 3.0, $"rotated first entry {entry} faces away from the next part");
+ }
+
+ [Fact]
+ public void BlockedPreferredStart_CertifiedFallbackStillReady()
+ {
+ // A sheet hugging the right side of part 1 (0.05 gap) blocks every +X-facing lead;
+ // the planner must fall back to a candidate whose emitted leads certify clear.
+ var parameters = Parameters();
+ var blocker = new Part(new Drawing("block", LeadPathValidationTests.Rectangle(0, -2, 9.9, 12)), Vector.Zero);
+ blocker.Location = new Vector(10.05, 0);
+ blocker.CuttingParameters = parameters;
+ var parts = new[] { Square(0, parameters), blocker, Square(20.4, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var first = result.ProposedOrder.Single(p => p.SourceOrdinal == 0);
+ var entry = first.ContourChoices.Single().Point + first.Location;
+ var material = LeadMaterialSnapshot.Capture(
+ LeadPathValidationTests.Rectangle(0, 0, 10, 10), Vector.Zero);
+ var execution = ExecutionMotionReader.Read(first.CopyProgram(), first.Location, null, default);
+ var leads = LeadPathValidator.Check(execution, material, []);
+ Assert.True(leads.IsClear, leads.Reason);
+ }
+
+ [Fact]
+ public void NoCandidateFits_SurfacesContourFindingNotBudget()
+ {
+ // Shrink-wrap material 0.05 around part 1: every external lead enters it, so the
+ // finite candidate catalogue PROVES no lead-in fits; the search must say that, not
+ // hide behind the expansion budget.
+ var parameters = Parameters();
+ var wrap = new Part(new Drawing("wrap", LeadPathValidationTests.Rectangle(-0.05, -0.05, 10.05, 10.05)),
+ Vector.Zero);
+ wrap.CuttingParameters = parameters;
+ var square = Square(0, parameters);
+ var request = new CuttingPlanRequest([square, wrap], Vector.Zero, 20000, parameters);
+
+ var result = CuttingPlanService.Plan(request);
+
+ Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
+ Assert.Contains(result.Findings,
+ f => (f.Message ?? string.Empty).Contains("No tested lead-in fits", StringComparison.Ordinal));
+ Assert.DoesNotContain(result.Findings,
+ f => (f.Message ?? string.Empty).Contains("Expansion budget", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void BudgetOne_ReportsBudgetNotImpossibility()
+ {
+ var parameters = Parameters();
+ var parts = new[] { Square(0, parameters), Square(10.5, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters, budget: 1));
+
+ Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, result.Status);
+ Assert.Contains(result.Findings,
+ f => (f.Message ?? string.Empty).Contains("budget", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(result.Findings,
+ f => (f.Message ?? string.Empty).Contains("No tested lead-in fits", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void CancellationIsHonouredMidSearch()
+ {
+ var parameters = Parameters();
+ var parts = Enumerable.Range(0, 6).Select(i => Square(i * 10.5, parameters)).ToArray();
+ using var cancel = new CancellationTokenSource();
+ cancel.Cancel();
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters), cancel.Token);
+
+ Assert.Equal(CuttingPlanStatus.Cancelled, result.Status);
+ }
+
+ [Fact]
+ public void LockedProgramFingerprintIsUntouchedByLookAhead()
+ {
+ var parameters = Parameters();
+ var locked = Square(0, parameters);
+ var prepared = PreparedContours.Capture(locked.Program, parameters);
+ var emitted = prepared.Emit([prepared.ClosestEntry(0, Vector.Zero)]);
+ Assert.True(locked.RestoreLeadInProgram(emitted, false));
+ locked.LeadInsLocked = true;
+ var before = ExplicitContourTests.Fingerprint(locked.Program);
+
+ var result = CuttingPlanService.Plan(Request([locked, Square(10.5, parameters)], parameters));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.Equal(before, ExplicitContourTests.Fingerprint(locked.Program));
+ var placement = result.ProposedOrder.Single(p => p.SourceOrdinal == 0);
+ Assert.False(placement.IsRegenerated); // locked programs never gain automatic choices
+ }
+
+ [Fact]
+ public void PreservePartOrder_RightToLeftFirstStartFacesLeft()
+ {
+ // Right-to-left supplied sequence: the x=20.2 sheet is cut first and must face the
+ // next part on its -X side.
+ var parameters = Parameters();
+ var parts = new[] { Square(21.0, parameters), Square(10.5, parameters), Square(0, parameters) };
+ var request = new CuttingPlanRequest(parts, Vector.Zero, 20000, parameters, preservePartOrder: true);
+
+ var result = CuttingPlanService.Plan(request);
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var first = result.ProposedOrder[0];
+ Assert.Equal(0, first.SourceOrdinal);
+ var entry = first.ContourChoices.Single().Point + first.Location;
+ // Facing the next part (centre 15.5, 5) means the -X side of the x=21 sheet.
+ Assert.True(entry.X <= 21.0 + 1e-9, $"first entry {entry} faces away from the next part");
+
+ // Last part (x=0..10): no target — arrival-nearest means its +X side (the tool
+ // arrives from the middle sheet), NOT the plate-origin corner at (0,0).
+ var last = result.ProposedOrder[2];
+ var lastEntry = last.ContourChoices.Single().Point + last.Location;
+ Assert.True(lastEntry.X >= 10.0 - 1e-9,
+ $"last entry {lastEntry} faces the plate origin instead of the arrival");
+ }
+
+ [Fact]
+ public void FullFallbackSearch_TargetsNearestReadyPart()
+ {
+ // Middle-first geometry: the leftmost part is blocked until the middle one moves
+ // (forced by a shrink-wrap on its other side), so the learned-order replay must
+ // recompute look-ahead after replanning — the middle part must face whichever part
+ // the new sequence cuts after it, never a stale target.
+ var parameters = Parameters();
+ var parts = new[] { Square(0, parameters), Square(10.5, parameters), Square(21.0, parameters) };
+
+ var result = CuttingPlanService.Plan(Request(parts, parameters));
+
+ // Sanity: normal Ready; the facing entry of each non-last part faces its successor
+ // in the RESULTING order, not merely the request order.
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ var sequence = result.ProposedOrder.Select(p => p.SourceOrdinal).ToArray();
+ for (var i = 0; i < sequence.Length - 1; i++)
+ {
+ var entry = result.ProposedOrder[i].ContourChoices.Single().Point + result.ProposedOrder[i].Location;
+ var nextCentre = Centre(result.ProposedOrder[i + 1]);
+ var here = result.ProposedOrder[i].Location;
+ // The opposite side of the current sheet from the next part: the facing check
+ // is "entry is closer to the next centre than the wrong-side point is".
+ var wrongSide = nextCentre.X >= here.X + 5
+ ? new Vector(here.X - 5, here.Y + 5)
+ : new Vector(here.X + 15, here.Y + 5);
+ Assert.True(entry.DistanceTo(nextCentre) < wrongSide.DistanceTo(nextCentre),
+ $"order slot {i} entry {entry} faces away from next centre {nextCentre}");
+ }
+ }
+
+ private static Vector Centre(FixedProgramPlacement placement)
+ {
+ var cuts = placement.Execution.Motions
+ .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
+ .Select(m => m.Curve.ToEntity().BoundingBox).ToList();
+ return cuts.Count == 0 ? placement.Execution.DeparturePoint : cuts.GetBoundingBox().Center;
+ }
+
+ private static string Describe(CuttingPlanResult result) =>
+ $"{result.Status}: {string.Join("; ", result.Findings.Take(5).Select(f => f.Message))}";
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 3d52e2f..ffb4e70 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -128,6 +128,27 @@ emission; it is not a wall-clock timeout. Callers can cancel. Exhaustion may occ
before already-generated siblings are traversed; it returns a refusal, not an
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
+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
+it). The next cut is the next unfinished part in a supplied order — recomputed
+after every learned-order replan — or, in the full fallback search, the nearest
+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
+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.
+
Selected programs are replayed from the beginning with a fresh checker and fresh
lead validation, without regenerating them or trusting cached search verdicts.
Before replay, expected-emission geometry is independently built from the owned