diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs
index 7d3b1c1d..5471388f 100644
--- a/OpenNest.Console/Program.cs
+++ b/OpenNest.Console/Program.cs
@@ -824,6 +824,7 @@ static class NestConsole
{
CuttingPlanPhase.Reordering => "finding a part order...",
CuttingPlanPhase.KeepingOrder => "keeping the current order...",
+ CuttingPlanPhase.SafestRoute => "finding the safest route...",
CuttingPlanPhase.CheckingOverlap => "checking for overlaps...",
_ => value.Phase + "...",
});
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
index e3d36a83..a24e836a 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
@@ -20,6 +20,9 @@ public enum CuttingPlanPhase
/// Checking the plate's clean part material for overlaps.
CheckingOverlap,
+
+ /// No route passed every check within the search limit: finding the safest one.
+ SafestRoute,
}
/// Worker progress: zero-based position in the batch, the plate's display number and phase.
@@ -187,12 +190,55 @@ public sealed class CuttingPlanBatch
}
}
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
- plans[index] = new(entry.Plate, entry.Number,
- WithFallback(entry, CuttingPlanService.Plan(entry.KeepOrder, token), token), reorder, overlap);
+ var kept = WithFallback(entry, CuttingPlanService.Plan(entry.KeepOrder, token), token);
+ if (SearchLimited(kept, reorder))
+ {
+ progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.SafestRoute));
+ if (SafestRoute(entry, token) is var (safest, freeOrder))
+ {
+ plans[index] = new(entry.Plate, entry.Number, safest, freeOrder ? null : reorder, overlap);
+ continue;
+ }
+ }
+ plans[index] = new(entry.Plate, entry.Number, kept, reorder, overlap);
}
return new(plans, ownedParameters);
}
+ // The strict search stopped at its limit: no complete plan, and no other reason to refuse.
+ private static bool SearchLimited(CuttingPlanResult kept, CuttingPlanResult reorder) =>
+ kept.Status == CuttingPlanStatus.NoSolutionWithinBudget
+ || reorder?.Status == CuttingPlanStatus.NoSolutionWithinBudget && kept.Status == CuttingPlanStatus.ConstraintConflict;
+
+ ///
+ /// Safest first, then fastest: the replayed safest route from the free order and from the
+ /// current order, whichever breaks fewer checks (most severe first), then has less rapid
+ /// travel. A route whose planner-chosen motions pass every check is taken at once, even
+ /// when fixed lead-ins still warn. Null when neither walk finds a route.
+ ///
+ private static (CuttingPlanResult Result, bool FreeOrder)? SafestRoute(Entry entry, CancellationToken token)
+ {
+ (CuttingPlanResult Result, bool FreeOrder)? best = null;
+ try
+ {
+ foreach (var (snapshot, freeOrder) in new[] { (entry.Reorder, true), (entry.KeepOrder, false) })
+ {
+ if (CuttingPlanService.PlanSafestRoute(snapshot, token) is not { } candidate)
+ continue;
+ if (!candidate.IsSafestRouteFallback)
+ return (candidate, freeOrder);
+ if (best is not { } known || candidate.SafestRoute < known.Result.SafestRoute
+ || candidate.SafestRoute == known.Result.SafestRoute && candidate.RapidDistance < known.Result.RapidDistance)
+ best = (candidate, freeOrder);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ return (new(CuttingPlanStatus.Cancelled), true);
+ }
+ return best;
+ }
+
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
int firstPassBudget = FirstPassBudget)
{
@@ -427,7 +473,9 @@ public sealed class CuttingPlanProposal
else if (RequiresWarningAcceptance)
lines.Add((Plates.All(p => p.IsReady || p.HasOnlyFixedLeadWarnings)
? "Unverified plan available: fixed lead-ins could not be certified. "
- : "Best-effort plan available. ")
+ : Plates.Any(p => p.Result.IsSafestRouteFallback)
+ ? "Safest-route plan available: no route passed every check within the search limit. "
+ : "Best-effort plan available. ")
+ "Review the warnings and accept the unverified plan to apply. "
+ "Posting CNC output still requires its own review and approval.");
else
@@ -471,6 +519,11 @@ public sealed class CuttingPlanProposal
yield return heading + (plate.HasOnlyFixedLeadWarnings
? $"unverified: fixed lead-ins need review. {Count(plate.PartCount, "part")}, "
+ $"{plate.RegeneratedCount} regenerated; every planned motion passed its checks."
+ : result.SafestRoute is { } severity
+ ? $"safest route found, unverified. No route passed every check within the search limit; "
+ + $"this one breaks {Count(severity.Count, "check")}, listed below. {Count(plate.PartCount, "part")}, "
+ + $"{plate.RegeneratedCount} regenerated; rapid travel "
+ + $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}."
: $"best-effort, unverified. {Count(plate.PartCount, "part")}, "
+ $"{plate.RegeneratedCount} regenerated; review lead-ins and cutting order.");
// Unverified Apply requires the operator to see every finding, not just a
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
index 90ea9f02..af0861d4 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanModels.cs
@@ -340,6 +340,16 @@ public sealed class CuttingPlanResult
public int Expansions { get; }
public double RapidDistance { get; }
public bool IndependentlyReplayed { get; }
+
+ ///
+ /// The violations of a safest-route fallback: no route passed every check within the search
+ /// limit, so this BestEffort is the replayed route that breaks the fewest. Null otherwise.
+ ///
+ internal RouteSeverity? SafestRoute { get; init; }
+
+ /// Whether this is a safest-route fallback rather than a route that passed every check.
+ public bool IsSafestRouteFallback => SafestRoute != null;
+
/// The captured input this result was planned from; binds Apply to its freshness record.
internal CuttingPlanSnapshot Snapshot { get; set; }
}
diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
index cfaba8e6..a3570b86 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanService.cs
@@ -319,10 +319,40 @@ public static class CuttingPlanService
expansions: expansions, rapidDistance: distance, independentlyReplayed: true);
}
+ ///
+ /// The safest route the greedy walk finds, independently replayed; null when there is none.
+ /// A route that passes every check is Ready. One that does not is an UNVERIFIED BestEffort
+ /// carrying every violation, applicable only with explicit acceptance. A route the replay
+ /// cannot check completely, or with any motion into material (a lead that does not fit,
+ /// a cut-order or nested-lead conflict), is never offered: the strict refusal stands.
+ ///
+ internal static CuttingPlanResult PlanSafestRoute(CuttingPlanSnapshot snapshot, CancellationToken token)
+ {
+ if (snapshot == null || snapshot.Failure != null || !snapshot.Regeneration || snapshot.BestEffort
+ || snapshot.Placements.Count == 0)
+ return null;
+ // The walk weighs every part it cuts rather than stopping at the first refusal: it gets
+ // twice the strict search's budget. Replay still checks it against the captured input.
+ var budget = (int)System.Math.Min(int.MaxValue, 2L * snapshot.ExpansionBudget);
+ var order = JointCuttingPlanSearch.SafestRoute(snapshot.WithSearchLimits(budget, snapshot.MaxEntries), token);
+ if (order == null)
+ return null;
+ var result = ReplayPrograms(snapshot, order, 0, token, safestRoute: true);
+ if (result.Status is not (CuttingPlanStatus.Ready or CuttingPlanStatus.BestEffort)
+ || result.SafestRoute is { Severe: > 0 })
+ return null;
+ result.Snapshot = snapshot;
+ return result;
+ }
+
// Re-read EXACT selected programs with a fresh checker and native lead validation.
// No emission/regeneration or cached branch verdict is used here.
+ ///
+ /// Return a route whose planner-chosen motions break checks as an UNVERIFIED BestEffort with
+ /// those findings rather than refusing it; incomplete checks are still refused.
+ ///
internal static CuttingPlanResult ReplayPrograms(CuttingPlanSnapshot snapshot,
- IReadOnlyList order, int expansions, CancellationToken token)
+ IReadOnlyList order, int expansions, CancellationToken token, bool safestRoute = false)
{
if (order == null || order.Count != snapshot.Placements.Count
|| order.Any(p => p == null || p.SourceOrdinal < 0 || p.SourceOrdinal >= snapshot.Placements.Count)
@@ -392,7 +422,7 @@ public static class CuttingPlanService
// selection to compare with; a regenerated program's never escapes the strict rule.
if (!complete && source.Prepared == null && isFixed != null)
warnings.Add(FixedLeadWarnings.Uncertified(source));
- else if (!complete && findings.Count == 0)
+ else if (!complete && (findings.Count == 0 || safestRoute))
return new(CuttingPlanStatus.InvalidInput,
findings: [JointCuttingPlanSearch.Finding(source, null,
"Selected program does not preserve complete directed contours, certified tabs or selected entry/style geometry.")],
@@ -404,12 +434,18 @@ public static class CuttingPlanService
position = execution.DeparturePoint;
}
token.ThrowIfCancellationRequested();
+ // Support tabs that cannot hold their part level are noted, never refused.
+ var notes = order.SelectMany(SupportTabCapture.Notes).ToList();
+ if (safestRoute && findings.Count != 0 && findings.All(f => f.Kind != PostVerificationKind.Incomplete))
+ return new(CuttingPlanStatus.BestEffort, order, findings.Concat(warnings).Concat(notes).Distinct(),
+ expansions, distance)
+ {
+ SafestRoute = RouteSeverity.Of(findings.Distinct().Select(f => f.Kind)),
+ };
if (findings.Count != 0)
return new(findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict,
findings: findings, expansions: expansions);
- // Support tabs that cannot hold their part level are noted, never refused.
- var notes = order.SelectMany(SupportTabCapture.Notes).ToList();
// Every planner-chosen motion passed. Fixed-emission warnings make the proposal
// UNVERIFIED: never Ready, applicable only through explicit warning acceptance.
if (warnings.Count != 0)
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index b0a439ee..a1106021 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -116,6 +116,26 @@ internal static class JointCuttingPlanSearch
}
}
+ ///
+ /// The safest complete route a greedy walk finds, for when the strict search ran out of budget.
+ /// From the start point it cuts, each step, the dependency-ready part whose own route breaks the
+ /// fewest checks (most severe first), then the nearest. It ranks violating motions rather than
+ /// refusing them, so the route needs the full replay before anyone sees it. It has one search
+ /// budget of its own; null when that runs out or a part cannot be emitted at all.
+ ///
+ internal static IReadOnlyList SafestRoute(CuttingPlanSnapshot snapshot, CancellationToken token)
+ {
+ var walk = new Walk(snapshot, token);
+ try
+ {
+ return walk.SafestRoute();
+ }
+ catch (BudgetExceededException)
+ {
+ return null;
+ }
+ }
+
// Captured containment (never learned rules) and each host's placed cut contours.
private static CuttingNestedOrder.Context NestedOrder(CuttingPlanSnapshot snapshot, Vector[] centres,
HashSet[] prerequisites, CancellationToken token)
@@ -565,8 +585,10 @@ internal static class JointCuttingPlanSearch
// Recovery only: solve directed contour transitions rather than assuming
// that a centre-to-centre hole tour can cross the already released material.
// One complete checked chain per outside endpoint; original DFS remains fallback.
+ // arrivalMayCross (safest route only): the rapid onto the part may cross cut contours;
+ // every other motion of the part stays strict.
private IEnumerable RecoverPart(Node node, int[] sequence,
- FixedProgramPlacement source, Attempt attempt)
+ FixedProgramPlacement source, Attempt attempt, bool arrivalMayCross = false)
{
var prepared = source.Prepared;
var target = LookAheadCentre(node, sequence, source) - source.Location;
@@ -596,7 +618,7 @@ internal static class JointCuttingPlanSearch
var findings = released.Check(opening, node.Position, source.SourceOrdinal + 1, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
- if (findings.Count != 0) yield break;
+ if (findings.Count != 0 && !(arrivalMayCross && OnlyCrossings(findings))) yield break;
start = opening.DeparturePoint;
}
OwnedExecution Motion(ContourChoice choice)
@@ -632,7 +654,8 @@ internal static class JointCuttingPlanSearch
var execution = ExecutionMotionReader.Read(program, source.Location, node.Position, token);
var checker = node.Checker.Copy();
CountExpansion(source);
- if (!Check(source, execution, node.Position, checker, attempt, chain)) continue;
+ if (arrivalMayCross ? Score(source, execution, node.Position, checker, chain) is not { Severe: 0, Minor: 0 }
+ : !Check(source, execution, node.Position, checker, attempt, chain)) continue;
var proposal = source.Propose(program, execution, chain, token);
yield return new(new([.. node.Order, proposal], execution.DeparturePoint, checker, null, node),
execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, endpoint, endpoint);
@@ -674,7 +697,9 @@ internal static class JointCuttingPlanSearch
var findings = after.Check(execution, position, source.SourceOrdinal + 1, token);
rejected.AddRange(Map(snapshot, findings));
attempt.NoteCrossings(source.SourceOrdinal, findings);
- if (findings.Count != 0) continue;
+ // The first contour's rapid is the arrival unless the part opens with scribes.
+ if (findings.Count != 0 && !(arrivalMayCross && mask == 0 && openingProgram.Length == 0
+ && OnlyCrossings(findings))) continue;
chain.Add(choice);
if (Solve(mask | (1 << choice.ContourOrdinal), execution.DeparturePoint, after)) return true;
chain.RemoveAt(chain.Count - 1);
@@ -695,6 +720,9 @@ internal static class JointCuttingPlanSearch
}
}
+ private static bool OnlyCrossings(IReadOnlyList findings) =>
+ findings.All(f => f.Kind == PostVerificationKind.RapidCrossing);
+
// An isolated contour probe repeats the part's scribes first; keep the contour alone.
private static OwnedExecution WithoutScribes(OwnedExecution execution)
{
@@ -942,6 +970,142 @@ internal static class JointCuttingPlanSearch
token.ThrowIfCancellationRequested();
}
+ /// The nearest ready parts a safest-route step weighs when none has a safe route.
+ private const int SafestRouteNearest = 4;
+
+ // A kept order's parts face the next part along it; a free walk's, the nearest ready part.
+ private int[] KeptSequence => snapshot.PreservePartOrder ? Enumerable.Range(0, snapshot.Placements.Count).ToArray() : null;
+
+ internal IReadOnlyList SafestRoute()
+ {
+ var node = new Node([], snapshot.StartPoint, new ReleasedContourState(reportMissingLeadIns: false), null, null);
+ while (node.Order.Length < snapshot.Placements.Count)
+ {
+ token.ThrowIfCancellationRequested();
+ var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
+ var ready = (snapshot.PreservePartOrder ? [snapshot.Placements[node.Order.Length]]
+ : snapshot.Placements.Where(p => !finished.Contains(p.SourceOrdinal)
+ && snapshot.Dependencies.IsReady(p.SourceOrdinal, finished)))
+ .OrderBy(p => Centre(p).DistanceTo(node.Position)).ThenBy(p => p.SourceOrdinal).ToArray();
+ // Nearest first, the first part with a route that passes every check wins. When
+ // none of the nearby parts has one, the least unsafe of them is cut.
+ SafestStep best = null;
+ foreach (var source in ready.Take(SafestRouteNearest))
+ {
+ if (best is { Severity.IsClear: true })
+ break;
+ if (SafestPart(node, source) is { } step
+ && (best == null || step.Severity < best.Severity
+ || step.Severity == best.Severity && step.Distance < best.Distance))
+ best = step;
+ }
+ if (best == null)
+ return null;
+ node = best.Node;
+ }
+ return node.Order;
+ }
+
+ /// The holes, next along the tour, a safest-route step weighs for its next cut.
+ private const int SafestHoleWindow = 4;
+
+ // One part's route from the node, its perimeter last. Null when it cannot be emitted.
+ private SafestStep SafestPart(Node node, FixedProgramPlacement source)
+ {
+ var arrival = node.Position;
+ if (source.Prepared is not { } prepared)
+ {
+ CountExpansion(source);
+ var fixedChecker = node.Checker.Copy();
+ return new(new([.. node.Order, source], source.Execution.DeparturePoint, fixedChecker, null, node),
+ Score(source, source.Execution, arrival, fixedChecker), source.Execution.RapidDistanceFrom(arrival));
+ }
+ // Recovery's contour solver first: its first route whose only violations, if any, are
+ // where the arrival rapid crosses cut contours. It passes every check when it can.
+ if (prepared.Count > 1 && prepared.PerimeterOrdinal <= MaximumRecoveryHoles
+ && RecoverPart(node, KeptSequence, source, new Attempt(null), arrivalMayCross: true).FirstOrDefault() is { } recovered)
+ {
+ var proposal = recovered.Node.Order[^1];
+ return new(recovered.Node, Score(proposal, proposal.Execution, arrival, node.Checker.Copy(),
+ proposal.ContourChoices), recovered.Distance);
+ }
+ // Otherwise contour by contour: of the next few holes along the tour, the cut that adds
+ // the fewest violations to the part's route, nearest along the tour on a tie.
+ var approach = arrival - source.Location;
+ var holes = Enumerable.Range(0, prepared.PerimeterOrdinal).ToArray();
+ var centres = CuttingHoleOrder.Centres(prepared, approach, token);
+ var remaining = CuttingHoleOrder.Plan(holes, centres, approach, null, token).ToList();
+ var onward = LookAheadCentre(node, KeptSequence, source) - source.Location;
+ var choices = new List();
+ Program program = null;
+ OwnedExecution execution = null;
+ ReleasedContourState checker = null;
+ var severity = default(RouteSeverity);
+ while (choices.Count < prepared.Count)
+ {
+ var local = (execution?.DeparturePoint ?? arrival) - source.Location;
+ var before = severity;
+ ContourChoice chosen = null;
+ foreach (var contour in remaining.Count == 0 ? [prepared.PerimeterOrdinal] : remaining.Take(SafestHoleWindow))
+ {
+ // Leave each hole toward the next one along the tour, so its exit rapid does
+ // not run back across it; the perimeter faces the next part.
+ var next = remaining.FirstOrDefault(h => h != contour, -1);
+ var entries = SelectEntries(source, contour, next >= 0 ? centres[next] : onward, local);
+ if (entries.Count == 0)
+ entries = [prepared.ClosestEntry(contour, local)];
+ foreach (var entry in entries)
+ {
+ CountExpansion(source);
+ var prefix = choices.Append(entry).ToArray();
+ Program emitted;
+ OwnedExecution read;
+ try
+ {
+ emitted = prefix.Length == prepared.Count ? prepared.Emit(prefix) : prepared.EmitPrefix(prefix);
+ read = ExecutionMotionReader.Read(emitted, source.Location, arrival, token);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or ArithmeticException or NotSupportedException)
+ {
+ continue;
+ }
+ // The prefix repeats the part's earlier contours: score it from before the part.
+ var trial = node.Checker.Copy();
+ var score = Score(source, read, arrival, trial, prefix);
+ if (chosen == null || score < severity)
+ (chosen, program, execution, checker, severity) = (entry, emitted, read, trial, score);
+ if (score == before)
+ break;
+ }
+ if (chosen != null && severity == before)
+ break;
+ }
+ if (chosen == null)
+ return null;
+ choices.Add(chosen);
+ remaining.Remove(chosen.ContourOrdinal);
+ }
+ return new(new([.. node.Order, source.Propose(program, execution, choices, token)],
+ execution.DeparturePoint, checker, null, node), severity, execution.RapidDistanceFrom(arrival));
+ }
+
+ // The strict checks of Check, counted instead of refused.
+ private RouteSeverity Score(FixedProgramPlacement source, OwnedExecution execution, Vector arrival,
+ ReleasedContourState checker, IReadOnlyList choices = null)
+ {
+ var isFixed = source.FixedMotions(execution, choices);
+ var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, source.IsCutOff, isFixed, token).Strict;
+ var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
+ : LeadPathValidator.Check(execution, source.Material, materials, isFixed, [], token, leadContacts,
+ snapshot.PierceZoneRadius);
+ var kinds = findings.Select(f => (PostVerificationKind?)f.Kind);
+ if (!lead.IsComplete)
+ kinds = kinds.Append(PostVerificationKind.Incomplete);
+ else if (!lead.IsClear)
+ kinds = kinds.Append(null);
+ return RouteSeverity.Of(kinds);
+ }
+
// choices: the contours a regenerated execution emits, so its pinned ones are known.
private bool Check(FixedProgramPlacement source, OwnedExecution execution, Vector arrival,
ReleasedContourState checker, Attempt attempt, IReadOnlyList choices = null)
@@ -1037,6 +1201,7 @@ internal static class JointCuttingPlanSearch
/// 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, int ContourRank = 0);
+ private sealed record SafestStep(Node Node, RouteSeverity Severity, double Distance);
private sealed class Frame(Node node)
{
internal Node Node { get; } = node;
diff --git a/OpenNest.Engine/CuttingPlanning/RouteSeverity.cs b/OpenNest.Engine/CuttingPlanning/RouteSeverity.cs
new file mode 100644
index 00000000..b0c043cd
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/RouteSeverity.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Generic;
+using OpenNest.Diagnostics;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+///
+/// How unsafe a route is, compared most severe first: motions into material (leads through a
+/// part, cut-order or nested-lead conflicts, incomplete checks), then rapids across cut
+/// contours, then everything else. The default is a route that breaks no check.
+///
+internal readonly record struct RouteSeverity(int Severe, int Rapids, int Minor) : IComparable
+{
+ internal bool IsClear => Severe == 0 && Rapids == 0 && Minor == 0;
+
+ internal int Count => Severe + Rapids + Minor;
+
+ public int CompareTo(RouteSeverity other) =>
+ Severe != other.Severe ? Severe.CompareTo(other.Severe)
+ : Rapids != other.Rapids ? Rapids.CompareTo(other.Rapids)
+ : Minor.CompareTo(other.Minor);
+
+ public static bool operator <(RouteSeverity left, RouteSeverity right) => left.CompareTo(right) < 0;
+
+ public static bool operator >(RouteSeverity left, RouteSeverity right) => left.CompareTo(right) > 0;
+
+ /// A finding without a kind is a refused lead path (into material or a pierce zone).
+ internal static RouteSeverity Of(IEnumerable kinds)
+ {
+ var severe = 0;
+ var rapids = 0;
+ var minor = 0;
+ foreach (var kind in kinds)
+ {
+ switch (kind)
+ {
+ case null or PostVerificationKind.Overlap or PostVerificationKind.NestedCutOrder
+ or PostVerificationKind.NestedLead or PostVerificationKind.Incomplete:
+ severe++;
+ break;
+ case PostVerificationKind.RapidCrossing:
+ rapids++;
+ break;
+ default:
+ minor++;
+ break;
+ }
+ }
+ return new(severe, rapids, minor);
+ }
+}
diff --git a/OpenNest.Tests/CuttingPlanning/SafestRouteFallbackTests.cs b/OpenNest.Tests/CuttingPlanning/SafestRouteFallbackTests.cs
new file mode 100644
index 00000000..3961d701
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/SafestRouteFallbackTests.cs
@@ -0,0 +1,127 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Diagnostics;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class SafestRouteFallbackTests
+{
+ [Fact]
+ public void KeptOrderThroughAWall_OffersTheSafestRouteAsAnUnverifiedPlan()
+ {
+ var plate = WalledPlate();
+ var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: true).Plan();
+
+ var planned = Assert.Single(proposal.Plates);
+ Assert.True(planned.Result.IsSafestRouteFallback);
+ Assert.Equal(CuttingPlanStatus.BestEffort, planned.Result.Status);
+ Assert.False(planned.IsReady);
+ Assert.True(planned.CanApplyWithWarnings);
+ Assert.False(planned.HasOnlyFixedLeadWarnings);
+ Assert.False(planned.SearchLimitReached);
+ Assert.Equal(plate.Parts, planned.Result.ProposedOrder.Select(p => p.SourcePart));
+ // Only the rapid from the left square to the right one has to cross the cut bar.
+ var crossing = Assert.Single(planned.Result.Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
+ Assert.Equal(2, crossing.SourceOrdinal);
+ Assert.Equal(0, crossing.OtherSourceOrdinal);
+ Assert.DoesNotContain(planned.Result.Findings, f => f.Kind is null or PostVerificationKind.Incomplete);
+ Assert.True(proposal.RequiresWarningAcceptance);
+ var text = string.Join("\n", proposal.Describe("in"));
+ Assert.Contains("Safest-route plan available", text);
+ Assert.Contains("safest route found, unverified", text);
+ Assert.Contains("breaks 1 check", text);
+ }
+
+ [Fact]
+ public void SafestRoute_AppliesOnlyWithExplicitAcceptance()
+ {
+ var plate = WalledPlate();
+ var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: true).Plan();
+
+ Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
+ Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
+
+ Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: true).Status);
+ Assert.All(plate.Parts, part => Assert.True(part.HasManualLeadIns));
+ }
+
+ [Fact]
+ public void FreeOrder_FindsTheRouteThatPassesEveryCheck_WithoutTheFallback()
+ {
+ var proposal = CuttingPlanBatch.Capture([WalledPlate()], ExplicitContourTests.Parameters(), preservePartOrder: false).Plan();
+
+ var planned = Assert.Single(proposal.Plates);
+ Assert.True(planned.IsReady);
+ Assert.False(planned.Result.IsSafestRouteFallback);
+ }
+
+ [Fact]
+ public void SafestRoute_ThatPassesEveryCheck_IsReadyAndReplayed()
+ {
+ var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(WalledPlate(),
+ confirmedParameters: ExplicitContourTests.Parameters()));
+
+ var result = CuttingPlanService.PlanSafestRoute(snapshot, CancellationToken.None);
+
+ Assert.NotNull(result);
+ Assert.Equal(CuttingPlanStatus.Ready, result.Status);
+ Assert.True(result.IndependentlyReplayed);
+ Assert.False(result.IsSafestRouteFallback);
+ Assert.Empty(result.Findings);
+ }
+
+ [Fact]
+ public void SafestRoute_NeverMutatesThePlate()
+ {
+ var plate = WalledPlate();
+ var programs = plate.Parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)).ToArray();
+ var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
+ confirmedParameters: ExplicitContourTests.Parameters(), preservePartOrder: true));
+
+ Assert.NotNull(CuttingPlanService.PlanSafestRoute(snapshot, CancellationToken.None));
+
+ Assert.Equal(programs, plate.Parts.Select(p => ExplicitContourTests.Fingerprint(p.Program)));
+ Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
+ }
+
+ [Fact]
+ public void Severity_RanksMaterialBeforeRapidsBeforeEverythingElse()
+ {
+ var lead = RouteSeverity.Of([null]);
+ var rapids = RouteSeverity.Of([PostVerificationKind.RapidCrossing, PostVerificationKind.RapidCrossing]);
+ var minor = RouteSeverity.Of([PostVerificationKind.PierceZone, PostVerificationKind.PartSupport, PostVerificationKind.MissingLeadIn]);
+
+ Assert.Equal(new RouteSeverity(1, 0, 0), lead);
+ Assert.True(minor < rapids);
+ Assert.True(rapids < lead);
+ Assert.True(default(RouteSeverity) < minor);
+ Assert.True(default(RouteSeverity).IsClear);
+ Assert.Equal(new RouteSeverity(1, 0, 0), RouteSeverity.Of([PostVerificationKind.NestedCutOrder]));
+ Assert.Equal(new RouteSeverity(1, 0, 0), RouteSeverity.Of([PostVerificationKind.Incomplete]));
+ }
+
+ // A tall bar between two squares, in the order bar, left, right: once the bar is cut every
+ // straight rapid from the left square to the right one crosses it. Cut last, it crosses nothing.
+ private static Plate WalledPlate()
+ {
+ var plate = new Nest().CreatePlate();
+ plate.Size = new Size(60, 60);
+ plate.Parts.Add(new Part(new Drawing("bar", Rectangle(1, 50)), new Vector(25, 2)));
+ plate.Parts.Add(new Part(new Drawing("left", ExplicitContourTests.Square(false)), new Vector(5, 20)));
+ plate.Parts.Add(new Part(new Drawing("right", ExplicitContourTests.Square(false)), new Vector(36, 20)));
+ return plate;
+ }
+
+ private static Program Rectangle(double width, double height)
+ {
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(0, height);
+ program.LineTo(width, height);
+ program.LineTo(width, 0);
+ program.LineTo(0, 0);
+ return program;
+ }
+}
diff --git a/OpenNest/Forms/CuttingInspectionPanel.cs b/OpenNest/Forms/CuttingInspectionPanel.cs
index bcda1275..5ee0f705 100644
--- a/OpenNest/Forms/CuttingInspectionPanel.cs
+++ b/OpenNest/Forms/CuttingInspectionPanel.cs
@@ -686,6 +686,7 @@ internal sealed class CuttingInspectionPanel : UserControl
{
CuttingPlanPhase.Reordering => "finding a part order",
CuttingPlanPhase.KeepingOrder => "keeping the current order",
+ CuttingPlanPhase.SafestRoute => "finding the safest route",
CuttingPlanPhase.CheckingOverlap => "checking for overlaps",
_ => phase.ToString(),
};
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 3319bc28..a4d1c6b3 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -214,7 +214,8 @@ material-centre distance. When a branch stalls, it disposes the deeper search
frames and resumes alternatives at the root instead of spending the remainder
on that branch's completed prefix. Any budget left then goes to the retained full
search. Recovery shares the original total expansion budget; exhausting it still
-returns a refusal, never a best-effort upgrade.
+returns a refusal from the service, never a best-effort upgrade. Only the batch then
+looks for the separate [safest route](#safest-route-fallback-when-the-search-runs-out).
Candidates use native closest points, vertices, midpoints and circle angles in
stable order. `maxEntries` caps the normal ranked shortlist, not the recovery
@@ -391,7 +392,8 @@ readiness or physical machine safety.
Findings and source ordinals use the original zero-based source list, not proposed
sequence positions. Strict service refusals contain no proposed order. The desktop batch may
produce a separate `BestEffort` proposal as described below; it never labels it `Ready` or
-sets `IndependentlyReplayed`.
+sets `IndependentlyReplayed`. The one exception is a safest route that passes every check
+on its own fresh replay: that is an ordinary replayed `Ready` proposal.
- `ConstraintConflict`: fixed programs or explored fixed routing violate the
modeled constraints. Locked internal crossings cannot be repaired by regeneration;
@@ -619,6 +621,8 @@ panel is wired in the editor. Non-editor callers still use the shared analyzer.
`NoSolutionWithinBudget` is retried once with the current part order, and the summary
says the order was kept. Both are allowed 400 expansions per part (at least the
default 20000), because both still plan contour order and entries for every part.
+ When both stop at that limit, the batch looks for the
+ [safest route](#safest-route-fallback-when-the-search-runs-out) before refusing the plate.
- **Tools > Options > Cut planning effort** (`Settings.CuttingPlanEffort`, default 1.0,
0.5–10) multiplies both of those budgets. The panel reads it when planning starts and
passes it to `CuttingPlanBatch.Capture(..., budgetMultiplier)`; the worker never reads
@@ -704,6 +708,40 @@ overlap check may likewise be accepted with warnings. `CanApply` and parameterle
explicit review path. `CuttingPlanService.Apply` still rejects best-effort results directly.
Acceptance never skips the separate pre-post checks or grants posting consent.
+### Safest-route fallback when the search runs out
+
+Planning ranks routes safest first, then fastest. The strict search only accepts a route that
+passes every check, so on a hard plate (for example scrap cut-offs whose trimmed segments wall
+off a column of parts, since rapids are straight) it can run out of budget without a plan even
+when a slightly unsafe one would be acceptable after review. The batch then plans the safest
+route it can find instead of refusing the plate. It runs only when the plate's plan stopped at
+the search limit (`NoSolutionWithinBudget`, or a free-order search at its limit followed by a
+refused current order), never for overlaps, unsupported geometry, invalid input or
+cancellation, and the progress phase is `SafestRoute`.
+
+`JointCuttingPlanSearch.SafestRoute` walks the plate greedily from the start point. Each step,
+of the dependency-ready parts nearest first (the next part when the order is kept), it cuts
+the first whose route passes every check; when none of the four nearest has one, it cuts the
+least unsafe of them, then the nearer. A part's route comes from the recovery contour solver
+when it has 16 holes or fewer, with every motion strict except the rapid onto the part, which
+may cross cut contours; otherwise its next contour is the safest of the next four holes along
+the hole tour, each entry facing the hole after it, the perimeter last. Cut-off and nested-part
+prerequisites always hold. The walk weighs violations with `RouteSeverity`, compared in tiers:
+motions into material, cut-order and nested-lead conflicts and incomplete checks first, then
+rapids across cut contours, then everything else (missing lead-ins, pierce zones, support).
+It gets twice the plate's expansion budget, so the planning effort scales it too.
+
+The batch walks both the free order and the current order, replays each against its captured
+input with fresh checks, and keeps the one with the lower severity, then the shorter rapid
+travel. A route that passes every check on replay is an ordinary `Ready` proposal. Otherwise
+the result is `BestEffort` with `IsSafestRouteFallback`, listing every violation; the summary
+calls it the safest route found, gives the number of checks it breaks and its rapid travel,
+and Apply requires explicit warning acceptance like any unverified plan. The fallback never
+offers material damage: a route with any motion in the first tier (a lead-in that does not
+fit, a cut-order or nested-lead conflict) or whose replay cannot be completed is dropped, and
+the plate keeps its strict refusal and its advice, such as shortening a lead-in that does not
+fit. A walk that finds no route likewise leaves the refusal unchanged.
+
## Console and MCP
Both front ends run `CuttingPlanBatch` exactly as the desktop does: capture, plan, then an