feat(planning): offer the safest route when the strict search runs out

Cut planning should rank routes safest first, then fastest, but a plate whose
strict search hit its expansion budget was simply refused ("no plan found
within the search limit"), even when a route with a few reviewable rapid
crossings existed. Scrap cut-offs make this common: their trimmed segments
can wall off a column of parts, and rapids are straight.

When both the free-order and current-order searches stop at the limit, the
batch now walks a greedy safest route from each input, replays it with fresh
checks, and keeps the one with fewer violations (material first, then rapid
crossings, then the rest), then less rapid travel. A route that passes every
check is an ordinary Ready plan; otherwise it is an unverified BestEffort
that lists every violation and applies only with explicit acceptance. Routes
with any motion into material are never offered, so the strict refusal and
its lead-in advice stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-22 11:37:17 -04:00
1 parent 1fc36e9e54
commit ff46de69df
9 files changed
+495 -13

No files matched your search

@@ -20,6 +20,9 @@ public enum CuttingPlanPhase
/// <summary>Checking the plate's clean part material for overlaps.</summary>
CheckingOverlap,
/// <summary>No route passed every check within the search limit: finding the safest one.</summary>
SafestRoute,
}
/// <summary>Worker progress: zero-based position in the batch, the plate's display number and phase.</summary>
@@ -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;
/// <summary>
/// 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.
/// </summary>
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
@@ -340,6 +340,16 @@ public sealed class CuttingPlanResult
public int Expansions { get; }
public double RapidDistance { get; }
public bool IndependentlyReplayed { get; }
/// <summary>
/// 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.
/// </summary>
internal RouteSeverity? SafestRoute { get; init; }
/// <summary>Whether this is a safest-route fallback rather than a route that passed every check.</summary>
public bool IsSafestRouteFallback => SafestRoute != null;
/// <summary>The captured input this result was planned from; binds Apply to its freshness record.</summary>
internal CuttingPlanSnapshot Snapshot { get; set; }
}
@@ -319,10 +319,40 @@ public static class CuttingPlanService
expansions: expansions, rapidDistance: distance, independentlyReplayed: true);
}
/// <summary>
/// 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.
/// </summary>
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.
/// <param name="safestRoute">
/// 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.
/// </param>
internal static CuttingPlanResult ReplayPrograms(CuttingPlanSnapshot snapshot,
IReadOnlyList<FixedProgramPlacement> order, int expansions, CancellationToken token)
IReadOnlyList<FixedProgramPlacement> 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)
@@ -116,6 +116,26 @@ internal static class JointCuttingPlanSearch
}
}
/// <summary>
/// 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.
/// </summary>
internal static IReadOnlyList<FixedProgramPlacement> 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<int>[] 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<Edge> 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<PostVerificationFinding> 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();
}
/// <summary>The nearest ready parts a safest-route step weighs when none has a safe route.</summary>
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<FixedProgramPlacement> 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;
}
/// <summary>The holes, next along the tour, a safest-route step weighs for its next cut.</summary>
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<ContourChoice>();
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<ContourChoice> 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<ContourChoice> choices = null)
@@ -1037,6 +1201,7 @@ internal static class JointCuttingPlanSearch
/// <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, int ContourRank = 0);
private sealed record SafestStep(Node Node, RouteSeverity Severity, double Distance);
private sealed class Frame(Node node)
{
internal Node Node { get; } = node;
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using OpenNest.Diagnostics;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// 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.
/// </summary>
internal readonly record struct RouteSeverity(int Severe, int Rapids, int Minor) : IComparable<RouteSeverity>
{
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;
/// <summary>A finding without a kind is a refused lead path (into material or a pierce zone).</summary>
internal static RouteSeverity Of(IEnumerable<PostVerificationKind?> 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);
}
}