Files
OpenNest/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
T
ajandClaude Opus 5.5 b2cc796e4a fix(cutting): let a part no tab can steady leave from another entry
A support-tabbed part that no single tab can hold level was limited to its
one steadiest entry. Two strips stacked in a frame's cutout then blocked
the plate: from the upper strip's only entry, every route to the lower one
crossed holes already cut. Its other entries clear of inside corners now
follow the steadiest, in the search and in contour recovery; the part is
still tabbed and still warned about. Parts a tab can hold keep their
steadying entries.

The unverified headline also named fixed lead-ins when the only warning
was a part that may tip; it now says what needs review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:34:00 -05:00

649 lines
33 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Diagnostics;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>What a batch is doing for one plate, reported from the worker.</summary>
public enum CuttingPlanPhase
{
/// <summary>Searching for a new whole-part order.</summary>
Reordering,
/// <summary>Planning with the plate's current part order.</summary>
KeepingOrder,
/// <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>
public sealed record CuttingPlanProgress(int PlateIndex, int PlateCount, int PlateNumber, CuttingPlanPhase Phase);
/// <summary>
/// One desktop planning attempt over one or more plates with caller-confirmed cutting parameters.
/// Capture on the thread that owns the plates, <see cref="Plan"/> on a worker, then apply the
/// returned proposal back on the owner thread. Every plate goes through
/// <see cref="CuttingPlanService"/>; nothing here installs programs or reorders parts itself.
/// </summary>
public sealed class CuttingPlanBatch
{
/// <summary>The smallest budget a plate gets, the service default.</summary>
public const int MinimumExpansionBudget = 20000;
/// <summary>
/// Expansions allowed per part, whether or not the order is kept: both still choose contour
/// order and entries for every part. Dense grids measured near 260 per part with a new order
/// and 210 when kept; the margin keeps 100-150 part plates inside the budget.
/// </summary>
public const int ExpansionsPerPart = 400;
/// <summary>The planning-effort multiplier that gives today's budgets.</summary>
public const double DefaultBudgetMultiplier = 1.0;
/// <summary>The smallest planning-effort multiplier; it still leaves the first pass room to run.</summary>
public const double MinimumBudgetMultiplier = 0.5;
/// <summary>The largest planning-effort multiplier, a bound on one plate's planning time.</summary>
public const double MaximumBudgetMultiplier = 10.0;
// Extra bounded work on hard/refused plates; the full-cap search still gets its whole budget.
// Not scaled by the multiplier: it is a fixed-cost probe for easy plates, so raising the
// effort spends more only where the full search runs and leaves easy plates' plans unchanged.
internal const int FirstPassBudget = 1000;
internal const int FirstPassEntries = 8;
private readonly Entry[] entries;
private readonly CuttingParameters ownedParameters;
private CuttingPlanBatch(Entry[] entries, CuttingParameters ownedParameters)
{
this.entries = entries;
this.ownedParameters = ownedParameters;
}
public int PlateCount => entries.Length;
/// <summary>
/// Captures every plate's exact state with an owned copy of <paramref name="confirmedParameters"/>,
/// and its clean part material for the overlap check. When the order may change, the
/// current-order request is captured too, so a free search that runs out of budget can be
/// retried on the worker without reading live plates again.
/// </summary>
/// <param name="budgetMultiplier">
/// Scales both searches' <see cref="PlateBudget"/>, between <see cref="MinimumBudgetMultiplier"/>
/// and <see cref="MaximumBudgetMultiplier"/>. The caller passes the operator's setting; the
/// worker never reads it.
/// </param>
public static CuttingPlanBatch Capture(IReadOnlyList<Plate> plates, CuttingParameters confirmedParameters,
bool preservePartOrder, IReadOnlyList<int> plateNumbers = null, CancellationToken token = default,
double budgetMultiplier = DefaultBudgetMultiplier) =>
Capture(plates, confirmedParameters, preservePartOrder, plateNumbers, null, token, budgetMultiplier);
/// <param name="reorderBudget">Overrides the new-order budget (tests); null gives <see cref="PlateBudget"/>.</param>
internal static CuttingPlanBatch Capture(IReadOnlyList<Plate> plates, CuttingParameters confirmedParameters,
bool preservePartOrder, IReadOnlyList<int> plateNumbers, int? reorderBudget, CancellationToken token,
double budgetMultiplier = DefaultBudgetMultiplier)
{
ArgumentNullException.ThrowIfNull(plates);
ArgumentNullException.ThrowIfNull(confirmedParameters);
if (!(budgetMultiplier >= MinimumBudgetMultiplier && budgetMultiplier <= MaximumBudgetMultiplier))
throw new ArgumentOutOfRangeException(nameof(budgetMultiplier), budgetMultiplier,
$"The budget multiplier must be between {MinimumBudgetMultiplier} and {MaximumBudgetMultiplier}.");
if (plates.Count == 0)
throw new ArgumentException("At least one plate is required.", nameof(plates));
if (plates.Any(plate => plate == null)
|| plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
throw new ArgumentException("Plates must be distinct and not null.", nameof(plates));
if (plateNumbers != null && plateNumbers.Count != plates.Count)
throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers));
CuttingParameters owned;
try
{
owned = OwnedCuttingParameters.Copy(confirmedParameters);
}
catch (NotSupportedException)
{
owned = null; // Each plate's capture reports the unsupported settings itself.
}
var entries = new Entry[plates.Count];
for (var index = 0; index < plates.Count; index++)
{
var plate = plates[index];
var budget = PlateBudget(plate.Parts.Count, budgetMultiplier);
// One capture serves both searches: the order flag and budget are search settings
// over the same owned geometry, programs and freshness record.
var captured = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
expansionBudget: budget, confirmedParameters: confirmedParameters,
preservePartOrder: preservePartOrder), token);
var keepOrder = preservePartOrder ? captured : captured.WithPreservedOrder();
var reorder = preservePartOrder ? null
: reorderBudget is { } reorderLimit ? captured.WithSearchLimits(reorderLimit, captured.MaxEntries) : captured;
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
var fallback = reorder ?? keepOrder;
if (fallback.Failure == CuttingPlanStatus.UnsupportedGeometry)
fallback = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
confirmedParameters: confirmedParameters, preservePartOrder: preservePartOrder), token, bestEffort: true);
entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder, overlap, fallback);
}
return new(entries, owned);
}
/// <summary>A plate's expansion budget: <see cref="ExpansionsPerPart"/> per part, at least
/// <see cref="MinimumExpansionBudget"/>, times the multiplier.</summary>
internal static int PlateBudget(int partCount, double multiplier = DefaultBudgetMultiplier)
{
var budget = System.Math.Max((double)MinimumExpansionBudget, (double)partCount * ExpansionsPerPart);
return (int)System.Math.Min(int.MaxValue, System.Math.Round(budget * multiplier));
}
/// <summary>
/// Brings a stored planning-effort value inside the supported range; a non-finite value gives
/// <see cref="DefaultBudgetMultiplier"/>.
/// </summary>
public static double ClampBudgetMultiplier(double multiplier) => double.IsFinite(multiplier)
? System.Math.Clamp(multiplier, MinimumBudgetMultiplier, MaximumBudgetMultiplier)
: DefaultBudgetMultiplier;
/// <summary>
/// Checks and plans every plate from its captured snapshots. Safe on a worker: live plates are
/// not read. Overlapping material blocks a plate whatever its route. A free search that ends
/// without a complete plan within its budget is retried with the current order; the proposal
/// reports that it kept the order. Incomplete geometry also gets a separately labeled,
/// unverified fallback when supported closed contours can still be emitted.
/// </summary>
public CuttingPlanProposal Plan(IProgress<CuttingPlanProgress> progress = null,
CancellationToken token = default)
{
var plans = new CuttingPlanPlateResult[entries.Length];
for (var index = 0; index < entries.Length; index++)
{
var entry = entries[index];
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.CheckingOverlap));
PlateOverlapReport overlap;
try
{
overlap = PlateOverlapAnalyzer.Analyze(entry.Overlap, token);
}
catch (OperationCanceledException)
{
plans[index] = new(entry.Plate, entry.Number, new(CuttingPlanStatus.Canceled), null, null);
continue;
}
CuttingPlanResult reorder = null;
if (entry.Reorder != null)
{
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
reorder = PlanReorder(entry.Reorder, token);
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
{
plans[index] = new(entry.Plate, entry.Number, WithFallback(entry, reorder, token), null, overlap);
continue;
}
}
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
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 raises the head
/// fewer times, then has less rapid travel. A route whose planner-chosen motions pass every
/// check head-down 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 && candidate.HeadRaises == 0)
return (candidate, freeOrder);
if (best is not { } known || Safer(candidate, known.Result))
best = (candidate, freeOrder);
}
}
catch (OperationCanceledException)
{
return (new(CuttingPlanStatus.Canceled), true);
}
return best;
}
// Fewer broken checks (a route that breaks none first), then fewer head raises, then less travel.
private static bool Safer(CuttingPlanResult candidate, CuttingPlanResult known)
{
var severity = (candidate.SafestRoute ?? default).CompareTo(known.SafestRoute ?? default);
if (severity != 0)
return severity < 0;
if (candidate.HeadRaises != known.HeadRaises)
return candidate.HeadRaises < known.HeadRaises;
return candidate.RapidDistance < known.RapidDistance;
}
internal static CuttingPlanResult PlanReorder(CuttingPlanSnapshot snapshot, CancellationToken token,
int firstPassBudget = FirstPassBudget)
{
// A failed first pass is not a proof of impossibility: retain all original
// full-cap alternatives and the complete original budget for the retry.
if (snapshot.Failure == null && snapshot.Regeneration && snapshot.MaxEntries > FirstPassEntries
&& snapshot.ExpansionBudget > firstPassBudget)
{
var first = CuttingPlanService.Plan(snapshot.WithSearchLimits(firstPassBudget, FirstPassEntries), token);
// A replayed proposal whose only warnings are fixed emissions passed every strict
// check; fixed emissions never steer the search, so the full pass cannot improve it.
if (first.IndependentlyReplayed && first.Status is CuttingPlanStatus.Ready or CuttingPlanStatus.BestEffort
|| first.Status == CuttingPlanStatus.Canceled)
return first;
}
return CuttingPlanService.Plan(snapshot, token);
}
private static CuttingPlanResult WithFallback(Entry entry, CuttingPlanResult strict, CancellationToken token)
{
if (strict.Status != CuttingPlanStatus.UnsupportedGeometry || entry.Fallback.Failure != null)
return strict;
var fallback = BestEffortCuttingPlan.Plan(entry.Fallback, token);
if (fallback.Status != CuttingPlanStatus.BestEffort)
return fallback.Status == CuttingPlanStatus.Canceled ? fallback : strict;
return new(CuttingPlanStatus.BestEffort, fallback.ProposedOrder,
strict.Findings.Concat(fallback.Findings).Distinct(), rapidDistance: fallback.RapidDistance)
{ Snapshot = entry.Fallback };
}
private sealed record Entry(Plate Plate, int Number, CuttingPlanSnapshot Reorder, CuttingPlanSnapshot KeepOrder,
PlateOverlapSnapshot Overlap, CuttingPlanSnapshot Fallback);
}
/// <summary>One plate's outcome inside a <see cref="CuttingPlanProposal"/>.</summary>
public sealed class CuttingPlanPlateResult
{
internal CuttingPlanPlateResult(Plate plate, int plateNumber, CuttingPlanResult result,
CuttingPlanResult reorderAttempt, PlateOverlapReport overlap)
{
Plate = plate;
PlateNumber = plateNumber;
Result = result;
ReorderAttempt = reorderAttempt;
Overlap = overlap;
}
/// <summary>The live plate. Read it only on the thread that owns it.</summary>
public Plate Plate { get; }
public int PlateNumber { get; }
/// <summary>The result that would be applied.</summary>
public CuttingPlanResult Result { get; }
/// <summary>The free-order attempt that ran out of budget before the current order was kept; else null.</summary>
public CuttingPlanResult ReorderAttempt { get; }
/// <summary>The clean-material overlap check (part ids are current part positions); null if canceled first.</summary>
public PlateOverlapReport Overlap { get; }
public bool KeptCurrentOrder => ReorderAttempt != null;
/// <summary>The route was replayed and the overlap check completed without overlaps.</summary>
public bool IsRouteReady => Result.Status == CuttingPlanStatus.Ready && Result.IndependentlyReplayed;
public bool IsOverlapClear => Overlap is { IsComplete: true } && Overlap.Pairs.Count == 0;
public bool IsReady => IsRouteReady && IsOverlapClear;
/// <summary>Owned readable output is available, but not all geometric checks passed.</summary>
public bool CanApplyWithWarnings => Overlap != null && Overlap.Pairs.Count == 0
&& (IsRouteReady || Result.Status == CuttingPlanStatus.BestEffort);
/// <summary>
/// Refused after a search ran out of its expansion budget: this plan, or the free-order attempt
/// before the current order was kept. A higher planning effort searches further, without a promise.
/// </summary>
public bool SearchLimitReached => !CanApplyWithWarnings && !IsRouteReady
&& (Result.Status == CuttingPlanStatus.NoSolutionWithinBudget || KeptCurrentOrder
&& Result.Status is not (CuttingPlanStatus.Canceled or CuttingPlanStatus.UnsupportedGeometry
or CuttingPlanStatus.InvalidInput));
/// <summary>
/// The route was replayed and passed every strict check; only fixed emissions (locked or
/// pinned leads) carry warnings, so the proposal is UNVERIFIED and needs acceptance.
/// </summary>
public bool HasOnlyFixedLeadWarnings => Result.Status == CuttingPlanStatus.BestEffort
&& Result.IndependentlyReplayed && IsOverlapClear;
public int PartCount => Result.ProposedOrder.Count;
public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
/// <summary>Head-locked far-edge cutoff segments the plan cuts the other way.</summary>
public int ReversedCutOffCount => Result.ProposedOrder.Count(p => p.ReversesCutOff);
public bool OrderChanged =>
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
}
/// <summary>A diagnostic tied to captured plate and source part positions, not display text.</summary>
public sealed record CuttingPlanDiagnosticRow(int PlateIndex, int PlateNumber, int? PartNumber,
int? OtherPartNumber, string Message)
{
/// <summary>World-coordinate markers for the problem (overlap area, rapid, contacts); null when none.</summary>
public PostVerificationFinding Witness { get; init; }
}
/// <summary>
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
/// </summary>
public sealed class CuttingPlanProposal
{
private const int FindingsPerPlate = 5;
private readonly CuttingParameters ownedParameters;
internal CuttingPlanProposal(IReadOnlyList<CuttingPlanPlateResult> plates, CuttingParameters ownedParameters)
{
Plates = plates;
this.ownedParameters = ownedParameters;
}
public IReadOnlyList<CuttingPlanPlateResult> Plates { get; }
public bool IsCanceled => Plates.Any(p => p.Result.Status == CuttingPlanStatus.Canceled);
public bool CanApply => Plates.Count > 0 && !IsCanceled && ownedParameters != null
&& Plates.All(p => p.IsReady);
/// <summary>All plates have usable output, possibly requiring explicit warning acceptance.</summary>
public bool CanApplyWithWarnings => Plates.Count > 0 && !IsCanceled && ownedParameters != null
&& Plates.All(p => p.CanApplyWithWarnings);
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
/// <summary>All findings, including ones hidden by a blocked plate's abbreviated summary.</summary>
public IReadOnlyList<CuttingPlanDiagnosticRow> DiagnosticRows()
{
var rows = new List<CuttingPlanDiagnosticRow>();
for (var index = 0; index < Plates.Count; index++)
{
var plate = Plates[index];
if (plate.Overlap != null)
{
foreach (var pair in plate.Overlap.Pairs)
{
var message = $"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part "
+ $"{pair.PartBId + 1}{Name(pair.PartBName)}.";
rows.Add(new(index, plate.PlateNumber, pair.PartAId + 1, pair.PartBId + 1, message)
{
Witness = new(PostVerificationKind.Overlap, plate.PlateNumber, pair.PartAId + 1,
pair.PartBId + 1, message)
{ Location = pair.Marker, Overlap = pair }
});
}
foreach (var issue in plate.Overlap.Issues)
rows.Add(new(index, plate.PlateNumber, issue.PartAId + 1,
issue.PartBId is int other ? other + 1 : null,
$"Overlap check incomplete for part {issue.PartAId + 1}"
+ (issue.PartBId is int second ? $" and part {second + 1}" : string.Empty)
+ $": {issue.Message}"));
}
foreach (var finding in plate.Result.Findings)
rows.Add(new(index, plate.PlateNumber,
finding.SourceOrdinal is int part ? part + 1 : null,
finding.OtherSourceOrdinal is int other ? other + 1 : null,
DescribeFinding(finding))
{ Witness = finding.Witness });
}
return rows;
}
/// <summary>
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
/// of the confirmed parameters as its cutting settings; any other status changes nothing.
/// </summary>
public CuttingCommitResult Apply(CancellationToken token = default) => Apply(false, token);
/// <summary>Explicit per-proposal acceptance; never grants consent to post CNC output.</summary>
public CuttingCommitResult Apply(bool acceptWarnings, CancellationToken token = default)
{
if (!(CanApply || acceptWarnings && CanApplyWithWarnings))
return new(CuttingCommitStatus.InvalidInput,
"Every plate must have a ready plan before anything is applied.");
var commit = CuttingPlanService.Apply(Plates.Select(p => p.Result), token, null, acceptWarnings);
if (commit.Status == CuttingCommitStatus.Applied)
foreach (var plate in Plates)
plate.Plate.CuttingParameters = OwnedCuttingParameters.Copy(ownedParameters);
return commit;
}
/// <summary>
/// A detached copy of a ready or explicitly unverified proposal for display, or null.
/// Refused graphs are never cloned. Changed plates are not previewed at uncaptured poses.
/// The copy has quantity zero, so drawing quantities stay put.
/// Call it on the thread that owns the plates.
/// </summary>
public Plate BuildPreview(int index)
{
var planned = Plates[index];
if (!planned.CanApplyWithWarnings || planned.Result.Snapshot?.PlateState?.IsCurrent() != true)
return null;
// Unchanged since capture: graphs are supported and the preview uses the exact proposal.
var source = planned.Plate;
var preview = new Plate(source.Size)
{
Quantity = 0,
Quadrant = source.Quadrant,
PartSpacing = source.PartSpacing,
EdgeSpacing = source.EdgeSpacing,
// View > Draw Support shows the proposal on the same bed.
SupportBed = source.SupportBed,
};
// Machine clamps stay visible while inspecting the proposal; zones are immutable.
foreach (var zone in source.ClampZones)
preview.ClampZones.Add(zone);
foreach (var proposal in planned.Result.ProposedOrder)
{
var part = (Part)proposal.SourcePart.Clone();
part.RestoreLeadInProgram(proposal.CopyProgram(), proposal.LeadInsLocked);
preview.Parts.Add(part);
}
return preview;
}
/// <summary>Operator-facing summary: one overall line, then each plate and its findings.</summary>
/// <param name="changeSettings">How this front end changes the settings, completed by
/// "a lead-in type other than None..." in the missing lead-in advice.</param>
public IReadOnlyList<string> Describe(string unit, string changeSettings = "Open Cutting Settings... and select")
{
var lines = new List<string>();
var blocked = Plates.Count(p => !p.CanApplyWithWarnings);
if (IsCanceled)
lines.Add("Planning was canceled. Nothing has changed.");
else if (CanApply)
lines.Add($"Ready to apply to {Count(Plates.Count, "plate")}: "
+ $"{Count(Plates.Count(p => p.OrderChanged), "plate")} with a new part order, "
+ $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
else if (RequiresWarningAcceptance)
lines.Add((Plates.All(p => p.IsReady || p.HasOnlyFixedLeadWarnings)
? "Unverified plan available: " + Unverified(Plates.Where(p => !p.IsReady)).Headline + " "
: 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
lines.Add($"Apply is unavailable: {blocked} of {Count(Plates.Count, "plate")} could not "
+ "be planned. No plate changes until every plate is ready.");
foreach (var plate in Plates)
{
lines.Add(string.Empty);
lines.AddRange(DescribePlate(plate, unit, changeSettings));
}
return lines;
}
// What an unverified plan whose motions all passed asks the operator to review: its fixed
// lead-ins, parts its support bed cannot hold level, or both.
private static (string Headline, string Plate) Unverified(IEnumerable<CuttingPlanPlateResult> plates)
{
var kinds = plates.SelectMany(p => p.Result.Findings).Select(f => f.Kind).Where(k => k != null).ToHashSet();
return kinds.Count == 1 && kinds.Contains(PostVerificationKind.PartSupport)
? ("a part may tip once cut free.", "a part may tip once cut free.")
: kinds.Contains(PostVerificationKind.PartSupport)
? ("fixed lead-ins could not be certified, and a part may tip once cut free.",
"fixed lead-ins need review, and a part may tip once cut free.")
: ("fixed lead-ins could not be certified.", "fixed lead-ins need review.");
}
private static IEnumerable<string> DescribePlate(CuttingPlanPlateResult plate, string unit, string changeSettings)
{
var heading = $"Plate {plate.PlateNumber}: ";
var result = plate.Result;
if (plate.IsReady && plate.PartCount == 0)
{
yield return heading + "no parts.";
yield break;
}
if (plate.IsReady)
{
var kept = plate.PartCount - plate.RegeneratedCount;
var order = plate.KeptCurrentOrder
? "No new part order was found within the search limit, so the current order is kept."
: plate.OrderChanged ? "The part order changes." : "The part order is unchanged.";
yield return heading + $"ready. {Count(plate.PartCount, "part")}, {plate.RegeneratedCount} "
+ $"regenerated, {kept} kept as is; rapid travel "
+ $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}"
+ (plate.ReversedCutOffCount == 0 ? ""
: $" {Count(plate.ReversedCutOffCount, "cutoff segment")} at the far edge change direction.")
+ (result.HeadRaises == 0 ? ""
: $" No route found keeps the head down over every cut part, so {Count(result.HeadRaises, "rapid")} "
+ $"{(result.HeadRaises == 1 ? "raises" : "raise")} the head; each is listed below.");
// A ready plan's findings are advisory notes (head raises, support tabs that may not hold a part level).
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
yield return line;
yield break;
}
if (plate.CanApplyWithWarnings)
{
yield return heading + (plate.HasOnlyFixedLeadWarnings
? $"unverified: {Unverified([plate]).Plate} {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
// bounded diagnostic summary that hides later parts behind "... and N more".
foreach (var line in DescribeOverlap(plate.Overlap).Concat(result.Findings.Select(DescribeFinding)))
yield return " - " + line;
yield break;
}
var missingLead = !plate.IsRouteReady && result.Status != CuttingPlanStatus.Canceled
&& result.Findings.Any(f => f.Kind == PostVerificationKind.MissingLeadIn);
yield return heading + (plate.IsRouteReady
? "blocked: parts overlap or could not be checked for overlap."
: missingLead ? "blocked: missing or zero-length lead-in." : StatusText(result.Status));
foreach (var line in Limit(DescribeOverlap(plate.Overlap)))
yield return line;
if (plate.IsRouteReady)
yield break;
if (missingLead)
{
yield return $" {changeSettings} a lead-in type other than None with "
+ "nonzero length for the affected contour (External, Internal, or Arc / Circle), then replan.";
yield return " If the affected part is locked, edit its lead-ins or unlock it before replanning.";
}
else if (plate.KeptCurrentOrder)
yield return " No new part order was found within the search limit, and planning with the "
+ "current order was refused:";
foreach (var line in Limit(result.Findings.Select(DescribeFinding)))
yield return line;
if (!missingLead && plate.SearchLimitReached)
yield return " " + SearchLimitHint;
}
/// <summary>Hint for a plate refused at the search limit (console and MCP; the desktop panel names its effort setting).</summary>
public const string SearchLimitHint = "The planner stopped at its search limit; a longer search might help, "
+ "but it may still find no plan.";
private static IEnumerable<string> Limit(IEnumerable<string> lines)
{
var all = lines.ToList();
foreach (var line in all.Take(FindingsPerPlate))
yield return " - " + line;
if (all.Count > FindingsPerPlate)
yield return $" ... and {all.Count - FindingsPerPlate} more.";
}
private static IEnumerable<string> DescribeOverlap(PlateOverlapReport overlap)
{
if (overlap == null)
yield break;
foreach (var pair in overlap.Pairs)
yield return $"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part "
+ $"{pair.PartBId + 1}{Name(pair.PartBName)}.";
foreach (var issue in overlap.Issues)
yield return $"Overlap check incomplete for part {issue.PartAId + 1}"
+ (issue.PartBId is int other ? $" and part {other + 1}" : string.Empty) + $": {issue.Message}";
}
private static string StatusText(CuttingPlanStatus status) => status switch
{
CuttingPlanStatus.Ready => "ready, but the plan was not independently replayed.",
CuttingPlanStatus.ConstraintConflict => "blocked: the fixed programs or required cut order conflict.",
CuttingPlanStatus.UnsupportedGeometry => "blocked: unsupported geometry or an incomplete check.",
CuttingPlanStatus.InvalidInput => "blocked: invalid input.",
CuttingPlanStatus.NoSolutionWithinBudget => "no complete plan was found within the search limit.",
CuttingPlanStatus.Canceled => "canceled.",
_ => status.ToString(),
};
private static string DescribeFinding(CuttingPlanFinding finding)
{
var text = finding.SourceOrdinal is int ordinal
? $"Part {ordinal + 1}{Name(finding.SourcePart)}: {finding.Message}"
: finding.Message;
if (finding.OtherSourceOrdinal is int other)
text += $" (with part {other + 1}{Name(finding.OtherSourcePart)})";
return text;
}
private static string Name(Part part) => Name(part?.BaseDrawing?.Name);
private static string Name(string name) => string.IsNullOrWhiteSpace(name) ? string.Empty : $" ({name})";
private static string Count(int count, string noun) => $"{count} {noun}{(count == 1 ? string.Empty : "s")}";
}