Files
OpenNest/OpenNest.Engine/CuttingPlanning/ContourEntrySelection.cs
T
ajandClaude Opus 5.5 4d55e11318 style: use one spelling across the codebase
The repo mixed variants such as center/centre and color/colour, so a
name could be spelled either way and searches missed half the hits.
Standardize on the US spelling, matching .NET's own APIs (Color, Center).

Wire values owned by other systems keep their spelling: the material
ledger's "Cancelled" hold status and GitHub's "cancelled" job status.

No behavior change beyond the spelling of a few status messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:11:30 -04:00

267 lines
12 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>Why a selection ended the way it did. Honest metadata, never a geometric overclaim.</summary>
internal enum ContourSelectionShortfall
{
/// <summary>Selection ended at the cap with the side scan satisfied: a complete verdict.</summary>
None,
/// <summary>
/// The finite catalog was fully evaluated and the selection is all there is. With an
/// empty selection this is exactly the sentence "no tested lead-in fits on part N,
/// contour M" — and ONLY here may that sentence be spoken.
/// </summary>
Exhausted,
/// <summary>
/// At least one check could not complete (incomplete material or emission). Never
/// present this as geometric impossibility and never as "nothing fits".
/// </summary>
Incomplete,
}
/// <summary>
/// Outcome of one bounded selection: selected choices in global rank order, how many
/// distinct candidates were evaluated, which ones (in catalog 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);
/// <summary>
/// Bounded, lazy selection of lead-feasible entry candidates for ONE contour. Greedy global
/// rank order until the cap (default 16) fills or the finite catalog ends — rejected and
/// incomplete candidates never consume a slot. When the cap can afford it (4+), one
/// corrective scan makes sure every side of the candidate bounding rectangle that HAS a
/// feasible candidate is represented, replacing the worst selected candidate only when
/// every other covered side survives; one corner may cover two sides. The cap is never
/// exceeded and points are never manufactured on infeasible sides — a capped selection may
/// truthfully omit a feasible side. Sides are exactly the ranker's side geometry (the
/// candidate-set bounding rectangle). Verdicts are memoized per attempt (the adapter
/// memoizes too): no candidate is ever evaluated twice, and once selection settles the
/// untouched tail is never evaluated. No search/DFS changes and no larger search budget.
/// </summary>
internal static class ContourEntrySelection
{
internal const int DefaultMaxEntries = 16;
/// <summary>Below this cap all-side coverage is not promised; the cap and ranking bind first (caps 1-3).</summary>
internal const int SideCoverageMinCap = 4;
/// <summary>
/// Selects up to <paramref name="maxEntries"/> feasible choices from one contour's
/// globally ranked catalog. <paramref name="evaluate"/> is the S07 adapter verdict,
/// called lazily at most once per distinct candidate.
/// </summary>
internal static ContourSelectionResult Select(
IReadOnlyList<ContourEntryCandidate> rankedCandidates,
Func<ContourEntryCandidate, ContourFeasibilityVerdict> evaluate,
int maxEntries = DefaultMaxEntries,
CancellationToken token = default)
{
token.ThrowIfCancellationRequested();
if (rankedCandidates == null)
throw new ArgumentException("Ranked candidates are required.", nameof(rankedCandidates));
if (evaluate == null)
throw new ArgumentException("A feasibility evaluation is required.", nameof(evaluate));
if (maxEntries <= 0)
throw new ArgumentException("The cap must be positive.", nameof(maxEntries));
// Distinct points only — the cap must not double-count a geometric duplicate.
var candidates = new List<ContourEntryCandidate>();
var seen = new HashSet<(long, long)>();
foreach (var candidate in rankedCandidates)
if (seen.Add(candidate.GeometryKey))
candidates.Add(candidate);
var evaluated = new List<ContourEntryCandidate>();
var verdicts = new Dictionary<(long, long), ContourFeasibilityVerdict>();
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)
{
token.ThrowIfCancellationRequested();
if (!verdicts.TryGetValue(candidate.GeometryKey, out var known))
{
known = evaluate(candidate);
verdicts[candidate.GeometryKey] = known;
evaluated.Add(candidate);
}
return known;
}
// Phase 1: greedy global order until the cap fills or the catalog ends.
for (; index < candidates.Count && selected.Count < maxEntries; index++)
{
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;
incompleteReason ??= verdict.Reason;
uncertain.Add(candidates[index]);
continue;
}
if (verdict.IsClear)
selected.Add(candidates[index]);
}
// Phase 2: reconsider the globally ranked catalog for each missing side, reusing
// verdicts passed while chasing earlier sides and evaluating the tail lazily.
// A clear candidate on that side is appended when
// 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)
for (var side = 0; side < 4 && !sawIncomplete; side++)
{
if (selected.Any(c => Sides(c, box).Contains(side)))
continue;
for (index = 0; index < candidates.Count; index++)
{
var verdict = Verdict(candidates[index]);
if (verdict.Status == ContourFeasibilityStatus.Incomplete)
{
sawIncomplete = true;
incompleteReason ??= verdict.Reason;
uncertain.Add(candidates[index]);
continue;
}
if (!verdict.IsClear || !Sides(candidates[index], box).Contains(side))
continue;
TryPlace(selected, candidates[index], box, maxEntries);
// A reused candidate can outrank an earlier replacement. Keep the
// safe-victim scan in global rank order for the next missing side.
selected.Sort((a, b) => candidates.IndexOf(a).CompareTo(candidates.IndexOf(b)));
break;
}
}
var exhausted = index >= candidates.Count;
ContourSelectionShortfall shortfall;
string? reason;
if (sawIncomplete)
{
shortfall = ContourSelectionShortfall.Incomplete;
reason = "At least one lead check could not complete; treat the result as undetermined rather than a geometric verdict."
+ (incompleteReason == null ? "" : $" First reason: {incompleteReason}");
}
else if (selected.Count >= maxEntries)
{
shortfall = ContourSelectionShortfall.None;
reason = null;
}
else if (exhausted)
{
shortfall = ContourSelectionShortfall.Exhausted;
reason = selected.Count == 0
? "No tested lead-in fits on this contour."
: null;
}
else
{
// Unreachable: phase 1 ends at cap or catalog end and phase 2 chases every
// side to the end; a cancellation throws before this point.
shortfall = ContourSelectionShortfall.Incomplete;
reason = "Selection stopped before the catalog ended.";
}
var ordered = selected
.OrderBy(c => candidates.IndexOf(c))
.Select(c => c.Choice)
.ToList();
return new(ordered, evaluated.Count,
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(
List<ContourEntryCandidate> candidates)
{
double minX = double.PositiveInfinity, minY = double.PositiveInfinity,
maxX = double.NegativeInfinity, maxY = double.NegativeInfinity;
foreach (var c in candidates)
{
var p = c.Choice.Point;
if (p.X < minX) minX = p.X;
if (p.X > maxX) maxX = p.X;
if (p.Y < minY) minY = p.Y;
if (p.Y > maxY) maxY = p.Y;
}
if (!double.IsFinite(minX))
return (0, 0, 0, 0);
return (minX, minY, maxX, maxY);
}
/// <summary>Sides 0 left, 1 right, 2 bottom, 3 top of the candidate bounding rectangle that the point sits on.</summary>
private static List<int> Sides(ContourEntryCandidate candidate,
(double MinX, double MinY, double MaxX, double MaxY) box)
{
var p = candidate.Choice.Point;
var left = p.X - box.MinX;
var right = box.MaxX - p.X;
var bottom = p.Y - box.MinY;
var top = box.MaxY - p.Y;
var min = System.Math.Min(System.Math.Min(left, right), System.Math.Min(bottom, top));
var sides = new List<int>(2);
if (left <= min + PostVerificationGeometry.Epsilon) sides.Add(0);
if (right <= min + PostVerificationGeometry.Epsilon) sides.Add(1);
if (bottom <= min + PostVerificationGeometry.Epsilon) sides.Add(2);
if (top <= min + PostVerificationGeometry.Epsilon) sides.Add(3);
return sides;
}
/// <summary>Appends when a slot remains, else replaces the worst (latest-ranked) candidate whose removal preserves every other covered side.</summary>
private static void TryPlace(List<ContourEntryCandidate> selected, ContourEntryCandidate candidate,
(double MinX, double MinY, double MaxX, double MaxY) box, int maxEntries)
{
if (selected.Count < maxEntries)
{
selected.Add(candidate);
return;
}
var incoming = Sides(candidate, box);
for (var i = selected.Count - 1; i >= 0; i--)
{
var removalSafe = true;
for (var side = 0; side < 4 && removalSafe; side++)
{
if (incoming.Contains(side))
continue; // the replacement covers it
var covers = selected.Where((c, at) => at != i && Sides(c, box).Contains(side)).Any();
var wasCovered = selected.Any(c => Sides(c, box).Contains(side));
if (wasCovered && !covers)
removalSafe = false;
}
if (removalSafe)
{
selected[i] = candidate;
return;
}
}
// No safe victim: the side stays truthfully unrepresented at this cap.
}
}