mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 00:54:13 -04:00
The desktop batch searched for a new part order with the flat 20000 default and kept the current order with 400 expansions per part. A new order now also plans contour order and entries for every part (about 260 expansions per part on a dense grid), so a 100-part plate ran out of budget and fell back to the current order. Both attempts now get PlateBudget: 400 per part, at least 20000. The constants are renamed MinimumExpansionBudget and ExpansionsPerPart to match.
372 lines
17 KiB
C#
372 lines
17 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>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;
|
|
|
|
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>
|
|
public static CuttingPlanBatch Capture(IReadOnlyList<Plate> plates, CuttingParameters confirmedParameters,
|
|
bool preservePartOrder, IReadOnlyList<int> plateNumbers = null, CancellationToken token = default) =>
|
|
Capture(plates, confirmedParameters, preservePartOrder, plateNumbers, null, token);
|
|
|
|
/// <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)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(plates);
|
|
ArgumentNullException.ThrowIfNull(confirmedParameters);
|
|
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);
|
|
var keepOrder = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
|
expansionBudget: budget, confirmedParameters: confirmedParameters,
|
|
preservePartOrder: true), token);
|
|
var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
|
expansionBudget: reorderBudget ?? budget, confirmedParameters: confirmedParameters), token);
|
|
var overlap = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
|
|
entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder, overlap);
|
|
}
|
|
return new(entries, owned);
|
|
}
|
|
|
|
internal static int PlateBudget(int partCount) =>
|
|
(int)System.Math.Min(int.MaxValue,
|
|
System.Math.Max((long)MinimumExpansionBudget, (long)partCount * ExpansionsPerPart));
|
|
|
|
/// <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.
|
|
/// </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.Cancelled), null, null);
|
|
continue;
|
|
}
|
|
|
|
CuttingPlanResult reorder = null;
|
|
if (entry.Reorder != null)
|
|
{
|
|
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
|
|
reorder = CuttingPlanService.Plan(entry.Reorder, token);
|
|
if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
|
|
{
|
|
plans[index] = new(entry.Plate, entry.Number, reorder, null, overlap);
|
|
continue;
|
|
}
|
|
}
|
|
progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
|
|
plans[index] = new(entry.Plate, entry.Number, CuttingPlanService.Plan(entry.KeepOrder, token), reorder,
|
|
overlap);
|
|
}
|
|
return new(plans, ownedParameters);
|
|
}
|
|
|
|
private sealed record Entry(Plate Plate, int Number, CuttingPlanSnapshot Reorder, CuttingPlanSnapshot KeepOrder,
|
|
PlateOverlapSnapshot Overlap);
|
|
}
|
|
|
|
/// <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 cancelled 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;
|
|
public int PartCount => Result.ProposedOrder.Count;
|
|
public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
|
|
|
|
public bool OrderChanged =>
|
|
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The outcome of a batch. Apply is all-or-nothing and is offered only when every plate is ready.
|
|
/// </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 IsCancelled => Plates.Any(p => p.Result.Status == CuttingPlanStatus.Cancelled);
|
|
|
|
public bool CanApply => Plates.Count > 0 && !IsCancelled && ownedParameters != null
|
|
&& Plates.All(p => p.IsReady);
|
|
|
|
/// <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)
|
|
{
|
|
if (!CanApply)
|
|
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);
|
|
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 plate for display, in the proposed order with the proposed
|
|
/// programs, or null. A refused plate is not previewed: its program graphs may be unsafe to
|
|
/// copy. Neither is a plate that changed after planning, which would draw replayed programs at
|
|
/// poses that were never checked. 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.IsReady || planned.Result.Snapshot?.PlateState?.IsCurrent() != true)
|
|
return null;
|
|
// Unchanged since capture: live poses, plate and programs are the validated, replayed ones.
|
|
var source = planned.Plate;
|
|
var preview = new Plate(source.Size)
|
|
{
|
|
Quantity = 0,
|
|
Quadrant = source.Quadrant,
|
|
PartSpacing = source.PartSpacing,
|
|
EdgeSpacing = source.EdgeSpacing,
|
|
};
|
|
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>
|
|
public IReadOnlyList<string> Describe(string unit)
|
|
{
|
|
var lines = new List<string>();
|
|
var ready = Plates.Count(p => p.IsReady);
|
|
if (IsCancelled)
|
|
lines.Add("Planning was cancelled. 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
|
|
lines.Add($"Apply is unavailable: {Plates.Count - ready} 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));
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
private static IEnumerable<string> DescribePlate(CuttingPlanPlateResult plate, string unit)
|
|
{
|
|
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}";
|
|
yield break;
|
|
}
|
|
|
|
yield return heading + (plate.IsRouteReady
|
|
? "blocked: parts overlap or could not be checked for overlap."
|
|
: StatusText(result.Status));
|
|
foreach (var line in Limit(DescribeOverlap(plate.Overlap)))
|
|
yield return line;
|
|
if (plate.IsRouteReady)
|
|
yield break;
|
|
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;
|
|
}
|
|
|
|
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.Cancelled => "cancelled.",
|
|
_ => 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")}";
|
|
}
|