Files
OpenNest/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs
T
ajandClaude Opus 5.5 91089820ee feat(engine): group like parts in Irregular's grouped variants
After its six variants and tail re-plan, Irregular decodes the job again
with each type of three or more copies packed as one group, in a searched
order, holding back up to four copies per group that would start a ragged
row and filling them into the gaps afterward. When everything fits, the
best candidates are re-packed in a strip ending just short of the front.

The pass runs after the ungrouped search, which is unchanged, and replaces
its result only by beating it. The help tutorial's parts now end at 34.9"
instead of 38.1" on 60 x 120.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:41:33 -05:00

592 lines
29 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.
///
/// With <see cref="NestJobOptions.SearchUntilCancelled"/>, a cancellable token and a progress
/// listener, the search then keeps decoding the variants with seeded per-type weights on the
/// placement rule, reporting each better plan, until cancelled (not for one-type jobs, where
/// the weights change nothing).
///
/// Fully deterministic: no clocks or randomness influence any decision. The weights come from
/// fixed seeds 1, 2, 3, ..., so an extended search depends only on how far it got.
/// </summary>
public sealed class IrregularNestingEngine : INestingEngine
{
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).
/// The cutout packer proposal tries the same variants on a cutout.</summary>
internal 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>
/// Front directions of the grouped variants, tried after <see cref="Variants"/>: along the
/// length of the stock, and across it too when some stock is taller than it is long.
/// </summary>
internal static IEnumerable<PackAxis> GroupedAxes(NestJob job)
{
yield return PackAxis.X;
if (job.Plates.Any(s => s.WorkArea.Width > s.WorkArea.Length))
yield return PackAxis.Y;
}
/// <summary>Whether the grouped variants run; off only in tests of the ungrouped search.</summary>
internal bool GroupLikeParts { get; init; } = true;
/// <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; }
/// <summary>A tail re-plan starts, with the work it may still spend.</summary>
internal Action<long>? TailReplanning { 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,
TailReplanning);
// 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;
ReportPlan(job, types, best, progress, token);
}
if (best.Unplaced == 0 && best.Sheets.Count == 0)
break;
}
// The last sheets hold the leftovers, which is where waste concentrates; re-plan them.
var improved = solver.ImproveTail(best!, WorkBudget * 2);
if (!ReferenceEquals(improved, best))
ReportPlan(job, types, improved, progress, token);
// Like parts grouped, the groups' stragglers filling the gaps after them. These run last, on
// their own budget, so the search above is the same with or without them; a grouped plan
// replaces its result only by beating it. Like the variants, none starts once the work
// budget is spent.
var groups = GroupLikeParts && GroupedFill.Applies(types, demand);
if (groups)
foreach (var axis in GroupedAxes(job))
{
token.ThrowIfCancellationRequested();
if (solver.Work.Value >= WorkBudget)
break;
var plan = solver.Plan(demand, axis, 1.0, grouped: true);
if (!plan.IsBetterThan(improved))
continue;
ReportPlan(job, types, plan, progress, token);
improved = solver.ImproveTail(plan, solver.Work.Value + WorkBudget * 2);
if (!ReferenceEquals(improved, plan))
ReportPlan(job, types, improved, progress, token);
}
// More searching can only end in cancellation, so it needs a listener for its plans, and
// it needs demand and two or more types, or every weighted decode repeats a variant.
if (job.Options.SearchUntilCancelled && token.CanBeCanceled && progress != null
&& types.Count > 1 && demand.Any(d => d > 0))
SearchUntilCancelled(job, types, solver, demand, improved, groups, progress, token);
return BuildResult(job, types, improved, progress, token);
}
/// <summary>Spread of the seeded type weights: each is e^(±WeightSpread) at most.</summary>
internal const double WeightSpread = 0.4;
/// <summary>The placement-rule weights for one seed, one per part type.</summary>
internal static double[] SeedWeights(int seed, int typeCount)
{
var random = new Random(seed);
var weights = new double[typeCount];
for (var i = 0; i < weights.Length; i++)
weights[i] = System.Math.Exp(WeightSpread * (2 * random.NextDouble() - 1));
return weights;
}
/// <summary>
/// Decodes seed after seed, cycling the variants and grouped variants, and re-plans the tail of each plan that
/// beats the best so far; ends only when the token is cancelled.
/// </summary>
private void SearchUntilCancelled(
NestJob job,
IReadOnlyList<PartType> types,
Solver solver,
int[] demand,
Plan best,
bool groups,
IProgress<NestJobProgress>? progress,
CancellationToken token
)
{
// Grouped decodes take their turn among the variants when the job groups.
var grouped = groups ? GroupedAxes(job).ToArray() : Array.Empty<PackAxis>();
for (var seed = 1; ; seed = seed == int.MaxValue ? 1 : seed + 1)
{
token.ThrowIfCancellationRequested();
var pick = seed % (Variants.Length + grouped.Length);
var plan = pick < Variants.Length
? solver.Plan(demand, Variants[pick].Axis, Variants[pick].Beta, SeedWeights(seed, types.Count))
: solver.Plan(demand, grouped[pick - Variants.Length], 1.0, SeedWeights(seed, types.Count), grouped: true);
if (!plan.IsBetterThan(best))
continue;
// Report before the tail re-plan, which the limit may cut short.
ReportPlan(job, types, plan, progress, token);
best = solver.ImproveTail(plan, solver.Work.Value + WorkBudget * 2);
if (!ReferenceEquals(best, plan))
ReportPlan(job, types, best, progress, token);
}
}
/// <summary>Reports a new best whole-job plan; reporting never changes the search.</summary>
private static void ReportPlan(NestJob job, IReadOnlyList<PartType> types, Plan plan,
IProgress<NestJobProgress>? progress, CancellationToken token)
{
if (progress == null)
return;
CandidateProgress.ReportPlan(BuildResult(job, types, plan, null, token), progress);
}
/// <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),
job.Options);
return BuildResult(job, types, solver.ImproveTail(plan, WorkBudget * 2), null, default);
}
/// <summary>Decodes one variant with the given type weights, whole demand placeable; for
/// tests of the weighted placement rule only.</summary>
internal NestJobResult PlanForTesting(NestJob job, int variant, IReadOnlyList<double>? weights)
{
var types = PartCatalog.Build(job);
using var solver = new Solver(job, types, null, default, WorkBudget, DrawingFor, SheetPacked, BatchSelected, TailReplaced);
var demand = types.Select(t => t.Part.Quantity).ToArray();
var (axis, beta) = Variants[variant];
return BuildResult(job, types, solver.Plan(demand, axis, beta, weights), 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,
Action<long>? tailReplanning = null
) : 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<NestPlateStock, ClampObstacles> clamps = new();
private readonly Dictionary<NestPlateStock, PackBoundary?> boundaries = 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);
/// <summary>Work at which the current grouped plan stops starting new fills.</summary>
private long groupedBudget;
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);
/// <summary>
/// One sheet against another for the same demand: more part area placed first, then lower net
/// cost, then the larger compactness offcut; negative when <paramref name="a"/> is better.
/// </summary>
private int CompareSheets(SheetFill a, SheetFill b)
{
static int Compare(double x, double y) =>
System.Math.Abs(x - y) <= 1e-9 * System.Math.Max(1, System.Math.Max(System.Math.Abs(x), System.Math.Abs(y)))
? 0 : x.CompareTo(y);
var area = Compare(b.PartArea, a.PartArea);
if (area != 0)
return area;
var cost = Compare(NetCost(job.Options, a), NetCost(job.Options, b));
return cost != 0 ? cost : Compare(Offcut(job.Options, b), Offcut(job.Options, a));
}
public Plan Plan(int[] demand, PackAxis axis, double beta, IReadOnlyList<double>? weights = null, bool grouped = false)
{
groupedBudget = Work.Value + workBudget;
var run = Decode(demand, axis, beta, new Dictionary<string, int>(StringComparer.Ordinal), job.Options.MaxPlates, null,
weights, grouped);
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), job.Options);
}
/// <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)
{
tailReplanning?.Invoke(budget - Work.Value);
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;
}
/// <summary>An outlined sheet's usable material as the packer's boundary, built once per
/// stock per solve; null for a rectangular sheet, which keeps the work-area path.</summary>
private PackBoundary? BoundaryFor(NestPlateStock stock)
{
if (stock.Outline == null)
return null;
if (!boundaries.TryGetValue(stock, out var found))
boundaries[stock] = found = new PackBoundary(stock.Outline.Material(stock.PlacementOrigin),
stock.OutlineInset, stock.WorkArea, CacheFor(stock));
return found;
}
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,
IReadOnlyList<double>? weights = null,
bool grouped = false
)
{
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));
// Grouped fills pack groups from singles and pairs: Fill blocks cost far more per
// fill there and gave no denser groups.
FrontierPacker Packer(IReadOnlyList<int>? ranks, double? reach) => new(types, CacheFor(stock),
PairsFor(stock), stock, axis, beta, Work, grouped ? null : BlocksFor(stock), ClampsFor(stock), BoundaryFor(stock),
weights, ranks, reach);
var fill = grouped && GroupedFill.Applies(types, remaining)
? new GroupedFill(types, Packer, axis, stock.Outline == null, CompareSheets, Work, groupedBudget)
.Fill(remaining, token)
: Packer(null, null).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)
.ThenByDescending(t => NestJobCost.TieBreakKey(Offcut(job.Options, t.Fill)))
.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) =>
fill.Parts.Count == 0 ? NestJobCost.GrossSheetCost(fill.Stock)
: NestJobCost.NetSheetCost(options, fill.Stock, Envelope(fill));
/// <summary>Equal-cost tie-break: the fill's <see cref="NestJobCost.CompactnessOffcut(NestJobOptions, NestPlateStock, OpenNest.Geometry.Box)"/>.</summary>
private static double Offcut(NestJobOptions options, SheetFill fill) =>
fill.Parts.Count == 0 ? 0 : NestJobCost.CompactnessOffcut(options, fill.Stock, Envelope(fill));
private static OpenNest.Geometry.Box Envelope(SheetFill fill)
{
var left = fill.Parts.Min(p => p.Left);
var bottom = fill.Parts.Min(p => p.Bottom);
return new OpenNest.Geometry.Box(left, bottom,
fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom);
}
/// <summary>Ranked by unplaced parts, cost, sheet count, then compactness offcut (without salvage
/// credit every one-sheet plan costs the same); full ties keep the incumbent.</summary>
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason,
bool Priced, NestJobOptions Options)
{
private double Offcut => Sheets.Sum(s => IrregularNestingEngine.Offcut(Options, s));
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;
if (Sheets.Count != other.Sheets.Count)
return Sheets.Count < other.Sheets.Count;
var offcut = Offcut;
var otherOffcut = other.Offcut;
return offcut - otherOffcut > 1e-9 * System.Math.Max(1, System.Math.Max(offcut, otherOffcut));
}
}
}
/// <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);
}