mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-16 08:41:37 -04:00
Recovery only tried contiguous runs of clamps, so a part with a notch over a middle clamp had all three clamps removed although the two outer ones were enough (D8: 'the fewest clamps that let it fit'). One probe per clamp now finds the clamps every removal must include (a part that cannot fit with only that clamp left needs it off; removal is monotone), and the search tries every set by size, mandatory clamps plus the optional ones nearest the origin first. Full-sheet parts resolve with one probe per clamp. PM 3df03af2-35a6-49c2-9082-fc6208f5b708
517 lines
24 KiB
C#
517 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
|
|
namespace OpenNest.Engine.Jobs;
|
|
|
|
/// <summary>
|
|
/// D8/D9 last-resort clamp removal for the whole-job pipeline. After a normal clamped solve
|
|
/// leaves demand unmet, finds parts that cannot fit anywhere with every clamp in place (C10),
|
|
/// computes the fewest clamps whose removal lets each one fit (C11), and re-solves the
|
|
/// residual demand on stock variants with those clamps taken off (C12). Never changes the
|
|
/// result when nothing is blocked (C10's bit-identical guarantee) and never trades a removal
|
|
/// for material savings - it only runs after the ordinary solve could not place a part.
|
|
/// </summary>
|
|
internal static class ClampRemovalRecovery
|
|
{
|
|
/// <summary>A result that replaces the caller's raw solve, and the expanded job it was
|
|
/// validated against (the caller's own stock plus the reduced-clamp variants used).</summary>
|
|
internal sealed record Outcome(NestJob Job, NestJobResult Result);
|
|
|
|
/// <summary>
|
|
/// Most engine solves one recovery may spend on feasibility probes, independent of job,
|
|
/// stock and rail size. Running out abandons the recovery and keeps the ordinary result
|
|
/// (no clamp is removed on an unfinished search).
|
|
/// </summary>
|
|
internal const int MaxProbeSolves = 1000;
|
|
|
|
/// <summary>
|
|
/// Null when there is nothing to recover: the job is already complete, no offered stock
|
|
/// has any clamp, no remaining part is genuinely clamp-blocked, or removing clamps did
|
|
/// not let anything new fit. Callers keep their original job/result unchanged in every
|
|
/// one of those cases (C10).
|
|
/// </summary>
|
|
internal static Outcome TryRecover(
|
|
NestJob job,
|
|
NestJobResult raw,
|
|
INestingEngine engine,
|
|
CancellationToken token
|
|
)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(job);
|
|
ArgumentNullException.ThrowIfNull(raw);
|
|
ArgumentNullException.ThrowIfNull(engine);
|
|
|
|
// Recovery is optional: any failure inside it (a throwing probe or residual solve)
|
|
// keeps the caller's ordinary result instead of discarding it. Cancellation still
|
|
// propagates.
|
|
try
|
|
{
|
|
return Recover(job, raw, engine, token);
|
|
}
|
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static Outcome Recover(NestJob job, NestJobResult raw, INestingEngine engine, CancellationToken token)
|
|
{
|
|
if (raw.Status == NestJobStatus.Complete)
|
|
return null;
|
|
|
|
var availableStock = job.Plates.Where(s => s.Quantity != 0).ToList();
|
|
if (!availableStock.Any(CanRemoveClamps))
|
|
return null;
|
|
|
|
var partsById = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
|
var remaining = raw.Fulfillment
|
|
.Where(f => f.Unplaced > 0)
|
|
.Select(f => (Part: partsById[f.PartId], Unplaced: f.Unplaced))
|
|
.ToList();
|
|
if (remaining.Count == 0)
|
|
return null;
|
|
|
|
token.ThrowIfCancellationRequested();
|
|
|
|
// C10: only a part that fits nowhere with every clamp in place is "blocked". One
|
|
// lane's failure is not proof: a part any built-in lane can place clamped is not.
|
|
var lanes = FeasibilityLanes(engine);
|
|
var budget = new ProbeBudget(MaxProbeSolves);
|
|
var blocked = remaining
|
|
.Where(x => !lanes.Any(lane => FitsAnywhere(x.Part, availableStock, job.Options, lane, budget, token)))
|
|
.Select(x => x.Part)
|
|
.ToList();
|
|
if (blocked.Count == 0)
|
|
return null;
|
|
|
|
// Phase 2 state. Each kept sheet comes out of its base stock's remaining physical
|
|
// pool and the plate cap (C12: a finite pool is never exceeded).
|
|
var unmet = remaining.ToDictionary(x => x.Part.Id, x => x.Unplaced, StringComparer.Ordinal);
|
|
var blockedIds = new HashSet<string>(blocked.Select(p => p.Id), StringComparer.Ordinal);
|
|
var pool = availableStock.ToDictionary(
|
|
s => s.Id,
|
|
s => s.Quantity is int q ? q - raw.StockUsage.Where(u => u.StockId == s.Id).Sum(u => u.Used) : (int?)null,
|
|
StringComparer.Ordinal);
|
|
int? platesLeft = job.Options.MaxPlates is int cap ? cap - raw.Plates.Count : null;
|
|
var tried = blocked.ToDictionary(p => p.Id, _ => new HashSet<string>(StringComparer.Ordinal), StringComparer.Ordinal);
|
|
var recovered = new List<(NestPlateStock Variant, string BaseId, List<NestJobPlateResult> Sheets)>();
|
|
|
|
// Rounds: each blocked part with demand left takes its best removal among the offered
|
|
// stocks it has not tried that still have sheets, so a part whose preferred stock runs
|
|
// out moves on to another stock (with that stock's own minimal removal).
|
|
for (var round = 0; round < availableStock.Count && platesLeft is not <= 0; round++)
|
|
{
|
|
// C11: per blocked part, the fewest clamps (any set, nearest the origin on ties)
|
|
// whose removal frees an empty sheet of one of those stocks.
|
|
// Parts needing the same removal on the same stock share one variant.
|
|
var groups = new List<RemovalGroup>();
|
|
foreach (var part in blocked)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (unmet[part.Id] <= 0)
|
|
continue;
|
|
var usable = availableStock
|
|
.Where(s => pool[s.Id] is not <= 0 && !tried[part.Id].Contains(s.Id))
|
|
.ToList();
|
|
if (usable.Count == 0)
|
|
continue;
|
|
var best = BestRemoval(part, usable, job.Options, lanes, budget, token);
|
|
if (best == null)
|
|
{
|
|
// Unfit on all of them even with every clamp removed: stays unplaced.
|
|
tried[part.Id].UnionWith(usable.Select(s => s.Id));
|
|
continue;
|
|
}
|
|
tried[part.Id].Add(best.BaseStockId);
|
|
var group = groups.FirstOrDefault(g => g.Removal.Stock.Id == best.Stock.Id);
|
|
if (group == null)
|
|
groups.Add(group = new RemovalGroup(best));
|
|
group.BlockedIds.Add(part.Id);
|
|
}
|
|
if (groups.Count == 0)
|
|
break;
|
|
|
|
// One removal variant at a time, fewest removed clamps first: each variant takes
|
|
// its own blocked parts plus any other unmet demand that is not blocked (freed
|
|
// space may take other parts, C12). A sheet is kept only when it holds a part
|
|
// that needed exactly this removal (C10/C11 per sheet).
|
|
foreach (var group in groups.OrderBy(g => g.Removal.RemovedIds.Count))
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var baseId = group.Removal.BaseStockId;
|
|
var available = pool[baseId];
|
|
if (available <= 0 || platesLeft <= 0)
|
|
continue;
|
|
int? sheetCap = (available, platesLeft) switch
|
|
{
|
|
(int a, int p) => System.Math.Min(a, p),
|
|
(int a, null) => a,
|
|
(null, int p) => p,
|
|
_ => null,
|
|
};
|
|
|
|
var parts = remaining
|
|
.Where(x => unmet[x.Part.Id] > 0
|
|
&& (group.BlockedIds.Contains(x.Part.Id) || !blockedIds.Contains(x.Part.Id)))
|
|
.Select(x => new NestJobPart(x.Part.Id, x.Part.Geometry, unmet[x.Part.Id], x.Part.Priority, x.Part.Rotation))
|
|
.ToList();
|
|
if (!parts.Any(p => group.BlockedIds.Contains(p.Id)))
|
|
continue;
|
|
|
|
var variant = Clone(group.Removal.Stock, group.Removal.Stock.Id, sheetCap, null);
|
|
var options = new NestJobOptions(job.Options.PlacementStrategy, platesLeft,
|
|
job.Options.SalvageRate, job.Options.MinimumSalvageDimension);
|
|
var groupJob = new NestJob(parts, new[] { variant }, options);
|
|
|
|
List<NestJobPlateResult> kept = null;
|
|
foreach (var lane in lanes)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var solved = lane.Solve(groupJob, null, token);
|
|
token.ThrowIfCancellationRequested();
|
|
kept = solved?.Plates
|
|
.Where(sheet => sheet.Placements.Any(p => group.BlockedIds.Contains(p.PartId)))
|
|
.Take(sheetCap ?? int.MaxValue)
|
|
.ToList();
|
|
if (kept is { Count: > 0 })
|
|
break;
|
|
}
|
|
if (kept is not { Count: > 0 })
|
|
continue;
|
|
|
|
foreach (var placement in kept.SelectMany(sheet => sheet.Placements))
|
|
unmet[placement.PartId]--;
|
|
if (pool[baseId] is int left)
|
|
pool[baseId] = left - kept.Count;
|
|
if (platesLeft is int plates)
|
|
platesLeft = plates - kept.Count;
|
|
recovered.Add((group.Removal.Stock, baseId, kept));
|
|
}
|
|
}
|
|
if (recovered.Count == 0)
|
|
return null;
|
|
|
|
return Merge(job, raw, recovered);
|
|
}
|
|
|
|
private sealed class RemovalGroup(Removal removal)
|
|
{
|
|
public Removal Removal { get; } = removal;
|
|
public HashSet<string> BlockedIds { get; } = new(StringComparer.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Engines whose placement of a lone part proves it fits (C10): the caller's own engine,
|
|
/// plus the built-in Irregular (NFP, true material) and Rectangles lanes. Default already
|
|
/// runs both, so it is enough on its own. Phase 2 tries them in this order too.
|
|
/// </summary>
|
|
private static IReadOnlyList<INestingEngine> FeasibilityLanes(INestingEngine engine)
|
|
{
|
|
if (engine is NestingEngines.Default.DefaultNestingEngine)
|
|
return new[] { engine };
|
|
var lanes = new List<INestingEngine> { engine };
|
|
if (engine is not NestingEngines.Irregular.IrregularNestingEngine)
|
|
lanes.Add(new NestingEngines.Irregular.IrregularNestingEngine());
|
|
if (engine is not NestingEngines.Rectangles.RectanglesNestingEngine)
|
|
lanes.Add(new NestingEngines.Rectangles.RectanglesNestingEngine());
|
|
return lanes;
|
|
}
|
|
|
|
private sealed record Removal(NestPlateStock Stock, string BaseStockId, IReadOnlyList<string> RemovedIds);
|
|
|
|
/// <summary>
|
|
/// The (stock, removal) with the fewest removed clamps across every offered rail-backed
|
|
/// stock; ties prefer the stock listed earliest, then the clamps nearest the origin. One
|
|
/// all-clamps-removed probe per stock first dismisses stock the part cannot use at all
|
|
/// and picks the lanes worth asking; one probe per clamp then finds the clamps every
|
|
/// removal must include; the set search goes size by size across the remaining stock and
|
|
/// stops at the first size that fits anywhere.
|
|
/// </summary>
|
|
private static Removal BestRemoval(
|
|
NestJobPart part,
|
|
IReadOnlyList<NestPlateStock> stocks,
|
|
NestJobOptions options,
|
|
IReadOnlyList<INestingEngine> lanes,
|
|
ProbeBudget budget,
|
|
CancellationToken token
|
|
)
|
|
{
|
|
var candidates = new List<(NestPlateStock Stock, List<ClampZone> Zones, List<INestingEngine> Lanes)>();
|
|
foreach (var stock in stocks)
|
|
{
|
|
if (!CanRemoveClamps(stock))
|
|
continue;
|
|
token.ThrowIfCancellationRequested();
|
|
// Placement-frame X is signed from the origin, so |centre X| is the distance
|
|
// along the edge in every quadrant.
|
|
var zones = stock.ClampZones
|
|
.OrderBy(z => System.Math.Abs(z.Bounds.X + z.Bounds.Length / 2))
|
|
.ToList();
|
|
var allRemoved = Clone(stock, stock.Id, null, zones.Select(z => z.Id).ToList());
|
|
var fitting = lanes.Where(lane => FitsAlone(part, allRemoved, options, lane, budget, token)).ToList();
|
|
if (fitting.Count > 0)
|
|
candidates.Add((stock, zones, fitting));
|
|
}
|
|
|
|
// Removing more clamps never stops a part fitting, so a clamp the part cannot live
|
|
// with even when it is the only one left must be in every feasible removal.
|
|
var searches = new List<(NestPlateStock Stock, List<ClampZone> Zones, List<INestingEngine> Lanes, List<int> Mandatory)>();
|
|
foreach (var (stock, zones, fitting) in candidates)
|
|
{
|
|
var mandatory = new List<int>();
|
|
for (var i = 0; i < zones.Count; i++)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var onlyThis = Clone(stock, stock.Id, null, zones.Where((_, j) => j != i).Select(z => z.Id).ToList());
|
|
if (zones.Count == 1 || !fitting.Any(lane => FitsAlone(part, onlyThis, options, lane, budget, token)))
|
|
mandatory.Add(i);
|
|
}
|
|
searches.Add((stock, zones, fitting, mandatory));
|
|
}
|
|
|
|
// Then the fewest clamps overall (any set, not only neighbours: a notch can leave a
|
|
// middle clamp in place), size by size across stocks in offered order; within a stock
|
|
// the optional additions nearest the origin first.
|
|
var maxSize = searches.Count == 0 ? 0 : searches.Max(c => c.Zones.Count);
|
|
for (var size = 1; size <= maxSize; size++)
|
|
{
|
|
foreach (var (stock, zones, fitting, mandatory) in searches)
|
|
{
|
|
if (mandatory.Count > size || size > zones.Count)
|
|
continue;
|
|
var optional = Enumerable.Range(0, zones.Count).Where(i => !mandatory.Contains(i)).ToList();
|
|
foreach (var added in Combinations(optional, size - mandatory.Count))
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var removedIds = mandatory.Concat(added).OrderBy(i => i).Select(i => zones[i].Id).ToList();
|
|
var candidate = Clone(stock, Suffixed(stock.Id, removedIds), null, removedIds);
|
|
// Removing every clamp was already probed.
|
|
if (size == zones.Count || fitting.Any(lane => FitsAlone(part, candidate, options, lane, budget, token)))
|
|
return new Removal(candidate, stock.Id, removedIds);
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Every <paramref name="count"/>-element subset of <paramref name="items"/>, lazily,
|
|
/// in lexicographic order of positions.</summary>
|
|
private static IEnumerable<List<int>> Combinations(IReadOnlyList<int> items, int count)
|
|
{
|
|
if (count < 0 || count > items.Count)
|
|
yield break;
|
|
var positions = Enumerable.Range(0, count).ToArray();
|
|
while (true)
|
|
{
|
|
yield return positions.Select(p => items[p]).ToList();
|
|
var k = count - 1;
|
|
while (k >= 0 && positions[k] == items.Count - count + k)
|
|
k--;
|
|
if (k < 0)
|
|
yield break;
|
|
positions[k]++;
|
|
for (var j = k + 1; j < count; j++)
|
|
positions[j] = positions[j - 1] + 1;
|
|
}
|
|
}
|
|
|
|
/// <summary>Only clamps resolved from a machine rail can be removed (see
|
|
/// <see cref="NestPlateStock.ClampRail"/>): explicit zones have no clamp to name.</summary>
|
|
private static bool CanRemoveClamps(NestPlateStock stock) =>
|
|
stock.ClampRail != null && stock.ClampZones.Count > 0;
|
|
|
|
private static bool FitsAlone(
|
|
NestJobPart part,
|
|
NestPlateStock stock,
|
|
NestJobOptions options,
|
|
INestingEngine engine,
|
|
ProbeBudget budget,
|
|
CancellationToken token
|
|
) => Probe(part, new[] { stock }, options, engine, budget, token);
|
|
|
|
private static bool FitsAnywhere(
|
|
NestJobPart part,
|
|
IReadOnlyList<NestPlateStock> stocks,
|
|
NestJobOptions options,
|
|
INestingEngine engine,
|
|
ProbeBudget budget,
|
|
CancellationToken token
|
|
) => Probe(part, stocks, options, engine, budget, token);
|
|
|
|
/// <summary>Whether this part, alone and at quantity one, places on an empty sheet of any
|
|
/// of the given stock entries with the given engine. A question already answered for the
|
|
/// same part, engine and physically identical stock is reused; otherwise it spends one
|
|
/// probe from the budget.</summary>
|
|
private static bool Probe(
|
|
NestJobPart part,
|
|
IReadOnlyList<NestPlateStock> stocks,
|
|
NestJobOptions options,
|
|
INestingEngine engine,
|
|
ProbeBudget budget,
|
|
CancellationToken token
|
|
)
|
|
{
|
|
var key = (engine, part.Id, string.Join(";", stocks.Select(Signature).Distinct(StringComparer.Ordinal)));
|
|
if (budget.Answers.TryGetValue(key, out var known))
|
|
return known;
|
|
budget.Spend();
|
|
var probePart = new NestJobPart(part.Id, part.Geometry, 1, part.Priority, part.Rotation);
|
|
var probeStock = stocks.Select(s => Unlimited(s)).ToList();
|
|
var probeOptions = new NestJobOptions(options.PlacementStrategy, 1, 0, 0);
|
|
var probeJob = new NestJob(new[] { probePart }, probeStock, probeOptions);
|
|
token.ThrowIfCancellationRequested();
|
|
var result = engine.Solve(probeJob, null, token);
|
|
var fits = result != null && result.Status == NestJobStatus.Complete;
|
|
budget.Answers[key] = fits;
|
|
return fits;
|
|
}
|
|
|
|
/// <summary>Everything about a stock entry that can change whether a lone part fits:
|
|
/// sheet size, quadrant, spacings and the exact clamp zones (not its id, cost or
|
|
/// quantity).</summary>
|
|
private static string Signature(NestPlateStock stock)
|
|
{
|
|
var e = stock.EdgeSpacing;
|
|
var zones = string.Join(",", stock.SheetClampZones.Select(z =>
|
|
FormattableString.Invariant($"{z.Id}:{z.Bounds.X:R}/{z.Bounds.Y:R}/{z.Bounds.Length:R}/{z.Bounds.Width:R}")));
|
|
return FormattableString.Invariant(
|
|
$"{stock.Size.Width:R}x{stock.Size.Length:R}|q{stock.Quadrant}|{stock.PartSpacing:R}|{e.Left:R},{e.Bottom:R},{e.Right:R},{e.Top:R}|{zones}");
|
|
}
|
|
|
|
private sealed class ProbeBudget(int solves)
|
|
{
|
|
private int left = solves;
|
|
|
|
/// <summary>Answers already paid for, by (engine, part, stock signatures).</summary>
|
|
public Dictionary<(INestingEngine, string, string), bool> Answers { get; } = new();
|
|
|
|
/// <summary>Throws <see cref="ProbeBudgetExhaustedException"/> once spent; recovery
|
|
/// then keeps the ordinary result.</summary>
|
|
public void Spend()
|
|
{
|
|
if (left-- <= 0)
|
|
throw new ProbeBudgetExhaustedException();
|
|
}
|
|
}
|
|
|
|
private sealed class ProbeBudgetExhaustedException()
|
|
: Exception("Clamp removal recovery ran out of feasibility probes.");
|
|
|
|
/// <summary>Same stock with unlimited quantity (feasibility probes never consume real
|
|
/// inventory); id/zones unchanged.</summary>
|
|
private static NestPlateStock Unlimited(NestPlateStock stock) =>
|
|
stock.Quantity == null ? stock : Clone(stock, stock.Id, null, null);
|
|
|
|
/// <summary>
|
|
/// A copy of <paramref name="stock"/> with a new id/quantity and, when
|
|
/// <paramref name="removedIds"/> is given, those clamp zones dropped. Built from the
|
|
/// stock's own sheet-frame zones, so the kept zones are bit-identical to the original's.
|
|
/// </summary>
|
|
private static NestPlateStock Clone(
|
|
NestPlateStock stock,
|
|
string id,
|
|
int? quantity,
|
|
IReadOnlyCollection<string> removedIds
|
|
)
|
|
{
|
|
var sheetZones = stock.SheetClampZones
|
|
.Where(z => removedIds == null || !removedIds.Contains(z.Id))
|
|
.ToList();
|
|
return new NestPlateStock(
|
|
id,
|
|
stock.Size,
|
|
quantity,
|
|
stock.PartSpacing,
|
|
stock.EdgeSpacing,
|
|
stock.Quadrant,
|
|
stock.Cost,
|
|
sheetZones
|
|
)
|
|
{ ClampRail = stock.ClampRail };
|
|
}
|
|
|
|
private static string Suffixed(string stockId, IReadOnlyList<string> removedIds) =>
|
|
$"{stockId}::removed:{string.Join(",", removedIds)}";
|
|
|
|
/// <summary>
|
|
/// Combines the ordinary solve with the kept recovery sheets into one result, and the
|
|
/// expanded job it validates against: the caller's stock with each finite base reduced
|
|
/// by the sheets its variants took, plus each variant with exactly the sheets it used
|
|
/// (unlimited when its base is), so the expanded inventory equals the physical one.
|
|
/// Recovery placements get fresh instance indices continuing on from phase 1's.
|
|
/// </summary>
|
|
private static Outcome Merge(
|
|
NestJob job,
|
|
NestJobResult raw,
|
|
IReadOnlyList<(NestPlateStock Variant, string BaseId, List<NestJobPlateResult> Sheets)> recovered
|
|
)
|
|
{
|
|
// A variant used in more than one round is one stock entry with all its sheets.
|
|
recovered = recovered
|
|
.GroupBy(r => r.Variant.Id, StringComparer.Ordinal)
|
|
.Select(g => (g.First().Variant, g.First().BaseId, g.SelectMany(r => r.Sheets).ToList()))
|
|
.ToList();
|
|
var placedSoFar = raw.Fulfillment.ToDictionary(f => f.PartId, f => f.Placed, StringComparer.Ordinal);
|
|
var nextPlateIndex = raw.Plates.Count;
|
|
var reindexed = new List<NestJobPlateResult>();
|
|
var variantStock = new List<NestPlateStock>();
|
|
var takenFromBase = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
foreach (var (variant, baseId, sheets) in recovered)
|
|
{
|
|
var baseStock = job.Plates.First(s => s.Id == baseId);
|
|
var used = Clone(variant, variant.Id, baseStock.Quantity == null ? null : sheets.Count, null);
|
|
variantStock.Add(used);
|
|
takenFromBase[baseStock.Id] = takenFromBase.GetValueOrDefault(baseStock.Id) + sheets.Count;
|
|
foreach (var sheet in sheets)
|
|
{
|
|
var placements = new List<NestJobPlacement>(sheet.Placements.Count);
|
|
foreach (var pose in sheet.Placements)
|
|
{
|
|
var index = placedSoFar.GetValueOrDefault(pose.PartId);
|
|
placedSoFar[pose.PartId] = index + 1;
|
|
placements.Add(pose with { InstanceIndex = index });
|
|
}
|
|
reindexed.Add(new NestJobPlateResult(nextPlateIndex++, used, placements));
|
|
}
|
|
}
|
|
|
|
var allPlates = raw.Plates.Concat(reindexed).ToList();
|
|
var fulfillment = job.Parts
|
|
.Select(p =>
|
|
{
|
|
var placed = placedSoFar.GetValueOrDefault(p.Id);
|
|
return new PartFulfillment(p.Id, p.Quantity, placed, p.Quantity - placed);
|
|
})
|
|
.ToList();
|
|
|
|
var used1 = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
foreach (var u in raw.StockUsage)
|
|
used1[u.StockId] = used1.GetValueOrDefault(u.StockId) + u.Used;
|
|
|
|
var expandedStock = job.Plates
|
|
.Select(s => s.Quantity is int q && takenFromBase.TryGetValue(s.Id, out var taken)
|
|
? Clone(s, s.Id, q - taken, null)
|
|
: s)
|
|
.Concat(variantStock)
|
|
.ToList();
|
|
var stockUsage = expandedStock
|
|
.Select(s =>
|
|
{
|
|
var usedCount = used1.GetValueOrDefault(s.Id) + reindexed.Count(p => p.StockId == s.Id);
|
|
return new StockUsage(s.Id, usedCount, s.Quantity is int q ? q - usedCount : null);
|
|
})
|
|
.ToList();
|
|
|
|
var complete = fulfillment.All(f => f.Unplaced == 0);
|
|
var status = complete ? NestJobStatus.Complete : raw.Status;
|
|
var stopReason = complete ? NestJobStopReason.Completed : raw.StopReason;
|
|
|
|
var expandedJob = new NestJob(job.Parts, expandedStock, job.Options);
|
|
var result = new NestJobResult(status, stopReason, allPlates, fulfillment, stockUsage);
|
|
return new Outcome(expandedJob, result);
|
|
}
|
|
}
|