mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-26 02:51:05 -04:00
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:
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;
|
||||
|
||||
Reference in new issue
Block a user