feat(engine): select from mixed plate inventory

This commit is contained in:
aj
2026-09-18 02:09:06 -04:00
parent 67f5fb8eca
commit 75c8adc76c
5 changed files with 228 additions and 45 deletions
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest;
/// <summary>Ranks independent plate trials: priority fulfillment, sheet area, placement envelope, then input order.</summary>
public sealed class NestJobCandidateComparer
{
private readonly IReadOnlyList<NestJobPart> parts;
public NestJobCandidateComparer(IReadOnlyList<NestJobPart> parts)
{
this.parts = parts ?? throw new ArgumentNullException(nameof(parts));
}
/// <summary>Returns positive when the left trial is preferred.</summary>
public int Compare(PlateCandidate left, NestPlateStock leftStock, int leftIndex,
PlateCandidate right, NestPlateStock rightStock, int rightIndex)
{
var priorities = parts.Select(part => part.Priority).Distinct().OrderBy(priority => priority);
foreach (var priority in priorities)
{
var leftCount = Count(left, priority);
var rightCount = Count(right, priority);
if (leftCount != rightCount) return leftCount.CompareTo(rightCount);
}
var area = Area(rightStock).CompareTo(Area(leftStock));
if (area != 0) return area;
var envelope = Envelope(right).CompareTo(Envelope(left));
if (envelope != 0) return envelope;
return rightIndex.CompareTo(leftIndex);
}
private int Count(PlateCandidate candidate, int priority) => candidate.Placements.Count(placement =>
parts.First(part => part.Id == placement.PartId).Priority == priority);
private static double Area(NestPlateStock stock) => stock.Size.Width * stock.Size.Length;
private static double Envelope(PlateCandidate candidate)
{
if (candidate.Placements.Count == 0) return 0;
var xs = candidate.Placements.Select(placement => placement.X);
var ys = candidate.Placements.Select(placement => placement.Y);
return (xs.Max() - xs.Min()) * (ys.Max() - ys.Min());
}
}
+56 -40
View File
@@ -6,8 +6,8 @@ using System.Threading;
namespace OpenNest;
/// <summary>
/// Single-stock physical-sheet allocation. Candidate accounting is validated before commit;
/// full geometry, clearance, and rotation-policy validation is not implemented yet.
/// Physical-sheet allocation. Every available stock entry is tried independently and only the selected
/// candidate changes demand or inventory accounting.
/// </summary>
public sealed class NestJobRunner : INestingEngine
{
@@ -27,56 +27,72 @@ public sealed class NestJobRunner : INestingEngine
token.ThrowIfCancellationRequested();
NestJobValidator.Validate(job);
var plates = new List<NestJobPlateResult>();
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
var placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal);
var remaining = job.Parts.ToDictionary(part => part.Id, part => part.Quantity, StringComparer.Ordinal);
var placed = job.Parts.ToDictionary(part => part.Id, _ => 0, StringComparer.Ordinal);
var used = job.Plates.ToDictionary(stock => stock.Id, _ => 0, StringComparer.Ordinal);
var comparer = new NestJobCandidateComparer(job.Parts);
var nester = job.Parts.Count == 0 ? null : plateNesterFactory(job.Options.PlacementStrategy) ??
throw new NotSupportedException($"Unknown placement strategy: {job.Options.PlacementStrategy}.");
var reason = NestJobStopReason.Completed;
if (job.Parts.Count != 0)
while (remaining.Values.Any(count => count > 0))
{
var nester = plateNesterFactory(job.Options.PlacementStrategy) ??
throw new NotSupportedException($"Unknown placement strategy: {job.Options.PlacementStrategy}.");
var stock = job.Plates.SingleOrDefault();
while (remaining.Values.Any(count => count > 0))
token.ThrowIfCancellationRequested();
if (job.Options.MaxPlates <= plates.Count)
{
token.ThrowIfCancellationRequested();
if (stock == null || stock.Quantity <= plates.Count)
{
reason = NestJobStopReason.StockExhausted;
break;
}
if (job.Options.MaxPlates <= plates.Count)
{
reason = NestJobStopReason.PlateLimitReached;
break;
}
var request = new PlatePlacementRequest(stock, job.Parts.Where(p => remaining[p.Id] > 0)
.Select(p => new NestJobPart(p.Id, p.Geometry, remaining[p.Id], p.Priority, p.Rotation)));
reason = NestJobStopReason.PlateLimitReached;
break;
}
CandidateTrial winner = null;
var hasAvailableStock = false;
for (var index = 0; index < job.Plates.Count; index++)
{
var stock = job.Plates[index];
if (stock.Quantity is int quantity && used[stock.Id] >= quantity) continue;
hasAvailableStock = true;
var request = new PlatePlacementRequest(stock, job.Parts.Where(part => remaining[part.Id] > 0)
.Select(part => new NestJobPart(part.Id, part.Geometry, remaining[part.Id], part.Priority, part.Rotation)));
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id,
plates.Count, plates.Count, placed.Values.Sum()));
token.ThrowIfCancellationRequested();
// Do not forward legacy/candidate progress as committed production.
var candidate = nester.Place(request, token: token);
token.ThrowIfCancellationRequested();
NestJobValidator.ValidateCandidate(candidate, remaining);
if (candidate.Placements.Count == 0)
{
reason = NestJobStopReason.NoPlacementFound;
break;
}
var committed = new List<NestJobPlacement>();
foreach (var pose in candidate.Placements)
{
committed.Add(pose with { InstanceIndex = placed[pose.PartId]++ });
remaining[pose.PartId]--;
}
plates.Add(new NestJobPlateResult(plates.Count, stock, committed));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, stock.Id,
plates.Count - 1, plates.Count, placed.Values.Sum()));
var trial = new CandidateTrial(candidate, stock, index);
if (winner == null || comparer.Compare(trial.Candidate, trial.Stock, trial.StockIndex,
winner.Candidate, winner.Stock, winner.StockIndex) > 0)
winner = trial;
}
if (!hasAvailableStock)
{
reason = NestJobStopReason.StockExhausted;
break;
}
if (winner.Candidate.Placements.Count == 0)
{
reason = NestJobStopReason.NoPlacementFound;
break;
}
var committed = new List<NestJobPlacement>();
foreach (var pose in winner.Candidate.Placements)
{
committed.Add(pose with { InstanceIndex = placed[pose.PartId]++ });
remaining[pose.PartId]--;
}
used[winner.Stock.Id]++;
plates.Add(new NestJobPlateResult(plates.Count, winner.Stock, committed));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, winner.Stock.Id,
plates.Count - 1, plates.Count, placed.Values.Sum()));
}
token.ThrowIfCancellationRequested();
return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete,
reason, plates, job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, placed[p.Id], remaining[p.Id])),
job.Plates.Select(stock => new StockUsage(stock.Id, plates.Count(p => p.StockId == stock.Id),
stock.Quantity - plates.Count(p => p.StockId == stock.Id))));
reason, plates, job.Parts.Select(part => new PartFulfillment(part.Id, part.Quantity, placed[part.Id], remaining[part.Id])),
job.Plates.Select(stock => new StockUsage(stock.Id, used[stock.Id],
stock.Quantity is int quantity ? quantity - used[stock.Id] : null)));
}
private sealed record CandidateTrial(PlateCandidate Candidate, NestPlateStock Stock, int StockIndex);
}
-3
View File
@@ -26,9 +26,6 @@ public static class NestJobValidator
!double.IsFinite(m.CenterX) || !double.IsFinite(m.CenterY)))
throw new ArgumentException($"Geometry must contain finite motions: {part.Id}.", nameof(job));
}
// Empty jobs do not select or consume stock (including multiple unused stock entries).
if (job.Parts.Count != 0 && job.Plates.Count > 1)
throw new NotSupportedException("This slice supports one stock entry only; mixed-stock selection is not implemented.");
}
internal static void ValidateCandidate(PlateCandidate candidate, IReadOnlyDictionary<string, int> remaining)