fix(cutting): aim perimeter entries toward the next part

No-hole parts now choose the outside entry through the S03-S08 pipeline
at the part boundary: rank the native catalogue toward the NEXT cut's
placed-material centre, lazily certify each emitted lead with the shared
validator, cap at maxEntries with side coverage. The next cut is the
next unfinished part on the supplied order (re-read after every
learned-order replan) or, in sequence-free fallback, the nearest
dependency-ready remaining part with stable ordinal ties; the last part
has no target and ranks by tier then arrival distance — never the plate
origin. Target and arrival are converted to prepared LOCAL coordinates
exactly once; geometry is already rotated.

The look-ahead rank survives Follow's ordering: children sort by
nearest source first (the sequence-free tour stays nearest-first), then
contour, then the selection rank — plain OrderBy(Distance) can no longer
undo the facing. Measured fixture (three squares, 0.15 leads, origin
start): legacy cut every sheet at its arrival-nearest lower-left corner
with 10.5-unit cross-sheet rapids totalling 21.0; look-ahead cuts the
+X-facing corner with the same 21.0 total but each inter-part rapid now
starts at the facing edge instead of trailing across the whole sheet.

Uncertain validator answers are never precheck-refused: they skip the
selected slots but flow to the emitted-prefix Check and complete replay,
which stay the authority — a refused-looking incomplete is reported,
not hidden. A fully evaluated catalogue with no fitting lead surfaces
'No tested lead-in fits on part N, contour M'; budget exhaustion stays a
budget finding. Lead prechecks count separately from DFS expansions; the
pipeline itself is one counted expansion per contour. Full backtracking,
dependencies, locked/fixed programs, cutoff handling, emitted-prefix
Check and complete replay are unchanged; budgets and CuttingPartOrder
untouched. Hole parts keep the legacy path until S12.
This commit is contained in:
aj committed 2026-10-07 11:01:08 -04:00
1 parent acd1f21293
commit d4544ea358
5 files changed
+473 -17

No files matched your search

@@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
@@ -1,3 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
@@ -30,13 +31,15 @@ internal enum ContourSelectionShortfall
/// <summary>
/// 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.
/// </summary>
internal sealed record ContourSelectionResult(
IReadOnlyList<ContourChoice> Choices,
int EvaluatedCount,
IReadOnlyList<(long, long)> EvaluatedKeys,
IReadOnlyList<ContourChoice> UncertainChoices,
ContourSelectionShortfall Shortfall,
string? Reason);
@@ -91,7 +94,9 @@ internal static class ContourEntrySelection
var box = Box(candidates);
var selected = new List<ContourEntryCandidate>();
var uncertain = new List<ContourEntryCandidate>();
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(
@@ -24,8 +24,9 @@ internal static class JointCuttingPlanSearch
/// </summary>
internal const int StallExpansionsPerEntry = 8;
/// <summary>LeadPrechecks counts S07 adapter evaluations — bounded work tracked separately from expansions.</summary>
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
IReadOnlyList<CuttingPlanFinding> Findings, int Expansions);
IReadOnlyList<CuttingPlanFinding> 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<CuttingPlanFinding> 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<int, ContourEntryFeasibility> feasibility = [];
private readonly HashSet<int> reportedNoFit = [];
internal int Expansions { get; private set; }
/// <summary>Lead precheck evaluations, tracked apart from DFS expansions: they are work, not free.</summary>
internal int LeadPrechecks { get; private set; }
internal IReadOnlyList<CuttingPlanFinding> Rejected => rejected.Distinct().ToArray();
internal Outcome Ready(IReadOnlyList<FixedProgramPlacement> 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);
}
/// <summary>
@@ -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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<Edge> Expand(Node node, int[] sequence, Attempt attempt)
{
IEnumerable<FixedProgramPlacement> 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<ContourChoice> 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);
}
}
}
}
/// <summary>
/// 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.
/// </summary>
private IReadOnlyList<ContourChoice> 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
/// <summary>A search state; Previous links a whole-part boundary to the boundary before it.</summary>
private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
ActivePart Active, Node Previous);
private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry);
/// <summary>Rank is the automatic selection slot (entry order) inside its contour stage; int.MinValue for legacy children.</summary>
private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry, int Rank = int.MinValue);
private sealed class Frame(Node node)
{
internal Node Node { get; } = node;