mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 15:34:14 -04:00
BlockCatalog's PrepareBlocks pre-pass ran a full independent NFP-based pack (PrivatePlateFill.Run) for up to 8 attempts per part type, but that cost never touched the engine's WorkCounter/WorkBudget. For jobs with several part types at double-digit quantities, these uncounted nested solves could burn the entire 5-minute timeout inside the first sheet of the first strategy variant, so Solve() never returned and nothing was ever committed. Charge quantity^2 * outline vertex count against the shared counter before each nested fill, and stop issuing new block proposals once the budget is exhausted, so the existing effort governor actually sees and bounds this cost. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
322 lines
14 KiB
C#
322 lines
14 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using OpenNest.Engine.Jobs;
|
|
|
|
namespace OpenNest.Engine.NestingEngines.Irregular;
|
|
|
|
/// <summary>
|
|
/// Frontier-advance NFP packer with look-ahead stock selection.
|
|
///
|
|
/// Per sheet, <see cref="FrontierPacker"/> keeps the exact free region of every
|
|
/// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places
|
|
/// either the largest part that fills a gap behind the packing front, or the part that advances
|
|
/// the front least per unit of area covered. Across sheets, every available stock size is
|
|
/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the
|
|
/// remaining demand at the best efficiency seen) is committed. A handful of deterministic
|
|
/// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins.
|
|
///
|
|
/// Fully deterministic: no clocks or randomness influence any decision.
|
|
/// </summary>
|
|
public sealed class IrregularNestingEngine : INestingEngine
|
|
{
|
|
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
|
|
private static readonly (PackAxis Axis, double Beta)[] Variants =
|
|
{
|
|
(PackAxis.X, 1.0),
|
|
(PackAxis.Y, 1.0),
|
|
(PackAxis.X, 0.5),
|
|
(PackAxis.Y, 0.5),
|
|
(PackAxis.X, 1.5),
|
|
(PackAxis.Y, 1.5),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Deterministic work budget, in free-region subtractions, after which no further variant
|
|
/// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock.
|
|
/// </summary>
|
|
internal long WorkBudget { get; init; } = 1_500_000;
|
|
|
|
public NestJobResult Solve(
|
|
NestJob job,
|
|
IProgress<NestJobProgress>? progress = null,
|
|
CancellationToken token = default
|
|
)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(job);
|
|
token.ThrowIfCancellationRequested();
|
|
var types = PartCatalog.Build(job);
|
|
using var solver = new Solver(job, types, progress, token, WorkBudget);
|
|
|
|
// Pair-only orientations may fit stock even when the sampled single poses do not.
|
|
// Demand that neither a single nor a pair can fit is reported unplaced.
|
|
var demand = new int[types.Count];
|
|
foreach (var type in types)
|
|
{
|
|
var placeable = job.Plates.Any(stock =>
|
|
stock.Quantity != 0
|
|
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
|
|
);
|
|
demand[type.Index] = placeable || solver.PairFits(type)
|
|
|| (type.Part.Quantity > 2 && type.Orientations.Count > 0) ? type.Part.Quantity : 0;
|
|
}
|
|
|
|
Plan? best = null;
|
|
foreach (var (axis, beta) in Variants)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (best != null && solver.Work.Value >= WorkBudget)
|
|
break;
|
|
var plan = solver.Plan(demand, axis, beta);
|
|
if (best == null || plan.IsBetterThan(best))
|
|
best = plan;
|
|
if (best.Unplaced == 0 && best.Sheets.Count == 0)
|
|
break;
|
|
}
|
|
|
|
// The last sheets hold the leftovers, which is where waste concentrates; re-plan them.
|
|
best = solver.ImproveTail(best!, WorkBudget * 2);
|
|
return BuildResult(job, types, best, progress);
|
|
}
|
|
|
|
/// <summary>Shared state for one solve: job, catalog, NFP caches, effort meter.</summary>
|
|
private sealed class Solver(
|
|
NestJob job,
|
|
IReadOnlyList<PartType> types,
|
|
IProgress<NestJobProgress>? progress,
|
|
CancellationToken token,
|
|
long workBudget
|
|
) : IDisposable
|
|
{
|
|
public void Dispose()
|
|
{
|
|
foreach (var catalog in blocks.Values)
|
|
catalog.Dispose();
|
|
}
|
|
|
|
private const int MaxTail = 3;
|
|
private readonly Dictionary<double, NoFitCache> caches = new();
|
|
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
|
|
private readonly Dictionary<double, BlockCatalog> blocks = new();
|
|
|
|
private BlockCatalog BlocksFor(NestPlateStock stock)
|
|
{
|
|
var spacing = System.Math.Max(0, stock.PartSpacing);
|
|
if (!blocks.TryGetValue(spacing, out var found))
|
|
blocks[spacing] = found = new BlockCatalog(spacing, types, PairsFor(stock), Work, workBudget);
|
|
return found;
|
|
}
|
|
|
|
public WorkCounter Work { get; } = new();
|
|
|
|
private double Penalty => NestJobCost.UnplacedPartPenalty(job);
|
|
|
|
public Plan Plan(int[] demand, PackAxis axis, double beta)
|
|
{
|
|
var run = Decode(demand, axis, beta, new Dictionary<string, int>(StringComparer.Ordinal), job.Options.MaxPlates, null);
|
|
var unplaced = types.Sum(t => t.Part.Quantity) - run.Sheets.Sum(s => s.Parts.Count);
|
|
var reason = run.Reason;
|
|
if (unplaced > 0 && reason == NestJobStopReason.Completed)
|
|
reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold.
|
|
return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Takes the parts off the last k sheets (k = 1..3) and re-plans just that demand with
|
|
/// every stock forced as the first sheet, under every variant; the cheapest complete
|
|
/// re-plan that beats the current tail replaces it. Tails are small and effort is metered.
|
|
/// </summary>
|
|
public Plan ImproveTail(Plan plan, long budget)
|
|
{
|
|
var sheets = plan.Sheets.ToList();
|
|
for (var k = 1; k <= System.Math.Min(MaxTail, sheets.Count); k++)
|
|
{
|
|
if (Work.Value >= budget)
|
|
break;
|
|
var prefix = sheets.Take(sheets.Count - k).ToList();
|
|
var tail = sheets.Skip(sheets.Count - k).ToList();
|
|
var tailParts = tail.Sum(s => s.Parts.Count);
|
|
var tailNet = tail.Sum(s => NetArea(job.Options, s));
|
|
var tailDemand = new int[types.Count];
|
|
foreach (var part in tail.SelectMany(s => s.Parts))
|
|
tailDemand[part.Orientation.TypeIndex]++;
|
|
var used = prefix
|
|
.GroupBy(s => s.Stock.Id)
|
|
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
|
int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null;
|
|
|
|
Run? bestRun = null;
|
|
var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet);
|
|
foreach (var (axis, beta) in Variants)
|
|
foreach (var first in job.Plates)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var run = Decode(tailDemand, axis, beta, used, cap, first);
|
|
if (run.Sheets.Sum(s => s.Parts.Count) != tailParts || run.Net >= bestNet)
|
|
continue;
|
|
bestRun = run;
|
|
bestNet = run.Net;
|
|
}
|
|
|
|
if (bestRun == null)
|
|
continue;
|
|
sheets = prefix.Concat(bestRun.Sheets).ToList();
|
|
plan = plan with { Sheets = sheets.ToList(), Cost = plan.Cost - (tailNet - bestRun.Net) };
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
private NoFitCache CacheFor(NestPlateStock stock)
|
|
{
|
|
var clearance = System.Math.Max(0, stock.PartSpacing);
|
|
if (!caches.TryGetValue(clearance, out var cache))
|
|
caches[clearance] = cache = new NoFitCache(clearance);
|
|
return cache;
|
|
}
|
|
|
|
public bool PairFits(PartType type)
|
|
{
|
|
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
|
|
return false;
|
|
return job.Plates.Any(stock => stock.Quantity != 0
|
|
&& PairsFor(stock).TryGetValue(type.Index, out var candidates)
|
|
&& candidates.Any(pair => stock.Fits(pair.Width, pair.Height)));
|
|
}
|
|
|
|
/// <summary>Best-fit pairs for this stock's spacing, built once per solve.</summary>
|
|
private IReadOnlyDictionary<int, IReadOnlyList<PairPose>> PairsFor(NestPlateStock stock)
|
|
{
|
|
var clearance = System.Math.Max(0, stock.PartSpacing);
|
|
if (!pairs.TryGetValue(clearance, out var found))
|
|
{
|
|
// The best-fit plate filter only needs the largest sheet at this spacing;
|
|
// each packer still checks the pair against its own work area.
|
|
var sheets = job.Plates.Where(p => System.Math.Max(0, p.PartSpacing) == clearance).ToList();
|
|
pairs[clearance] = found = PairCatalog.Build(types, clearance,
|
|
sheets.Max(p => p.Size.Length), sheets.Max(p => p.Size.Width), token);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
|
|
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
|
|
/// <paramref name="first"/>, when set, forces the stock of the first sheet.
|
|
/// </summary>
|
|
private Run Decode(
|
|
int[] demand,
|
|
PackAxis axis,
|
|
double beta,
|
|
IReadOnlyDictionary<string, int> usedBefore,
|
|
int? sheetCap,
|
|
NestPlateStock? first
|
|
)
|
|
{
|
|
var remaining = (int[])demand.Clone();
|
|
var used = job.Plates.ToDictionary(s => s.Id, s => usedBefore.GetValueOrDefault(s.Id), StringComparer.Ordinal);
|
|
var sheets = new List<SheetFill>();
|
|
var net = 0.0;
|
|
NestJobStopReason reason;
|
|
|
|
while (true)
|
|
{
|
|
if (remaining.All(r => r == 0))
|
|
{
|
|
reason = NestJobStopReason.Completed;
|
|
break;
|
|
}
|
|
if (sheetCap is int cap && sheets.Count >= cap)
|
|
{
|
|
reason = NestJobStopReason.PlateLimitReached;
|
|
break;
|
|
}
|
|
|
|
var trials = new List<(SheetFill Fill, double Net)>();
|
|
foreach (var stock in job.Plates)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (sheets.Count == 0 && first != null && !ReferenceEquals(stock, first))
|
|
continue;
|
|
if (stock.Quantity is int available && used[stock.Id] >= available)
|
|
continue;
|
|
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
|
|
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
|
|
var fill = packer.Fill(remaining, token);
|
|
if (fill.Parts.Count > 0)
|
|
trials.Add((fill, NetArea(job.Options, fill)));
|
|
}
|
|
|
|
if (trials.Count == 0)
|
|
{
|
|
var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
|
|
reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
|
|
break;
|
|
}
|
|
|
|
// Look-ahead: charge whatever a trial leaves behind at the best efficiency any trial
|
|
// achieved, so a sheet that finishes the job competes fairly with a denser partial one.
|
|
var remainingArea = types.Sum(t => remaining[t.Index] * t.Area);
|
|
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12));
|
|
var chosen = trials
|
|
.Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio))
|
|
.OrderBy(t => t.Estimate)
|
|
.ThenByDescending(t => t.Fill.Parts.Count)
|
|
.ThenBy(t => t.order)
|
|
.First();
|
|
|
|
sheets.Add(chosen.Fill);
|
|
net += chosen.Net;
|
|
used[chosen.Fill.Stock.Id]++;
|
|
foreach (var part in chosen.Fill.Parts)
|
|
remaining[part.Orientation.TypeIndex]--;
|
|
}
|
|
|
|
return new Run(sheets, net, reason);
|
|
}
|
|
}
|
|
|
|
private sealed record Run(IReadOnlyList<SheetFill> Sheets, double Net, NestJobStopReason Reason);
|
|
|
|
private static NestJobResult BuildResult(NestJob job, IReadOnlyList<PartType> types,
|
|
Plan plan, IProgress<NestJobProgress>? progress)
|
|
{
|
|
var builder = new NestJobResultBuilder(job, progress);
|
|
foreach (var sheet in plan.Sheets)
|
|
builder.AddSheet(sheet.Stock, sheet.Parts.Select(p =>
|
|
(types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation)));
|
|
return builder.Build(plan.Reason);
|
|
}
|
|
|
|
private static double NetArea(NestJobOptions options, SheetFill fill)
|
|
{
|
|
if (fill.Parts.Count == 0) return fill.Stock.Area;
|
|
var left = fill.Parts.Min(p => p.Left);
|
|
var bottom = fill.Parts.Min(p => p.Bottom);
|
|
return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom,
|
|
fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom));
|
|
}
|
|
|
|
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason)
|
|
{
|
|
public bool IsBetterThan(Plan other)
|
|
{
|
|
if (Unplaced != other.Unplaced)
|
|
return Unplaced < other.Unplaced;
|
|
var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost));
|
|
if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
|
|
return Cost < other.Cost;
|
|
return Sheets.Count < other.Sheets.Count;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Deterministic effort meter shared by all packers in one solve.</summary>
|
|
internal sealed class WorkCounter
|
|
{
|
|
private long value;
|
|
public long Value => Interlocked.Read(ref value);
|
|
public void Add(long amount) => Interlocked.Add(ref value, amount);
|
|
}
|