mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 17:21:34 -04:00
The frontier packer reports its placements after each choice; the engine turns them into preview parts at most four times a second and sends them as NestProgress.BestParts with the stock work area, which desktop Auto Nest already draws on the plate view. Reporting only reads finished placements, so layouts are unchanged with or without a progress sink. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
411 lines
20 KiB
C#
411 lines
20 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Engine.Jobs.Adapters;
|
|
|
|
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 cost 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;
|
|
|
|
/// <summary>
|
|
/// One private drawing per part geometry, shared by the best-fit pair and Fill block
|
|
/// catalogs and by every solve of this instance (a pipeline's cutout trial and its ordinary
|
|
/// solve see the same geometry), so each part's best-fit search runs once. Entries live as
|
|
/// long as the job geometry they describe.
|
|
/// </summary>
|
|
private readonly ConditionalWeakTable<PartGeometrySnapshot, Drawing> drawings = new();
|
|
|
|
private Drawing DrawingFor(NestJobPart part) =>
|
|
drawings.GetValue(part.Geometry, _ => DrawingJobMapper.CreateDrawing(part));
|
|
|
|
// Instance-owned diagnostics; no process-global counters or mutable solver state.
|
|
internal Action? SheetPacked { get; init; }
|
|
internal Action<RepeatBatch>? BatchSelected { get; init; }
|
|
internal Action<int>? TailReplaced { get; init; }
|
|
|
|
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, DrawingFor, SheetPacked, BatchSelected, TailReplaced);
|
|
|
|
// 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, token);
|
|
}
|
|
|
|
/// <summary>Exercises the production physical-tail lifecycle from a test-owned incumbent.
|
|
/// This does not alter normal Solve or expose a public packing API.</summary>
|
|
internal NestJobResult ImproveTailForTesting(NestJob job, IReadOnlyList<SheetFill> physicalPlan)
|
|
{
|
|
var types = PartCatalog.Build(job);
|
|
using var solver = new Solver(job, types, null, default, WorkBudget, DrawingFor, SheetPacked, BatchSelected, TailReplaced);
|
|
var unplaced = job.Parts.Sum(p => p.Quantity) - physicalPlan.Sum(s => s.Parts.Count);
|
|
var plan = new Plan(physicalPlan, physicalPlan.Sum(s => NetCost(job.Options, s)), unplaced,
|
|
unplaced == 0 ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound, NestJobCost.UsesExplicitCosts(job));
|
|
return BuildResult(job, types, solver.ImproveTail(plan, WorkBudget * 2), null, default);
|
|
}
|
|
|
|
/// <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,
|
|
Func<NestJobPart, Drawing> drawingFor,
|
|
Action? sheetPacked,
|
|
Action<RepeatBatch>? batchSelected,
|
|
Action<int>? tailReplaced
|
|
) : IDisposable
|
|
{
|
|
public void Dispose()
|
|
{
|
|
foreach (var catalog in blocks.Values)
|
|
catalog.Dispose();
|
|
}
|
|
|
|
private const int MaxTail = 3;
|
|
private readonly SheetPreview? preview = progress == null ? null : new SheetPreview(progress, types, drawingFor);
|
|
private readonly Dictionary<double, NoFitCache> caches = new();
|
|
private readonly Dictionary<NestPlateStock, ClampObstacles> clamps = new();
|
|
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
|
|
private readonly Dictionary<double, BlockCatalog> blocks = new();
|
|
private readonly IReadOnlyDictionary<string, NestJobPart> parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
|
|
|
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, drawingFor);
|
|
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, NestJobCost.RequireFinite(run.Net + unplaced * Penalty), unplaced, reason, NestJobCost.UsesExplicitCosts(job));
|
|
}
|
|
|
|
/// <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 => NetCost(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 - (NestJobCost.UsesExplicitCosts(job) ? 0 : 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) };
|
|
tailReplaced?.Invoke(k);
|
|
}
|
|
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;
|
|
}
|
|
|
|
private ClampObstacles? ClampsFor(NestPlateStock stock)
|
|
{
|
|
if (stock.ClampZones.Count == 0)
|
|
return null;
|
|
if (!clamps.TryGetValue(stock, out var found))
|
|
clamps[stock] = found = new ClampObstacles(stock);
|
|
return found;
|
|
}
|
|
|
|
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, drawingFor);
|
|
}
|
|
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)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
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 plateIndex = sheets.Count;
|
|
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work,
|
|
BlocksFor(stock), ClampsFor(stock),
|
|
preview == null ? null : placed => preview.Report(stock, plateIndex, placed));
|
|
var fill = packer.Fill(remaining, token);
|
|
sheetPacked?.Invoke();
|
|
if (fill.Parts.Count > 0)
|
|
trials.Add((fill, NetCost(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: NestJobCost.RequireFinite(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();
|
|
|
|
var outstanding = types.ToDictionary(t => t.Part.Id, t => remaining[t.Index], StringComparer.Ordinal);
|
|
var counts = chosen.Fill.Parts.GroupBy(p => types[p.Orientation.TypeIndex].Part.Id, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
|
// Count all stock still available, not just the forced-first trial subset.
|
|
var availableEntries = job.Plates.Count(s => s.Quantity == null || used[s.Id] < s.Quantity);
|
|
// Fill has no partial/budget exit: it returns after states empty or Choose finds
|
|
// no supported placement. WorkBudget only controls later variants/block preparation.
|
|
var copies = CompletedLayoutRepeat.Limit(outstanding, counts, searchCompleted: true, availableEntries,
|
|
chosen.Fill.Stock.Quantity - used[chosen.Fill.Stock.Id], sheetCap - sheets.Count);
|
|
if (copies > 1)
|
|
{
|
|
try
|
|
{
|
|
NestJobValidator.ValidateCandidate(new PlateCandidate(chosen.Fill.Parts.Select(p =>
|
|
new NestJobPlacement(types[p.Orientation.TypeIndex].Part.Id, 0, p.X, p.Y, p.Orientation.Rotation))),
|
|
chosen.Fill.Stock, outstanding, parts);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
copies = 1; // Uncertified candidates retain ordinary allocation/final validation.
|
|
}
|
|
}
|
|
batchSelected?.Invoke(new RepeatBatch(chosen.Fill.Stock.Id, copies, availableEntries, sheets.Count,
|
|
sheets.Count == 0 && first != null));
|
|
for (var copy = 0; copy < copies; copy++)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
// Poses are immutable records over the read-only catalog; each physical sheet
|
|
// owns a separate list so tail replanning can remove/replace individual copies.
|
|
sheets.Add(chosen.Fill with { Parts = chosen.Fill.Parts.ToList() });
|
|
net = NestJobCost.RequireFinite(net + chosen.Net);
|
|
}
|
|
used[chosen.Fill.Stock.Id] = checked(used[chosen.Fill.Stock.Id] + copies);
|
|
foreach (var type in types)
|
|
if (counts.TryGetValue(type.Part.Id, out var count))
|
|
remaining[type.Index] = checked(remaining[type.Index] - checked(count * copies));
|
|
}
|
|
|
|
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, CancellationToken token)
|
|
{
|
|
var builder = new NestJobResultBuilder(job, progress);
|
|
foreach (var sheet in plan.Sheets)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
builder.AddSheet(sheet.Stock, sheet.Parts.Select(p =>
|
|
(types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation)));
|
|
}
|
|
token.ThrowIfCancellationRequested();
|
|
return builder.Build(plan.Reason);
|
|
}
|
|
|
|
private static double NetCost(NestJobOptions options, SheetFill fill)
|
|
{
|
|
if (fill.Parts.Count == 0) return NestJobCost.GrossSheetCost(fill.Stock);
|
|
var left = fill.Parts.Min(p => p.Left);
|
|
var bottom = fill.Parts.Min(p => p.Bottom);
|
|
return NestJobCost.NetSheetCost(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, bool Priced)
|
|
{
|
|
public bool IsBetterThan(Plan other)
|
|
{
|
|
if (Unplaced != other.Unplaced)
|
|
return Unplaced < other.Unplaced;
|
|
if (Priced && Cost != other.Cost)
|
|
return Cost < other.Cost;
|
|
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);
|
|
}
|